readability (n.) – the quality of being legible or decipherable
Programmers often need to reason about unfamiliar code. By increasing the readability of our code, we communicate its intent and behaviour better, accelerating our productivity. In this post I propose two 'pillars' of code readability, and list some of the techniques they underpin.
Disclaimer: nothing in this post is objective, researched, nor backed by any evidence beyond my own experience! This is simply my own pet theory of the fundamentals of code readability; YMMV.
Reading and understanding code is a demanding task for a human: we have to read and parse it, then form a mental model of how the pieces interact, keeping everything in our (very limited) short-term memory. Increasing code readability entails reducing the cognitive load required to understand the intention and action of a program. In a collaborative environment this is critical, but even a solo programmer will benefit from writing readable code when they revisit a module written several months prior.
The Two Pillars
I propose that there are just two pillars (beyond the superficial, eg formatting & whitespace, and non-code, eg comments & documentation) by which we can increase our code's ability to communicate. All techniques for increasing code readability via its structure are ultimately an expression of one or both of these.
1. Abstraction
Human brains are naturally good at working with abstractions: we understand that a 'tree' has a trunk, branches, and leaves, but we can talk (and think) about a tree as a single entity without needing to consider its subparts or the various species of tree that exist.
It's impossible for a human to hold all of the details of a complex program in their mind at once, so abstractions allow us to compartmentalise and focus on one layer at a time. By creating abstractions we enable the brain to hide details, shifting them from short-term memory to long-term.
Abstractions are the crux of modern programming, and knowing how to design them effectively is an important skill. They also have a cost: the reader must understand what the abstraction represents before it can reduce their cognitive load. Rich Hickey covers this in his talk Simple Made Easy in which he explains that sometimes people conflate unfamiliarity with complexity – they have not taken the time to form the necessary abstractions in their mind, so the code seems unnecessarily difficult.
This is an important point that many programmers ignore in one direction or the other. Being unfamiliar with abstractions is common, so there is a balance to be struck here, highly dependent on the context of the project. Creating an 'ivory tower' of abstractions upon abstractions which only one person understands is a barrier to anyone else jumping into the code. On the other hand, limiting abstractions in your codebase hampers your most productive developers. If you are working in a team environment, investing in junior members' understanding of common abstractions will elevate the whole team's performance by removing these barriers.
2. Pruning
When we read code, we are trying to build a mental picture of what happens when it executes: we want a complete understanding of all the possible paths through the program. Since our brains can't hold an entire program at once, we have to incrementally build up our mental model, and at each step we must consider how the state is affected. The less variable state we have to keep on our 'mental stack', the fewer possibilities we need to explore.
Pruning the search space can be done by breadth (reducing the number of variables in scope) or by depth (limiting what can be done with those variables).
Readability Techniques
There are lots of ways we can leverage abstraction and pruning to increase readability. Many of these techniques can be related in some way to SOLID principles, decreasing coupling and making the code easier to change along the way.
Technique: Functional Programming
This is a broad (and deep!) category of abstraction techniques. But, even at a surface level, the simple replacement of an imperative loop with a declarative approach demonstrates the expressive power of functional programming:
Imperative:
let result = []
foreach (n in numbers) {
result.push(n+1)
}
return result
Declarative:
return numbers.map(x => x + 1)
Here we see both pillars at play: the map
operation is an abstraction which immediately informs the reader of what's happening to the numbers
collection, and thereby prunes the universe of possible code paths.
Delving a little deeper, we find that map
is an operation that can be performed on many different types, and in some languages we can use further abstractions to recombine these 'higher kinded' types.
Technique: Immutability
Most languages allow the value of a variable to be changed, which is another factor in code complexity. Languages that provide ways to guarantee immutability can help to prune the mental search space of the reader by implicitly disallowing possible code paths in which a variable has changed.
Not only does such a declaration (like const
or val
, etc) indicate to the reader that this variable's value is immutable, but it also guards against accidental mutation by further changes to the code.
Technique: Domain Types
By modelling a domain thoroughly and introducing a dedicated type for each concept, we can drastically increase readability. Domain types are useful for:
- Enforcing constraints on a primitive type (thus pruning the search space).
- Grouping related concepts (thus creating an abstraction). This is also helpful for pruning within a function call, by grouping arguments together as a parameter object.
Domain Type example: Duration
Many programs need to time or schedule events, and many libraries default to measuring time in seconds as a float
or int
. This is a burden on the reader! Not only do they need to track the variable, they also need to track the unit of measurement and ensure that it's the same unit everywhere. Many programmers will have seen something akin to LIFETIME_IN_SECONDS = 5 * 60 * 60
in a production codebase. This is needless complexity; when working with any measurement, encode the measurement in the type and perform any unit conversions at the boundary of your domain logic. LIFETIME = Duration.hours(5)
is not only more concise, but frees the reader from mentally tracking the units of an int
variable.
Technique: Appropriate Names
While this might fall into the 'superficial' bucket along with formatting, I include it here because it can't be done by a machine. Whenever we find a good name for something, we are leveraging an existing abstraction from our understanding of English.
Variable names, function names, class names – this is the low hanging fruit of readability. Naming things is renowned as a hard problem, but finding the right name to describe a concept (whether a variable, function, class or module) will guide the reader to the correct conclusion about its purpose. The English language is very rich and the thesaurus is every programmer's friend.
Technique: Small Classes, Short Functions
By keeping classes small and focused, there are fewer unrelated fields and methods available to method implementations within the class (pruning). This also applies at the function level: shorter methods will tend to have fewer parameters and local variables.
See also: (Single Responsibility Principle)
Technique: Role Interfaces
A Role Interface is a form of encapsulation, hiding the implementation details of a dependency. "Program to an interface, not an implementation" is almost the definition of abstraction, and by requiring a Role Interface (as opposed to Header Interface) for a dependency, we prune the search space of its potential interactions.
See also: (Interface Segregation Principle)
Conclusion
There are many techniques for writing readable code, and what's appropriate for each project will likely vary depending on the language, tech stack, and development team. In this post I have laid out several popular and effective techniques, showing that underpinning them are really just two pillars – those on which human cognition depends, when reading and comprehending code: abstraction and pruning.
Image credits via Unsplash:
- Cover photo - Jeremy Bezange
- Cut branch - Markus Spiske
- Pocketwatch - Pierre Bamin