Belle
15 May 2018
By Belle

11 principles that help me write better code

Since leaving behind my day job to work full-time on Hello Code, one of my focuses has been on reading and learning a lot. I'm at the point now where I know enough to realise how bad some of my old code is, but I don't always know how to improve it. So I've been learning about generic principles and ideas that I can use to guide me in writing better code in future.

In case it's helpful to others, I wanted to share the principles and style decisions I've decided to use as my guideposts.

Since I'm only writing native iOS apps in Objective-C and Swift for now, this guide is written specifically for that use-case. Keep in mind that if you work in different areas or with different languages these ideas might not fit perfectly.

1. Pure functions

Pure functions avoid side effects, taking and returning values instead.

Principle

All functions should take all objects they need to do their work as arguments and return a value when done, rather than altering global state or creating other side effects.

For example, this function alters global state, which counts as a side effect, because it's not directly returning a value, but rather changing code outside its own scope:

// defined somewhere else in your code
let usernameLabel = UILabel()

func updateUsername(_ username: String) {
 self.usernameLabel.text = username
}

Advantages

Pure functions make it easier to reason about your code because they state up-front what objects they require (they're all defined as arguments), and they return a value when they're done. Functions that create side effects, on the other hand, require you to inspect the implementation details to figure out what will happen when you call that function. Functions that modify global state can also create bugs that are difficult to track down, since their effects are not always obvious, and can even behave differently depending on that state!

Pure functions also make your code easier to test, because everything required for each function to run is passed in as an argument. Rather than using a global singleton, for instance, if that object is passed into the function, you can pass in a mock version during tests.

2. Avoid using generic types to represent data

I used to use arrays and dictionaries all through my code to represent JSON coming from an API. Since I needed to serialise that JSON into native objects before I could work with it, it would be represented as basic arrays and dictionaries from the first time I could use it.

The problem with leaving my data this way is that the actual shape of the data is extremely unclear. I was constantly following my code through a whole series of steps and comparing to the direct API output to figure out the shape of the data I was using in a particular method, so I'd know what keys were available and what kind of values they contained. It was confusing and a big time-waster.

Using custom types lets me clearly define the structure of each type for easy reference, and adds more clarity to my code by replacing generic types with custom-named types that make it clear what kind of data they hold.

Principle

Rather than relying on generic, built-in types, create custom types to represent your specific data. These can be simple wrappers around arrays and dictionaries with more descriptive naming and documentation, custom classes, or even enums to represent specific states rather than using Bools (see Ash Furrow's post on this idea).

Advantages

Using custom types, especially with a strongly-typed language like Swift, means the compiler can help you avoid bugs and unexpected behaviour by ensuring you're always working with the exact type of values you expect. You'll also avoid coercion bugs when working with dynamic and loosely-typed data such as JSON (e.g. expecting an array but receiving a dictionary).

This principle also makes your code more clear, as the shape of your values and objects is clearly defined and documented within your codebase.

3. Write code that explains itself

I have lots of bad habits around writing comments in my code. I either don't do it enough, or I write comments in places where they're unnecessary and not where I actually need them, or I rely too heavily on comments because my code is hard to read and understand. This principle is designed to avoid over-reliance on comments and to put comments in their place—where they're most useful and necessary.

I found most of the inspiration for this idea and the examples below in this post on the Google testing blog.

Principle

Make your code more expressive, rather than relying on comments to clarify functions, variable names, etc. Use those names to express clearly and succinctly what's happening, and save comments for explaining how and why.

For example, this is more clear:

let widthPx = 50

than this, which requires a comment to explain the code:

let width = 50 // width in pixels

But don't go overboard with long variable or function names either—aim for brevity as well as clarity.

You can also add variables for extra clarity rather than combining calculations into a single complicated expression. For example:

// This is more clear:

let price = numItems * itemPrice
let discount = min(5, numItems) * itemPrice * 0.1
let finalPrice = price - discount

// Compared to this, which is harder to read and requires a comment to explain what's happening:

// Subtract discount from price.
let finalPrice = (numItems * itemPrice) - min(5, numItems) * itemPrice * 0.1

If your functions are so long they require comments to explain each step (guilty!), extracting chunks of code into pure functions (see #1) can make your code easier to read.

You can also use your code to check assumptions, rather than leaving comments to explain them. For example:

// This is more clear and easy to read:

guard let height > 0 else { return 0 }
return width / height

// Compared to this, which requires a comment:

// Safe since height is always > 0
return width / height

In the example above, the first approach is also safer, as it checks assumptions, rather than relying on those assumptions. We'll explore this idea in more detail again when talking about assertions.

Advantages

Since code can compile and run without accurate comments, it's too easy for comments to become out-of-date or incorrect as your code changes. Relying on comments makes your codebase fragile for future colleagues or future you, as those comments aren't guaranteed to be in-sync with your code.

code comments Code comments. Via imgur

And, as we all know, code is written for humans to read first, and computers to read second, so making our code clear and easy to follow is always a good idea.

Finally, this approach can help avoid regression bugs that come from misunderstanding code that's confusing, unclear, or has comments that are incorrect or out-of-date.

4. Document functions and comment often

When I am writing comments or documenting my code, I try to follow the notes below to ensure my comments are useful, and don't become just noise.

Principle

If I use the right formatting for my comments to make these parse as function documentation (both Xcode and AppCode have keyboard shortcuts to help with this), I can take advantage of my IDE's documentation pop-ups for my own code. This is really handy for quickly checking a function signature, or reading notes about how a function works. I try to make sure all of my functions include documentation to make working with that code easier on future me.

I also try to keep my future self or a stranger in mind when writing new code, and add comments to any code that they might be confused about. Comments can help explain decisions I've made, and any compromises I've made that I might forget about in future, as well as explaining any code that's tough to understand quickly.

Advantages

Documentation makes it fast and easy to use your own APIs and makes it obvious what to expect from those APIs. This makes working with your own code in future much easier and more enjoyable.

Comments can quickly clear up decisions and compromises that would be confusing or lead to future you/other developers making changes you've already decided against. They can also make it faster and easier to put code back into your head when you've been away a long time, and can help when dealing with generic data (e.g. JSON from an API) that's not obvious from the types (e.g. basic, nested arrays and dictionaries vs. strict custom models and types).

5. Single-responsibility functions

A trap I fall into a lot is giving a class or function too many responsibilities. As much as possible, objects and methods should have a single responsibility, and that responsibility should be obvious (or easily guessed) from their names.

Principle

Functions should do what they say they do—no more, no less. This means consumers of your APIs (even if that's just future you) don't have to delve into the implementation of your functions to figure out what they do, so working with your codebase is more straightforward.

Types, classes, and functions shouldn't be responsible for anything you wouldn't guess, based on their name (e.g. a NetworkManager shouldn't be creating models or updating a database—it should deal with the network and nothing else). This ties in well with our pure functions concept, as a function without side-effects usually only does one thing anyway.

Advantages

When interacting with functions (e.g. an API), I shouldn't need to understand the internal implementation of those functions in order to use them. If a function says it does x, I can be reassured that it does x and only x. And it should always do x, not only in specific circumstances.

(See more explanation and examples here: Reusability and Composition in Swift)

As well as being easier to interact with and understand, code with a single responsibility is more easily tested and refactored, whereas multiple responsibilities in a single object or method can easily led to spaghetti code that's hard to follow and reason about, and nigh on impossible to test.

6. Composition

Swift tends to be more heavily focus on using protocols (otherwise known as interfaces or traits) than Objective-C, where I tended to opt for object-oriented programming and inheritance most of the time. Usually one of these approaches (composition via protocols or inheritance via subclassing objects) is more appropriate to the situation, but since I default to object-oriented ideas, this principle is designed to remind me there's another option that's sometimes a better choice.

Principle

Using protocols to set up has-a relationships as opposed to the is-a relationships that come from inheritance enables composition—combining protocols to compose types that have particular traits. Where possible, protocol extensions can be used to create default implementations like a superclass would offer.

With composition, you can build and change your objects like LEGO, adding and removing functionality in a modular way.

rabbit duck graph This object conforms to both rabbit and duck protocols.

For example: Imagine a Bird class that has a method fly and subclasses like Finch and Magpie. Now suppose you want to make a Dragon subclass. It needs to have a method breatheFire and it also needs to be able to fly... but it's not a Bird, so you don't really want to make it a subclass of Bird just to inherit the fly method.

If you make fly and breatheFire part of two different protocols, the Dragon class can conform to both in order to use those methods.

Advantages

Inheritance can lead to a messy tree of classes. It can also lead to bad decisions in order to create objects with the functionality you want (e.g. moving a method further up the tree in order to have it inherited by the classes that need it, which then makes many other subclasses inherit it that don't need it).

Read more details and examples about composition in Swift here, including the bird/dragon example I borrowed: Reusability and Composition in Swift and here: Protocol Oriented Programming Is All About Object Composition.

7. Dependency injection

Until recently I've been very reliant on global state, which is fragile and confusing. It causes bugs, makes code hard to reason about, and is rarely the best approach. This principle insists on making use of arguments to pass around objects instead of allowing functions to create side effects that are hard to reason about.

Principle

Pass as arguments every dependency that an object requires when instantiating, rather than accessing global state or creating new objects from within the method. (There are other types of dependency injection, such as passing arguments into functions when they're called, or setting them as properties on an object or type after creating it, but I prefer to inject arguments during instantiation whenever possible, so that's what I've focused on here.)

Advantages

Requiring dependencies as arguments makes your function definitions more clear, as it's obvious what objects and values are required for the function to do its work, and where those are coming from.

Passing dependencies as arguments also helps a lot with the testability of your code, as you can easily switch out a depencdency with another when testing, passing mock objects and values as needed.

8. Plan new features end-to-end before coding

After programming for a few years, I still struggle with thinking through a new feature. I tend to get a rough idea of how something will work and start coding, only to quickly hit a roadblock I hadn't thought of and have to undo all my work. This has happened so many times that I've designed this principle to force me to spend more time planning my code so I don't have to keep undoing it.

Principle

Think through as much of the implementation and the use-case of a new feature or a major refactor before coding. Write notes, draw diagrams, use flow charts; whatever works to help you figure out how your code will be implemented and how it will interact with the rest of your codebase.

Advantages

This process helps to save time and effort that comes from constantly backtracking when new features or refactoring don't work out. It helps you realise more quickly that a particular solution won't work so you can avoid wasting time implementing it.

Better planning can also help you avoid making a mess of your codebase and/or commits by coming up with a solution that will work before coding, rather than constantly refactoring, resetting commits, or adjusting your approach partway through writing the code.

9. DRY / prioritise code reusability

Repeating code has led to both bugs and headaches for me in the past. The worst situation I've run into comes from copying code into different places in my app, so that when I want to make a change to how that code works, I have to remember all the places I've put it and change it in each one. DRY (Don't Repeat Yourself) and code reusability help avoid this situation.

Principle

Create reusable functions for any chunks of code you're using in different places, so the implementation itself isn't copied into various places in your codebase. Identify code that's logically the same but with minor changes around values, for example, and refactor these blocks out into a single function that takes the differing value as an argument.

You can also use wrapper functions to adjust the behaviour of underlying reusable functions, rather than writing several functions that are very similar. For example, I have a function that calls an API endpoint and takes a timeout argument. In most cases I want to use a default timeout (this code is in Objective-C so I can't just use a default value for the argument as I would in Swift). I use a wrapper function in most of my code that calls this underlying function, passing in a default timeout argument. In the few cases where I want to change the timeout, I can call the underlying function myself and pass in a different argument.

The implementation of the code to call the API endpoint only exists once in my code base, and I have just one extra two-line wrapper function which gives me the ability to change the timeout argument when needed.

DRY also works when defining values for things like UI. Develop a consistent UI style throughout the app by creating reusable theme elements (fonts, colours, etc.) and UI elements (pre-styled buttons, labels, etc.), defining these elements once and referencing them as needed. You can do this with a whole theming library or something as simple as a set of constants for your colour names and font sizes.

Advantages

Copy + paste can lead to subtle bugs, as well as code that's hard to read and reason about.

Copy + pasted code also requires the developer to remember all the places the same code has been pasted when making changes in future, and to manually keep that code in-sync, which is an approach that's extremely fragile and error-prone.

DRY makes code easier to test, debug, and keep in your head, because you're only writing or changing code in one place.

10. Testing

Testing is something I heard about for a long time before I ever managed to understand it enough to try it. I still have a lot to learn about testing, but I'm committed to do so because it makes me write better code (testable code is generally better in other ways too, because it tends to require a lot of the principles above) and it helps me catch bugs before my users do.

Principle

Tests are designed to make sure your code behaves as you expect. They don't care about implementation, only outcomes.

Red, green, refactor is a TDD (test-driven development) approach that starts with writing tests that fail (because you haven't written/changed any code yet), then writing code to make the tests pass, then refactoring that code to make it more simple, robust, and/or elegant, while relying on your tests to make sure you don't break what you just built. This approach ensures new features or changes come with tests, since the tests are written first.

Advantages

Tests can help you catch bugs or breaking changes as soon as you create them, which helps you ship better code. They also help you change your code with more confidence, since tests will catch breaking changes or regressions as you work.

Writing tests in advance can also improve the usability and readability of your APIs, since you're planning with testability in mind.

11. Use assertions to ensure your code is being used correctly

Developers debate whether to use assertions in only debug builds or when the app is live, since they cause your app to crash. While this isn't a nice experience for the user, assertions are generally used to catch a programmer error that's caused your app to be in a state you never expected. Either way, this approach is particularly useful during debugging, as you can very quickly and easily catch mistakes you've made when using your own APIs.

Principle

Use assertions to define your expectations about arguments or conditions that should have been met, raising exceptions if they fail. Use them to test for things like making sure arguments are what you expect before trying to work with them, or that a particular argument is present in cases where it's optional but you're expecting it to be there.

Use assertions as extra conditions on your arguments, when the type of object required in your definition doesn't ensure your code can run as expected. For example, your function takes an integer but you really need one that's not a negative and not zero.

(A more formal approach to this principle is Design by Contract, in which languages like Eiffel require you to list the pre-conditions that must be satisfied before calling a function, and the post-conditions that are met when the function is done. But this works best in a language with first-class support, like Eiffel.)

Advantages

Assertions help to enforce correct use of your APIs by yourself and other developers. You can avoid your users having a bad or broken experience by crashing in debug mode so you can find and fix programming errors before shipping.


As I said earlier, these principles are designed around my own needs, my own level of experience, and my preferences. They're also based on what's relevant to me as an iOS developer. They may or may not be relevant to you, but feel free to take what's useful and leave the rest—that's how I came up with these in the first place.