Writing Code That Reads Like a Story
Envisioning code as a story can be a useful metaphor for helping to create clean code, which is simple and easy to reason about. In this article I will continue to build on this idea, and give more concrete examples of what I think it means to write simple, clean and elegant code. For me, this is code in which each individual component or method is so simple and easy to reason about, that skimming and reading through it is such a painless and pleasant experience that it is almost like reading through a story about a system.
The ideal scenario is when you can open a source code file, skim through it from top-to-bottom, and everything seems simple and easy to understand. It flows logically from beginning to middle and end. When code is structured like this, no one block of code is ever so complicated or convoluted that it is difficult to understand the original intent. When skimming through a source code file, a programmer should never have to stop for more than a fleeting moment to think and reason about individual methods, unless they are actively working on changing the logic in that method. This may sound like a lofty and unrealistic goal, but what it comes down to is being consciously aware of your own ability to reason about a complex codebase, and attempting to minimize the number of “WTFs?” going through your own mind in the future, and the minds of other developers who will have to work with your code.
How readable code is structured varies with the computer language with which the programmer is transcribing their mental model into code. Each computer language has different idioms, syntax and common patterns. In some languages every class must be in its own file, in others it is perfectly acceptable to have many classes defined in one single file. Nevertheless, there are a number of guidelines which can be followed in most high-level languages to help ensure code is easily readable.
Optimize for Intuitive Understanding
When a layman thinks about what a programmer does, they might imagine that we spend our days typing commands into a computer to make it do things. This is true, and on a very basic level this is what we do. But as programmers we are problem solvers, and much of our most productive time is spent modeling and solving complex problems.
When we are asked to implement a complex new feature by a client, there is not usually just a few special commands that we need to figure out and know to run. Usually what we have to do is find a way to model the problem or requirements in our mind, and find a way to translate this mental model into code. But rather than envisioning the code we write as commands we are giving to the computer to make it do something, I like to envision the code I write in higher level object-oriented languages as a direct reflection of the mental models that I have created. The code is the model, expressed in written language.
If code is the written expression of the mental model in the mind of the programmer, then it becomes very important that everything written in that expression is a true and accurate reflection of what is going on in the mental model. Anything that is out of place or inconsistent with the programmer’s beliefs about how the system works will cause unnecessary cognitive dissonance when reading and reasoning about the code. This is not unlike the cognitive dissonance that people experience when their beliefs about other systems are called into question, such as political systems. When the evidence they are presented with is irrefutable, an intelligent person will be left with no other choice than to reevaluate their beliefs about how the system works. In this case, they will need to update their mental models, and because code is a direct reflection of the programmer’s mental models, it too needs to be updated whenever their beliefs about and understanding of the system change. The failure to do so will leave logical inconsistencies in the code, and presents risk that the written expression in the code will diverge from the mental model that the programmer has built in his mind.
When the written expression diverges too far from the mental model, it becomes increasingly difficult for the programmer to reason about the code and map it to their own mental model, at which point working with the code becomes very difficult. Eventually the programmer will determine that the code no longer makes any sense, and that it must be rewritten. Rewriting the code is a suboptimal outcome, requiring a lot of time and effort to develop and express a new mental model. There is great risk that the programmer eager to rewrite the code in accordance with his own mental model does not understand everything that is happening in the current system, and will not account for those things in his new model. If he were able to develop an accurate mental model from the existing code, he would be able to work with it effectively enough to slowly adapt it for new requirements and truths about the system, rather than rewrite it.
For this reason, when writing code that reads like a story, it is of utmost importance to always be thinking about the audience, and whether they will be able to easily imagine the same things that you are imagining. If the audience has difficulty reading your story, it will not be a pleasant experience for them. They will not be able to work with it, explain the nuances to others, or adapt it in the future. When expressing your mental model of the problem in code, it should be described as accurately and succinctly as possible, with the goal that other programmers will be able to vividly imagine and recreate the same mental model that you had in their own mind. This will also help you to recreate the mental model of the problem yourself when you revisit it. In order to do this, the code should always be logically consistent with your mental model, and it should always be as easy to reason about as possible.
Order Methods In The Order In Which They Are Called
One of the basic principles I try to follow is having a logical beginning, middle and end. This is not a strict rule, but an ideal scenario. For code to be readable, the main entry points into the class or file should be near the top, the private-like methods are in the middle, and the final cleanup or end result happens near the end of the class or file. When the programmer opens a source code file or looks at a class, it should be obvious to them where the processing in that class begins. The programmer should not have to hunt through all of the methods in the class trying to figure out where it all begins. Ideally the constructor or initialization is at the top, and it is obvious in the following methods how the processing gets kicked off.
Another general guideline is to organize methods in the order in which they are called. If one method calls another, and the other method is only used in that one place, then the method should follow the one that calls it. The programmer should not have to go hunting through the file trying to find the definition for the other method, if it can be avoided.
When code is structured like this, it reduces the mental burden of having many small methods, because they are all easy to find. Each method becomes like an individual step in the process of accomplishing the goal. This is not to advocate for procedural code, but just for keeping methods organized and easy to find within object-oriented code.
Reduce The Level of Nesting
For code to be easily readable, it is necessary to reduce the levels of nesting as much as possible. When nesting more than one or two levels deep is required, it is likely that something needs to be broken out into a new method. Each level of nesting requires more mental effort to reason about. If a reader has to stop and spend much effort trying to figure out what is going on in a method with several levels of nesting, then they will not be able to skim through it quickly and intuitively understand what is going on. They will be able to figure it out if they stop and think about it, but the goal when writing code that reads like a story is to limit the amount of effort required to read and understand the story. This means keeping methods as flat as possible. When several levels of nesting are truly required or useful, the number of lines of code in each block should be limited. It is not easy to reason about a method when there are several levels of nesting, each containing numerous lines of code, which obscure the main branches of logic in the method.
One way of reducing the levels of nesting is to break out the nested code into another method. This is the most obvious way of dealing with nested code, and breaking out individual blocks code into separate methods, each doing one thing and doing that one thing well, is generally a good idea.
Another way of reducing nesting is to prefer returning early in a method when required conditions are not met. Rather than checking inputs and nesting if the input or condition is valid, it is often possible to invert the logic to return early if it is not. Not everyone would agree that having multiple exit points in a method is ideal, as it may increase the cyclomatic complexity of the method. However, for code to be as easily readable as possible, and like a list of steps that are being taken in order to achieve an outcome, I find that returning early in a method is the easiest way to reason about it.
In the example above, when the reader is thinking about whether the option is true or not, they must also be remembering that this is only happening if condition1 is also true. When returning early, the method is structured in a way such that when you are considering what happens whether option is true or not, you can assume that all prerequisites to get to that point have been met already. On some level, you are able to block out of your mind what the prerequisite conditions are. With the unnecessary nesting, you have to hold in your mind the path that was taken to get to that branch of logic.
In this simple example, it is pretty obvious which path is followed to get to the line where the “do_stuff” method is executed. But, it becomes increasingly difficult to hold that in your mind with each new nested block, and with the number of lines in each nested block. Each new line pushes the code in the true and false blocks further away from each other, until eventually with enough lines, it is not possible to see at a glance how you arrive at a certain point. This causes the reader to have to scroll back up through a method trying to figure out what the path was, and what conditions lead to a given block of code being executed. A core principle of writing code that reads like a story is to avoid anything that makes readers have to go hunting through a source code file to find other code, especially when they have to go hunting within a single method.
Do One Thing and Do It Well
Each method in the code should have one clear responsibility. It should do one thing only, and do that one thing well. For code to read like a story, you must be able to accurately and concisely describe what a method does. If you cannot accurately and concisely describe what a method does, then you may be confused about the purpose of that method. If you are unsure about it enough that you cannot accurately and concisely name it, then your readers will also be confused. They will not be able to skim through your story and get the gist of what is going on in each place, quickly and easily.
If you are absolutely sure that the method does not need to be broken up into one or more new methods, then you may need to consult a thesaurus. If you have a word that is close to, but not exactly what you are looking for, you could look it up to find synonyms or other similar words that may more accurately describe what the method does. If you still can not find an accurate and concise name, then you need to seriously reconsider what you are trying to accomplish.
When writing code that reads like a story, it is worth it to take a few extra moments to think about what you are trying to do, and find the right word to describe that. If your methods are only doing one thing and doing it well, then it should not take more than two or three minutes at the most to find the right word to describe that. Finding the right word is not about being pedantic, but about keeping the written expression as close as possible to your mental model, and making sure that your mental model is as clear as possible. If you do not take the time to find the right name for the method, you will experience a slight confusion every time you return to it.
The goal is to have everything fit perfectly into place, and eliminate any and all confusion that may arise. The two or three minutes that are saved by not taking the time to find the right name, will be lost many times over when reading the story later. You will lose this time yourself, every time you return to it and have to stop for a moment and remember what you were trying to do. It is impossible to remember the original intent for every single method that you write, if it is not accurately described. You may remember what you were trying to do with the most commonly used methods and components in your code, but as the codebase grows, you will surely start to forget all of the little exceptions and implementation details in each individual method. Your readers will also lose the two or three minutes many times over, each time they have to stop to think about what you were trying to do.
One of the telltale signs that a method is doing more than one thing is when the author has created separate sections in the method using inline comments to describe each section. If separate sections are needed, then it is likely that the method is doing more than one thing, and each section should be broken out into its own method. If the new methods created for each section follow the original method, generally in the order in which they are called, then the readers will not have to go hunting through the code to find them, reducing the cognitive burden of having more methods.
Another common culprit in some languages is anonymous functions. It is OK to use anonymous functions, and sometimes a closure is useful, in which case the anonymous function is needed. But for code that reads like a story, it is generally preferable to use a named function for non-trivial callbacks. Anonymous functions and closures are OK when the guideline that each method should do one thing thing and do it well is followed. In this case, the anonymous function is being used to help accomplish the singular goal that has been assigned to the method. If the anonymous function is being used to do non-trivial UI updates or error handling, for example, it is better to move it out into a named function. Giving the anonymous function a name allows the author to clearly express the intent of the code, without the reader having to stop and think about it by reading through the implementation details in the anonymous function.
In the example above, anyone can figure out what is going on in the less obvious code, but it requires them to actually stop and think for a moment about what is going on. Maybe not for long, but they do not have a simple, clear name or symbol to represent that step in their mental model, which makes it more difficult to reason about, and does not read fluidly.
Be Clever By Simplifying Difficult Problems
For code to read like a story, it should be simple to understand and easy to skim through. This does not mean, however, that there is no opportunity for being clever. True cleverness is being able to take a difficult problem and make the solution look as simple and obvious as possible. With truly clever code, your readers may never fully understand the difficulty of the problem which you faced.
Code that is difficult for others to understand due to various levels of nesting, advanced language constructs, and hard to decipher variable names which obscure intent is not clever. It is complicated. It is OK for an application to be complex, and to have many moving parts. With modern web applications, this is almost unavoidable. But it should not be complicated. Each individual component in the system, including each method, should be as simple and easy to reason about as possible. With a clever architecture, the individual, easy to understand components fit together in an obvious, intuitive way.
If you do have to do something so clever that it is hard to understand, it needs to be broken out and encapsulated into its own method. This will allow you to use the method name and docstring or Javadoc-style comment to express the intent. With code that reads like a story, it is OK to use terse code and clever one-liners, if and when that cleverness has been been encapsulated in its own method. This is because when skimming through the story, the reader does not have to pay close attention to the implementation details within each method. If the method is reasonably flat, follows common patterns, has a single responsibility, and intent is clearly communicated with the method name and docstring, a few lines of clever code will not be a distraction.
It is the reader’s own problem if they do not understand advanced language constructs or are not aware of more obscure library methods. They can look it up and learn. Code does not have to be dumbed down to an elementary level in order to be readable. You should strive to limit the amount of effort people have to expend worrying about or thinking about the implementation details of each method, but it is OK to assume the reader has advanced knowledge of the language you are expressing yourself in. It can be assumed that the reader has a proper understanding of the grammar and vocabulary of that language. If they do not, the time they spend learning the grammar and vocabulary is a constructive use of their time, as it will contribute to their knowledge and wisdom. Time the reader has to spend trying to figure out your intent is not a constructive use of their time, and does not benefit anyone.
It is not unlike the ideas expressed in an English language paragraph. In an academic journal, ideas may be expressed using the jargon of the discipline, and may be completely unintelligible to a layman. To another scholar in that discipline, the idea expressed may be crystal clear. Time the layman spends looking up the vocabulary used in the article will contribute to his knowledge. With enough effort, he may come to understand it clearly as well. In contrast, an article written by a non-native speaker of the language who has not yet reached proficiency level may be very difficult to understand for everyone. He may use grammar incorrectly, or slang he does not fully understand. Time spent trying to understand the semantics is frustrating, tiring and not constructive.
Code-Switch At Appropriate Points Only
Reading and writing code like a story means thinking in the computer language in the same way you think in ordinary human language. You are thinking in code. In this way, the code that you write is an expression of your thoughts, rather than commands that you are giving to the computer.
When skimming through a source code file that reads like a story, the reader thinks in both the human language and the computer language. When reading docstrings or Javadoc-style comments, they are thinking in the human language. When reading a method body, they are thinking in the computer language. When they skim through the source code file, the reader will be code-switching back and forth between the human language and computer language. If the reader is proficient in both languages, they will be able to do this naturally and effortlessly. This happens in the same way that bilingual speakers of two human languages may code-switch when communicating with each other.
Hispanics living in the United States may speak to each other in what is known colloquially as “Spanglish”, mixing both English and Spanish in their speech. They may frequently switch back and forth between the two languages, but the switching must still happen at points in the speech where it makes grammatical sense to do so. The same is true when code-switching between a human language and computer language. When docstrings or Javadoc-style comments are written in the human language, and the method body is written in the computer language with no comments, it is very easy and natural to make the switch from one language to another when reading through the file. In this case, it is like the summary is written in the human language, and the details are written in the computer language.
A readability problem occurs when the code-switching happens in places where it is not expected. Inline comments within a method body are a common source of this frustration. When thinking about a method, it is preferable that the reader is able to do so in the computer language only. The intent should be conveyed in the method name and docstring or Javadoc-style comment, and the implementation details should be described in the computer language only. With code that reads like a story, inline comments should not be necessary, and should be avoided if at all possible. They must be strictly reserved for cases where unexpected behaviour is occurring that the reader may not be aware of. Inline comments describing the obvious cause unnecessary code-switching between the computer and human languages, and are a distraction. They should not redundantly state what was already said in the computer language.
The conventions for documenting a class or method vary within each language community. The conventions used by Pythonistas may not be exactly the same as those used by Rubyists. Never-the-less, there are a few basic guidelines which should be useful for writing readable code, irrespective of language.
The first is to be consistent. Being sloppy with your summaries is an unnecessary distraction for your readers. If the style guide for your language or project says to use “action words” in your docstrings, then you should use action words. A failure to do so is an inconsistency in your code, and is a distraction for your readers. For readability, the verb tense does not matter as much, as long as it is consistent across all of your summaries. For Pythonistas, the format of an ideal docstring is described in PEP-257. For language communities which do not value consistency as much as Pythonistas do, it is still important to standardize on the format of your summaries, including tense. If the summary for one method is “Calculate foo”, then they should all follow that format, such as “Save foo”. There should not be another summary in a different format altogether, such as “This method updates the value of foo”.
The second is to start the summary with a clear and concise description which conveys intent clearly. If the class or method has cleverness in it which is not obvious or intuitive, it is OK to include this in the summary. But it must start with a clear and concise description. In this case, it is like the executive summary of what is going on in the class or method.
If the summaries follow a consistent pattern, the reader will be able to scroll through the source code file and effortlessly glance at the docstrings and get an idea of what is going on in each method. This is important because programmers spend much more time reading code than they do writing it. On any given day, a programmer may only write a couple of dozen lines of code, but spend hours reading through it. Clear and concise summaries will help them to find what they are looking for quickly when skimming through a source code file, and make hunting down bugs and problems a much more pleasurable experience.
Do What You Say You’re Going To Do
A method should always do what it says it is going to do. The method should not do what it says it is going to do, and then also do something special, in just that one special case. When a special case arises, it should be worked into the story in a way that fits naturally. It should not be hacked into the first place that works, if it does not fit with the story. A method or variable name should never lie, and new code should never be worked into a method in a way which causes it to start lying. When you tell a lie, you have to remember that lie. With each new lie that you tell, it becomes increasingly difficult to keep track of all of the lies that you have told. When your story has many holes in it, your audience will not be able to follow your story, and will know that there is something very wrong with it. Eventually you will not be able to keep track of your own lies, either. The solution to this is to just not tell lies, ever.
If a method says it is going to save something, that is all that it should do. It should not save something, and then also update the user interface. The refresh code should be in its own method, whose stated purpose is to refresh the user interface. In this way, it becomes possible to keep track of a complex story in your mind. It becomes very hard to remember all of the special cases inside each method, and interferes with the programmers ability to build a complex mental model of the application. When the special case is reworked in a way that fits naturally, it is no longer a special case, but a supported and documented feature of the system. It will have its own method name which conveys intent, and a docstring or Javadoc-style comment which can further summarize what it is doing using human language.
The most basic way to do this is to either use a setting to represent the special condition, or to make a new method which checks for it. In this way, you are able to give a readable name to the new setting or method for the condition, which conveys intent. You will also be able to fully describe the purpose of the method and which condition it is checking for using human language in the docstring or Javadoc-style comment. If the test for the condition, as well as the special handling for the condition are both broken out into separate methods with names which convey intent, it will be much easier for the reader to understand what the special condition is when reading through the original method.
If every method does exactly what it says it says it does, and nothing else, what you will be left with is many very small methods. Each one of these methods reads like an individual step in solving the puzzle. The ideal length for each method is from one to twenty lines each.
One-line methods are perfectly acceptable if they are doing one thing that they say they will do, and are doing it well. For example, one-line methods can be useful for encapsulating the logic to test for a condition. Not only do you get the benefit of readability, but you will also be able to directly test the logic for the condition that you are checking for. One-line methods can also be useful when the command that you need to run is not really the responsibility of the method that needs to run it, or if the implementation of that functionality could change in the future, outside the scope of the method that needs to run the command.
For example, to do a calculation, you may need to get an input value to use. Even if it could be retrieved with one line of code, determining where the input value comes from may not be the responsibility of the method that performs the calculation, especially if the source input value could change based on another variable. In a case like this, the one line of code could be moved out into another method whose stated purpose is to get the input value to use. This may be ideal, even if it is just a short and sweet one-liner, perhaps using a ternary operator, because you could explain in the docstring or Javadoc-style comment what value it is getting.
When methods grow to be more than a few lines long, it becomes increasingly likely that the method is trying to do more than one thing. There are some exceptions to this, such as if you need to set a number of attributes on a record before saving it. But generally speaking, if there are more than just a few branches of logic in the method, it likely needs to be broken up further. Two common symptoms of this which have already been mentioned, are multiple levels of nesting, and when separate sections of a method have been created using inline comments.
Too much abstraction can sometimes cause performance problems. In the example above, it is technically less performant to save the records to a relational database individually, rather than inserting multiple rows into the database using a single INSERT statement. Saving one model at a time using the ORM is an abstraction that helps humans to read and understand their code. The inefficiencies that can happen when using ORM technologies is a common example of object oriented languages designed to enhance human comprehension not always mapping well to other languages used by the computer.
The general rule of thumb when writing code that reads like a story is to not knowingly do anything really stupid, but to only optimize code when performance is known or likely to be a problem. Like with anything else, the performance optimization can often be abstracted out into its own method and given a name which conveys intent, and explained in the docstring or Javadoc-style comment.
When writing readable code, you should strive to limit the amount of effort required to understand the implementation details in each method. The intent of each method should always be clear to anyone else reading your story. When methods follow the guidelines for code that reads like a story, it becomes very easy to scan through a source code file and get the gist of what is going on, without thinking too much about the implementation details at each step along the way. In most cases, the methods will follow simple, easily recognizable patterns, that the reader should not have to stop to think about or try hard to understand.
Some of the basic patterns include:
- validate inputs, do something, return the result
- loop over iterable and call another method to do something with each record
- check a condition and choose which path to take if it is true or false
Being consistent with all aspects of the method definition allows the reader to immediately focus on the intent of the method. Not being consistent or not following common patterns causes readers to waste mental energy when they have to stop, think and reason about what is out of place. Any code that doesn’t follow common patterns and generally agreed upon style guides and best practices requires more mental effort to reason about. This includes all aspects of the method definition, including formatting, spelling, tense, and grammar. Misspelled words and inconsistent formatting look sloppy, and are a distraction.
The frustration experienced when reading inconsistent code is similar to the frustration experienced when reading normal English, when it is evident that the author has a wanton disregard for the rules of grammar and orthography. If the writer uses the wrong tense or misspells a word, what is said is often still intelligible, if you understand the context and stop to think about it for a moment. But stopping to think about what they are trying to say can be tiring and frustrating when it is due to bad spelling and grammar, and not because of an idea which you do not fully understand.
A fluent speaker of English should be able to read a well-written paragraph in that language, and comprehend what is being said in that paragraph, without necessarily understanding the full context of the story it came from. The same is true with computer languages. Another programmer reading the story should be able to look at a method and get the gist of what is going on in it, without necessarily having to understand the full context of everything else that came before it or that will come after it in the story. To understand the logic in one method, they should not necessarily have to understand the implementation details at other levels of abstraction in the story. The programmer should be able to know that something else is taking place simultaneously at some other location within the story, but reserve reading the details of that simultaneous event for later, when they get to that part of the story. They should be able to mentally block it out for the moment, which requires always working at the right level of abstraction.
The same programmer will have to work at several layers of abstraction within the application, and they may have a good conceptual understanding of the full-stack. But it is very difficult for any single programmer to have a full, detailed understanding of everything that is happening in a complex, modern web-application. There are so many moving parts and dependencies that it can be very difficult to keep track of everything that is going on. The same programmer who wrote the code often won’t remember all of the details of how something is implemented, just a few weeks or months later. Even if you are a genius, there is no need to make things unnecessarily difficult for yourself later, when you have to come back to it.
The key to managing all of this is to have good, clean, and simple abstractions that are easy to remember, and intuitively make sense. There are many design patterns which can be used to help manage complexity, and frameworks often introduce standardized ways of doing things. But even with the most advanced design patterns and trendy frameworks, it is still very easy to quickly make a mess of things if you are not careful. Simplicity is key, and the story should not be unnecessarily hard to follow. The composition should not be full of words that you do not understand, just to try and impress others and make yourself look smart.
As with method names, variable names should not lie either. Intent should always be clear with all class, method, and variable names. For code that reads like a story, it is preferable to use full English spelling in class, method and variable names. Short and concise naming is preferred, but not at the expense of intuitive understanding. Using full words allows the reader to read through the story in natural language, even if it is some sort of human/computer creole language that only exists in their own mind.
Abbreviated and single letter variable names interfere with the reader’s ability to read through the story naturally. They require the reader to stop and think about what the author was trying to say, which is undesirable and a distraction. The exception to this is well-known and commonly accepted abbreviations that everyone knows, and that any experienced developer with the computer language you are using would recognize without having to stop, think, and reason about it. Examples of this include using the variable name “i” in a for loop, “char” for character, or “str” for string.
If every method does just one thing that it says it does, and intent is clearly conveyed using concise but readable method and variable naming, then it is easier to remember what each method in a complex application does. If you do not remember the exact method, it will be easier to find it when skimming through the story.
Writing code that is as easy to reason about as possible allows you to keep building upon it. There are many design patterns, frameworks, and libraries that you can use to assist with writing better code. But with all of these tools, and layers upon layers of abstractions and dependencies, one thing that is often forgotten is the benefit of simplicity. Keep it simple, stupid. It is one of the most fundamental and important principles, regardless of which language and framework you are using. One of the ways to do this is by writing simple, elegant and clean code. When done right, this code can be such a pleasure to work with, that it is almost like reading through a story.