We like to describe code as pretty or ugly, clean or complicated, clever or cryptic. Those aren’t useful epithets: they describe emotions about code rather than describing code itself. It’s impossible for beginners to understand that kind of language. And it gives us the impression that we understand what we are saying.
When pushed to justify our thoughts about a piece of code, we like to talk about readability. So, what makes code readable? Can the code be understood easily? Can I hold large chunks of it in my head at any given time? How quickly can I triangulate a bug? Readability speaks to all of these questions and nobody cares, if you can’t read it.
Robert C. Martin emphasizes readability in his book Clean Code:
The ratio of time spent reading vs. writing is well over 10:1. We are constantly reading old code as part of the effort to write new code.
Because this ratio is so high, we want the reading of code to be easy, even if it makes the writing harder. […]
There is no escape from this logic. You cannot write code if you cannot read the surrounding code. The code you are trying to write today will be hard or easy to write depending on how hard or easy the surrounding code is to read. So if you want to go fast, if you want to get done quickly, if you want your code to be easy to write, make it easy to read.
This is a very good place to start. But it’s very hard to measure readability even when we know it has something to do with expressiveness, or simplicity, or doing one thing well. In order to measure readability, we can:
— Ask a few experts if they find a piece of code readable. If they mostly all agree that it is, then it is.
— Pair-program and ask our pair if they understand the code we just wrote. If there is no hesitation in the response, then it’s readable.
— Follow design guidelines like “keep functions under than 10 lines” or “keep classes under 10 methods”.
All these ways to measure readability are good habits, but we still haven’t figured out how to really pinpoint what it is that makes a piece of code readable.
Of course readability will depend on each programmer’s experience. A programmer seeped in Haskell will have a very different idea of what is readable compared to another programmer used to Java. But we shouldn’t use that as an excuse and sink into readability relativism. Clearly there are things to be said about readability.
A lot of static analysis tools offer metrics on codebases (lines of code, methods per class, files per module, etc) but I’m not sure that this problem can be solved by metrics. First, we need to figure out what we mean by readability.
I’d like to explore the concept of cognitive load. I will express readability in terms of cognitive load and I will analyze a few coding patterns in those terms.
Cognitive load is inherent in any attempt to link the various concepts that we hold in working memory. If it’s easy to see the link between two concepts, then the cognitive load is low. If it’s hard to draw a link, the cognitive load is high.
Cognitive load can be high because of inherent complexity in the problem at hand or because we don’t know enough about the problem domain, or perhaps the way the information is presented is complicated. I will mostly ignore these distinctions and just assume that we want to decrease cognitive load — no matter where it comes from.
Decreasing Cognitive Load
It’s hard to list all the ways that cognitive load can be decreased. It depends a lot on context. But when it comes to reading code, I believe two general ways of decreasing the load are:
— Allow the reader to discard concepts from working memory.
— Abstract away multiple concepts into one.
We can only hold a handful of concepts into our working memory. If the code we are reading requires us to hold more than that in order to understand its functionality, we will have to do a double-take or commit some of the lines of code to more permanent memory. This is not ideal: we want to be able to read code like good prose, top to bottom, without having to go back.
If the code is written so that I can discard a few branches or scenarios from my mind as I read it, I will be able to grok it more easily. Aspects (as in Aspect-Oriented Programming) are good examples of this: if the error handling is taken care of in an aspect somewhere else in the codebase, I don’t have to worry about it as I am reading the code handling the successful scenario.
As for abstractions, they allow us to consolidate concepts into one, and therefore to make space for more concepts in our working memory. The technical term for this process is chunking. It’s easier to remember a phone number by thinking of it as 3 groups of numbers (area code + 3-digit group + 4-digit group). In the same way, an ORM layer allows us to group a bunch of complicated concepts related to databases into one. This makes it easier to grok any piece of code touching the database.
Linguistic Syntax Trees
I like to use linguistic syntax trees as an illustration of cognitive load. A good discussion of those can be found in Steven Pinker’s book The Sense of Style. Syntax trees are used to display the relationships between words. You could use other representations but trees are good enough and they come easy to programmers. As an example, the English sentence “give me a chance” would have the following tree:
Syntax tree for the sentence “Give me a chance.”
The grammar terms like noun phrase aren’t all that important to the metaphor. It’s enough to grasp intuitively that “a chance” forms a compound. And that compound is linked at the top level of the tree to another noun phrase and a verb.
A flat tree makes for a sentence that is easy to parse: antecedents and connectors are rare and the idea is straightforward. In the sentence “A big car ran over that dog” there are no antecedents, verbs have a single object, and the number of connectors is limited.
Here’s another example where the tree grows as it branches to the right. The sentence is: “Would you like paper or plastic?”. (These are all examples from Pinker’s book.)
Syntax tree for the sentence “Would you like paper or plastic?”
The sentence is easy to parse since the complexity extends to the right. There is no backtracking necessary in order to follow the flow.
The opposite of a right-branching tree is of course a left-branching tree. It’s an anti-pattern. An example would be the sentence “Admitted Olympic Skater Nancy Kerrigan Attacker Brian Sean Griffith Dies”.
Syntax tree for the sentence “Admitted Olympic Skater Nancy Kerrigan Attacker Brian Sean Griffith Dies.”
This is a difficult sentence to parse since the links between the words are only made clear at the end of each noun phrase, if not later. The reader needs to keep a lot of noun phrases in working memory before being able to chunk them into groups and draw relationships between them.
The sentence above can be simplified in various ways by moving some branches to the right. Here’s one improved version:
The attacker of Olympic athlete Nancy Kerrigan – Brian Sean Griffith – dies.
Arguably, this sentence is complicated enough that it should be split in two. We should also note that the improved version is longer than the left-branching one. Sometimes conciseness hinders readability.
The shape of a linguistic syntax tree is a good predictor of how easy to read a sentence will be. To make a sentence easy to read, we should:
— Prefer flat trees.
— Prefer right-branching trees.
I find linguistic syntax trees useful since they capture some of our instincts about code readability. They capture cyclomatic complexity and a bit more too.
Examples of Coding Patterns
Let’s have a look at a few patterns and how they could be justified by their decreasing cognitive load. I’ll use Ruby for all examples.
Use Guard Clauses
Guard clauses are one-liners at the beginning of a function that take care of edge cases. The rest of the function is then free to handle the regular case.
In the code snippet below, the first line of the divide function takes care of the edge case where y would be zero. This is the guard clause.
Guard clauses are a great example of reducing cognitive load by keeping the tree flat. Readers can file the guarded case away and don’t have to worry about it for the rest of the reading.
You could write the divide function above by using an if/else:
But the if/else structure is not as flat: it requires extra effort to parse since we have to keep the two branches in working memory.
We can flatten the tree in a similar way as guard clauses by inlining if statements. Here’s an example of an inline if:
The simplification is similar to a guard clause: the edge case is taken care of and the reader can discard that edge case from working memory.
Inlining is a simple way to reduce cognitive load. Junior programmers tend to assign every temporary structure to its own variable and they then set out to manipulate those variables. Here is an example:
Assigning a variable to every chunk is terrible for readability: it’s the equivalent of inserting a pronoun with a long-gone antecedent in a long-running sentence. The reader has to do a double-take to verify the contents of each variable. Backtracking is a major impediment to readability.
Assigning temporary values to variables can be useful for readability when the variable becomes a chunk that can be used to build a mental image of the functionality at hand. Otherwise, we can simplify the code by inlining all temporary structures.
Treat Variables As Constants
A major increase in cognitive load comes from having to keep track of the changing values of variables. A variable like index might evaluate to zero but the next time around, it will evaluate to one:
Experienced programmers will wince at the above example. There are so many things to keep track of: sum will change at very iteration of the loop; so will index; and the range needs to be created carefully (0…items.length) in order to ensure that the sum will be calculated correctly.
Here’s the same functionality with a lower cognitive load:
Here we have no variables to keep track of. As long as you’re clear what map and reduce do, you should have no problem reading this code from top to bottom without backtracking. And we are avoiding all possible off-by-one errors.
Keep If Branches Skinny
The following code incurs some cognitive load when we get to the top-level else block:
You could be forgiven for not remembering what the main if/else was about when you get to its else clause.
The cognitive load can be decreased by branching to the right. In this case, we can make the if block skinny and insert the more complicated logic in the else block:
Better to have skinny if’s than skinny else’s. It’s easier to discard a skinny if from working memory and concentrate on a heavy else.
In this case, we also flipped the condition to make it positive — which is a bonus. We could also use a guard clause to simplify further.
Any if/else block comes with some cognitive load. Branching is more difficult to grok than a linear flow. In the following example, we have to keep the if/else block in memory in order to understand what follows:
In the above example, we have to learn a lot about title without knowing how it’s going to be used. Branching later in the process would ease the burden somewhat:
The above code can be read without backtracking: we understand the purpose of the if/else block as soon as we encounter it.
To sum up, we can reduce cognitive load with if/else blocks by following these guidelines:
— Don’t use if/else blocks if possible.
— If you have to use them, branch as late as possible.
— Keep your if blocks skinny.
Write From A Single Layer Of Abstraction
This pattern is easily justified using the concept of cognitive load. Concepts that are at the same level of abstraction will be easily chunked by our brains and the code will be readable.
In Code Complete, Robert C. Martin explains:
We want the code to read like a top-down narrative. We want every function to be followed by those at the next level of abstraction so that we can read the program, descending one level of abstraction at a time as we read down the list of functions.
The following snippet shows code at varying levels of abstraction:
When reading the above snippet, we have to follow the narrative across layers. The title of the potential email as well as the delivery of it are not interesting. Better to hide as much as possible behind appropriate layers of abstractions. The interested readers can always dig through. Improved code would read:
The emailing would now occur in the notify_of method — out of sight from the current layer of abstraction. The layers of abstraction are better organized: the narrative is easy to follow. It’s possible to explain what the favorite method does without getting bogged down in the details. And it’s easy to skip low-level parts of the narrative we are not interested in.
There is more to the single-layer-of-abstraction idea, but a good rule of thumb is that lines of code should stick to the left margin. Indentation is a smell. This rule of thumb might be language-dependent but I have found it useful across many languages.
Cognitive Load Versus Code As Prose
Code that reads as prose is often touted as a good thing. Lines like “skip if number.even?” are satisfying to read and write. And this makes a lot of sense: we’re used to reading prose and some of the cognitive load of parsing code is removed if the code looks a lot like a natural language.
Intuitively, we might think that code has to be more complicated than English since we have to be a lot more precise. And we are delighted when we realize that the code is so simple that it can pretend to be English.
Frameworks like RSpec have taken this idea quite far. Here’s an example from the official site:
The prose-like qualities are pleasing. Although few programmers could discuss the return value of eq or what kind of object the it function will create. These doubts point to drawbacks in writing prose-like code.
Would re-writing the code to read like English introduce many more components to chunk? Would it simply provide synonyms to well-known symbols or well-known abstractions? If so, perhaps we should let code be code.
Not to mention that prose can be easy to process or hard to process. Code can read like good prose, or bad prose. Code-as-prose is not a sufficient criterion for readability.
We should avoid avoid vague adjectives like “clean” or “ugly” to talk about code. If we want to improve our public debate over time, we should justify our opinions precisely and rationally.
And we should spend a little more time thinking about what we mean when we say that some piece of code is readable. How can we measure readability? How can we compare two design options?
And finally, if you were swayed at all by this article, perhaps you can use cognitive load or the metaphor of linguistic syntax trees to appraise readability. If not, let’s search for some other metaphor.