Because discussions about code readability often slides into arguments about some subjective sense of æsthetics

Coding is writing source code. Much like writing prose, writing source code is about communicating. You communicate most often to fellow humans (colleagues, contributors, maintainers, reviewers) and occasionally to computers (compilers, language servers, linters). Because fellow humans have limited attention span, cognitive bandwidth, and patience, you should make reading easy.

(Arguably, a program may be compiled more often than it is read. However, whether you compile your program once or a thousand times, the same communication is repeated. And so, for our purpose, multiple communications with the compiler count as one.)

(Below, I focus on communication between the programmer and a singular reviewer. This is merely to make the examples simpler and clearer. Nonetheless, the advice should help you just as much to write readable code for other people.)

Another way in which writing code is similar to writing prose: there is a plethora of style guides telling you what to do. Very few of those style guides ever explain why you should follow their advice, just that you should.


The Sense of Style (Steven Pinker, ISBN 9780670025855) is a style guide for writing prose, with a focus on clear non-fiction prose. The book stands out amongst style guides for multiple reasons, including that it explains its advice. That is, the style guide tells you why a particular piece of advice makes sense and in which contexts it makes sense. It gives a sense of purpose to the advice and helps you determine the extent to which you should apply it.


Some time ago, I reviewed some code. I noticed a function that looked like this:

let dir =
    match dir_from_config () with
    | Some p -> p
    | None -> match dir_from_cli_arg () with
        | None -> def_dir
        | Some p -> p

And a little bit further something along the lines of:

List.iter
    (fun (formatter, channel) ->
        setup_formatter formatter
            (if is_a_tty channel then Ansi else Plain))
    [ (Format.std_formatter, Unix.stdout) ;
        (Format.err_formatter, Unix.stderr) ]

Both of these fragments are correct: they do what they are supposed to. In other words, with both of these fragments, the programmer communicates successfully with the compiler. However, the communication with a reviewer (me) fell short. And so I wanted to suggest alternatives.

Importantly, I wanted to explain what made the alternatives more readable. This is important because discussions about code, especially code readability, often slides into unfounded arguments about some subjective sense of æsthetics.

Explaining what made the alternatives more readable, I ended up paraphrasing Pinker’s The Sense of Style. And that got me thinking:
How much of the advice in The Sense of Style can be applied to code?


The Sense of Coding Style

Below is collected advice from The Sense of Style, adapted to writing source code. I also recommend reading the original: it is indirectly applicable to writing code (as shown below) and directly applicable to writing documentation and tutorials.

Preliminaries

A few things that need to be said beforehand.

What this is not

This is not a tutorial. You cannot learn to code by reading this page.

This is not an exhaustive list of dos and donts about the minutia of styling. Instead, this is a collection of pieces of general advice on writing more readable code, each with an explanation of why the advice is helpful. As such, it stands somewhere between concrete advice about readability and non-specific discourse about what even is readability.

This is not a list of pieces of advice that you can all apply in all situations. You will need to understand why each piece of advice makes sense and, in many cases, you will need to decide which piece of advice to prioritise.

Note that all the code examples are given in OCaml. However, the advice is more general: it can be applied to programs written in other languages.

Why this is important

Writing readable code is important for multiple reasons. First, it eases the life of the reviewer of your software. Making someone else’s life easy is nice.
Be nice.

Second, writing readable code helps avoid bugs. This is a take on Hoare’s famous quote:

There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.

Whilst Hoare makes the distinction between simple and complicated code, the same distinction between readable and obscure code is just as valid.
Just like simplicity, readability is a mean to an end: correctness.

Boilerplate advice

Advice surrounding a software project.

Avoid metadiscourse

Metadiscourse is when you write about the structure of the writing (e.g., “Sections 1, 2 and 3 cover … and Sections 4 and 5 cover …”) or about the process of writing (e.g., “We started by researching … for which we attempted to interview … but we were unable to because …”).

A programming equivalent to metadiscourse is the comment that plainly describe the code.

(* Sort the elements and remove duplicates *)
List.sort xs |> List.deduplicate

Another is historical anecdotes about development.

(* This function used to be exposed in the public
   interface. Now it is hidden because … *)

Note that in this second example, a comment may be necessary. However, the comment should focus on the code as it is (This function is hidden because …) and not on the trivia of development history (This function used to…).

Metadiscourse most often unnecessarily captures some of the limited attention of the reviewer. Avoid it.

Build narratives

In a project, arrange code in an order that the reader can follow. It can be general-to-specific, or big-to-small, or network-to-disk, or high-level-to-low-level, or user-inputs-to-screen-updates, or database-to-webpage. The important thing is that the reader is able to latch onto a narrative in order to ease the reading.

One way to achieve this is to include a reading guide for the whole project in your README or for a specific component in a relevant file(s). Simply add a short section explaining how that module there grabs the keyboard and translates the keycodes, then that other module there parses the keycodes into actions, etc.

Another way that the code can be thought of as a narrative is to lean on the type of narratives that your programming language supports. In imperative languages such as C, procedure names should be active verbs because they act on the parameters. In declarative languages such as Haskell, function names should be nouns or past-tense verbs because they create new values that relate to parameters in a certain way. Compare subtract(accumulator, delta) (“actively subtract delta from the accumulator”) to difference old_v new_v (“this expression evaluates to the difference between old_v and new_v”).

In languages that support multiple styles of programming, use different kinds of names for different kinds of functions. Compare Python’s someList.sort() and sorted(someList).

Balance

All the advice given on this page (above and below) should be applied only to the extent that it makes code more readable. Never apply the advice blindly, ask yourself: what does this piece of advice achieve with my code?

Not sure about a specific case? Ask a colleague’s opinion.

Ask for feedback.
Ask for reviews.
Ask for advice.

Identifiers

Naming things is hard.

Names

Whilst many style guides insist on using long descriptive names for all variables, there are situations where short names are preferable.

One such situation is with very abstract variables. For example, in a library implementing a collection, be it a list or an array or a tree, the elements of the collection are abstract. Because they are abstract, long descriptive names are very generic and abstract themselves: ‘element’, ‘item’, ‘member’, or some such. In this case, the long name does not carry more information than a single-letter name: e, x, v, or some such.

You can also introduce the naming conventions of your module.

(* In this library, the following names are used:
   [e], [x], [y], [z]: elements held in a collection
   [c], [xs], [ys], [zs]: collections
   [acc]: accumulators when traversing a collection
*)

Another situation where short names are good is for very local variables. When a variable is only in scope for a few lines, the reviewer does not need to commit the variable to a long-lived context. In this case, a short name is sufficient: quickly parsed, quickly forgotten. The shortness of the name signals to the reviewer that the variable is short-lived. For example:

match … with
| Ok v -> Some v
| Error errors ->
    iter errors
        (fun error ->
            log_error error;
            if is_fatal error then exit 1);
    None

Because v is simply converted from a positive result to a positive option, there is no need to dwell on it. Sure, successful_result would be more descriptive, but it would need to be forgotten just as soon. On the other hand, errors deserves more attention because its scope is longer and because it is more consequential. Thus, errors is given a more descriptive name.

The curse of knowledge

When you write code, you over-estimate your reader’s familiarity with both the code and its context. When you over-estimate your reader you write more obscure code.

Always consider that your reader is intelligent but unfamiliar with the libraries you are using, the concurrent processes that may be executing, the concurrency model of your scheduler, or any other such minute context.

Abbreviations:

To avoid relying on the familiarity of your reviewer, make abbreviations scarce. And for abbreviations you do use, consider introducing them at the beginning of the module.

(* In this module, some variable names are
   reserved for the following uses
   [st]: variable carrying state as defined
         in {!State}
   [cfg]: variable carrying configuration as
          defined in {!Configuration}, the
          variables [cfg_env], and [cfg_cli]
          are inferred from the environment
          and the command line arguments
          respectively.
*)

Make abbreviations in interfaces even scarcer: Even if you use st as a variable name in the implementation, the module that packs multiple functions handling such values should be spelled out State. This is because the interface is used to communicate with a larger set of people – including other programmers working on separate projects using your library as a dependency.

Abstractions:

Over-estimating your reader, you might refer to abstractions by their technical academic names. You might be satisfied with documenting a whole module with a blurb along the lines of “implements a state monad specialised to handling contexts” or a class with “a factory for cooperative threadlets schedulers”.

Using abstractions and defining your own abstractions is important. In fact, it is arguably what programming/computer science is. But readers might not know all the abstractions used in your code, nor the nomenclature surrounding them. Explain abstractions, give examples.

Functional fixity:

The more you know how to use a thing, the less you see other ways to use it. For example, you might start to see a monad as just that: an abstract way to thread data of a specific type through a computation. But even when you provide a monad library, you should still consider giving a variant of your operators that is more grounded, that reflects more what the function does rather than how it is meant to be used.

Compare the following two chunks.

(** monadic bind for the list monad *)
val bind: 'a list -> ('a -> 'b list) -> 'b list
val ( >>= ): 'a list -> ('a -> 'b list) -> 'b list
(** [flat_map xs f] is equivalent to (but more efficient
    than) [flatten (map f xs)]. In other words,
    [flat_map [x1; x2; ..] f] is equivalent to
    [f x1 @ f x2 @ ..]. *)
val flat_map: 'a list -> ('a -> 'b list) -> 'b list
(** [bind] and [>>=] are aliases for [flat_map]. They are
    provided for use as monad operators for the list monad. *)
val bind: 'a list -> ('a -> 'b list) -> 'b list
val ( >>= ): 'a list -> ('a -> 'b list) -> 'b list

This helps in multiple ways. First, for the reviewer, who might not be familiar with the specific monad they are reviewing, it splits the work into two easier tasks: verify that the function’s implementation is correct (i.e., that its behaviour matches its name and documentation) and verify that the function combines with others according to the monadic laws. By merely exporting a more explicitly named function alongside the abstractly named function, you prepare this two-step work for the reviewer.

Second, it helps users of your library to use your provided function in a non-monadic setting.

This advice applies equally to abstractions other than a monad.

Grammars

Advice about trees.

The graph, the tree, and the string

The Sense of Style goes to great lengths to explain how grammar is a way to encode parts of a graph of ideas into a string of words. Specifically, grammar allows a speaker/writer to represent a few ideas and the links between them (a graph) as a tree of grammatical constructs and then that tree as a string of words. In other words, grammar is a serialiser.

Grammar is also a set of rules for retrieving the serialised graph of ideas from the string representation. Specifically, grammar allows a listener/reader to recover, from a string of words, a tree of grammatical constructs, and then, from that tree, the ideas and links it represents. In other words, grammar is also a parser.

For example “the quick brown fox jumps over the lazy dog” is a serialisation of an idea that a certain animal with a certain coat and a certain aptitude performs a specific action in a specific location relative to another animal with a certain character flaw. The same idea can be serialised differently as “over the dog that is lazy, the fox that is fast and brown jumps” or other such variation. A human that understands English understands how to deserialise either of the sentences to recover the meaning.

As far as this encoding/decoding goes, programmers and reviewers enjoy several advantage over the writers and readers of prose.

One advantage of programming languages is that the rules of parsing are formalised. As a result, there are no ambiguous strings that may be interpreted as two distinct trees. Or if there are, then it’s neither the writer’s nor the reader’s fault: it’s the compiler’s.

Another advantage of programming languages is that programmers have much more freedom in the serialising. For example, in most programming languages, programmers can add extraneous parentheses and break line freely. Even if these syntactic options are not available, then a programmer can associate additional names to a value or break a function in multiple parts.

Finally, a big advantage of programming languages is that the tree has more importance than in prose. Reviewers have explicit knowledge of what the tree is; the tree even bears a name: the abstract syntax tree (AST). Reviewers attempt to first recover the tree, and then the process it describes.

These are two things programmers can help reviewers with: recovering the tree and inferring what process it describes.

From the pixels on a screen to the AST

A reviewer starts by parsing your code to build a mental representation of the abstract syntax tree. The first step a reviewer takes is parsing your code: reading the visual representation of a stream of bytes on the screen, and building an AST from that visual representation.

A good exploration of this topic is available in the paper “A visual perception account of programming languages: finding the natural science in the art” (by Stéphane Conversy).

The paper explains how a reader reads code, what visual processes it involves, etc. It also explains how certain editor features (such as syntax colouring) or writing conventions (such as indentation) can be helpful to the reader. All in all, this paper gives a good framework for talking about the pixels-to-AST conversion.

Indentation:

One way that the programmer can help the reviewer is to match the visual representation to the AST. For example, indenting the code gives an easy way for the reviewer to asses the general shape of the AST. The number of blank pixels on the left-hand-side of each line gives an indication of the shape of the program.

However, matching the indentation to the AST depths only addresses half of the indentation issue. The other half of the issue is to shape the AST correctly. Shaping the AST correctly makes for a simpler indentation pattern (as shown in the example below). Additional (non indentation-related) advantages of shaping the AST correctly are explored later.

In general, flat or end-heavy trees are easier to read because they minimise the distances that separate related things. Consider the three examples below which have equivalent but distinct ASTs.

As a beginning-heavy tree, finding the error-printing that matches the error condition requires scanning through many empty lines.

if time < now then
    if not (is_full buffer) then
        if valid_entry input then
            if is_unknown_entry input then
                insert_new_entry input
            else
                update_entry input
        else
            print_error "Input is invalid"
    else
        print_error "Buffer is full, cannot process"
else
    print_error "Update is in the future"

As a mixed-heavy tree, it is not immediately clear what are error conditions versus valid conditions, nor where the branches lead to.

if time < now then
    if is_full buffer then
        print_error "Buffer is full, cannot process"
    else if valid_entry input then
        if is_unknown_entry input then
            insert_new_entry input
        else
            update_entry input
    else
        print_error "Input is invalid"
else
    print_error "Update is in the future"

As a end-heavy tree, each error message follows immediately its error condition and the control-flow is immediately obvious.

if now <= time then
    print_error "Update is in the future"
else if is_full buffer then
    print_error "Buffer is full, cannot process"
else if not (valid_entry input) then
    print_error "Input is invalid"
else if is_unknown_entry input then
    insert_new_entry input
else
    update_entry input

Note that in many cases, end-heavy trees can be indented as flat trees. This last example is one such case. Sequences of binding are another.

Syntactical parallelism:

When two functions do similar things, they should have similar names. When two variables hold similar values, they should have similar names.

For example, avoid mixing snake and camel case in a single project – or, if you do, do so to highlight fundamental differences between two kinds of values. But also avoid having a filter_elements_out_of_list (active verb) along with a collection_filter (noun-phrase): if the two have the same effect, their naming should follow similar conventions.

In general, when things are alike, their textual representation should be similar. This is the syntactical pendant to structural parallelism (see below for details).

From the AST to groking the code

After parsing the AST in their head, a reviewer then tries to understand what the AST means:
How does control flow from one place to another?
How does data flow from one place to another?
How does a given part of the tree transforms a given piece of data?
How does an error propagates through the tree?

Whilst building an understanding of the program, the reviewer needs to maintain a working memory of the context of the code they are reviewing. For example, when reading a line, they need to have an idea of what variables are in scope and what their values represent, under what conditions can the program reach that line, etc. To ease the review, programmers can shape their trees in a way that reduces cognitive workload for the reviewers.

The depth and width of trees:

In order to reduce the cognitive workload of reviewers, prefer flat-and-wide trees to deep-and-narrow trees. This is because, in general, the conditions necessary to reach a deep node of the AST are more intricate.

(Remember that, as explained above, end-heavy trees are similar to flat-and-wide trees.)

Look back at the indentation examples above and notice how the end-heavy tree is easier to parse. In particular, consider what information you need to keep in mind when reviewing this tree. The conditions necessary to reach a given line is a conjunction of simple boolean expressions that appear above it. That conjunction grows regularly as you read more and more lines.

Conversely, the beginning-heavy version requires more effort. Whether you take a line-by-line approach (in which you must first build up a conjunction of expressions, and then unwind it carefully one after the other in LIFO order) or a condition-by-condition approach (in which you read a condition and immediately move your attention to the else-branch before coming back to the then-branch), you need to keep more context in mind.

The order of branches:

If you do have to include some deep trees, then prefer end-heavy trees. The heaviness being at the end means that the reviewer can understand the beginning part, commit that understanding to memory, and then move on to understand the rest. By contrast, a tree that is middle-heavy requires a lot of stack unwinding from the reviewer.

Refer back to the indentation examples above and work through how your focus has to move through the text whilst you attempt to understand them.

For the same reason, if you find yourself with a branch that requires a comment (because it is too hard to understand without it), then this branch should be placed at the end.

Another way to order branches is given-before-new (a.k.a. known-before-unknown): finish processing related data before processing the next bit. In the example below, note how the order of the branches places the processing (process_with_…) near the respective queries (find_…).

match find_metadata_date picture with
| Some date -> process_with_metadata_date picture date
| None -> match find_file_creation_date picture with
    | Some date -> process_with_file_date picture date
    | None -> fail "cannot find date for picture"

Another factor to consider when ordering branches is normal-before-exception. In general, inputs can be categorised into two: valid and expected inputs, and invalid or unexpected inputs. Thinking about invalid/unexpected inputs has a greater cognitive load because it requires having to think about corner cases, unusual conditions, etc.

How to reconcile these rules? When you have a simple exception handling and a complex normal case, do you put the normal (complex) branch first or the simple (exception) branch first? You can try both and assess for yourself or ask a colleague to help. You can also move part of the processing into a separate function with a descriptive name.

let retreive_information key =
    let connection = connect_to_server config.server in
    let request = get connection key in
    let raw_result = wait request in
    let result = parse_raw_information raw_result in
    result

let retreive_information key =
    try retreive_information key
    with e -> log e; exit 1

More generally, you can use the abstractions available in the programming language –or even introduce your own– so as to order your branches in a way that follows the different pieces of advice above.

Consider an error monad. Specifically, consider the program below and how the error monad pushes the exception-handling code at the end. This helps the reviewer to temporarily tune-down error management: first review the chain of calls (open_file, read_content, split_into_lines), and only later review the handling of errors.

let ( >>= ) v f = match v with
    | Ok ok -> f ok
    | Error error -> Error error

let catch_all f v =
    try Ok (f v) with e -> Error e

let lines_of_file file =
    match
        catch_all open_file file >>= fun handle ->
        catch_all read_content handle >>= fun content ->
        Ok (split_into_lines content)
    with
    | Ok lines -> lines
    | Error exc -> log_exception exc; exit 1

Structural parallelism:

When two functions do similar things, their AST should be shaped similarly. When two variables hold similar values, the AST of the expressions that initialises them should be shaped similarly.

In many cases, avoiding repetitions is easy: two functions that do similar things can often be made into a single function that accepts an additional parameter. Even when factoring the two functions into one is not possible, parts of them can sometimes still be factored out into a third function. Because of the relative ease with which programmers can avoid repetition, they tend to try to always avoid repetition.

However, repetition is fine – especially when it simplifies the code.

For example, consider some limitation of a programming language that prevents the factoring out of two functions. Let’s say that we cannot use any fancy features due to backwards compatibility with early versions of the language or compatibility with some other compiler. Let’s say, specifically, that we need to duplicate a function to work on two different kinds of collections: lists and arrays.

let summarize_list l =
    let sum = List.fold_left (+) 0 l in
    let len = List.length l in
    if len > threshold_length then
        skewed_average (List.nth l 0) sum len
    else
        sum / len

The array counterpart to this list function should look very similar. It should, as much as possible, be a simple substitution of module identifiers.

let summarize_array a =
    let sum = Array.fold_left (+) 0 a in
    let len = Array.length a in
    if len > threshold_length then
        skewed_average a.(0) sum len
    else
        sum / len

The similarity in the structure of the function’s tree point to the similarities in the meaning of the function. Understanding the second version becomes a simple game of “spot the difference”. And a reviewer can easily cheat at such a game using tools such as diff.

Use positives

Avoid not in your conditional. Prefer length collection > threshold rather than not (length collection <= threshold). The former is simpler to parse than the latter.

Applying this piece of advice is often impossible because libraries don’t always export the needed values. Indeed, it is common for collection libraries to export is_empty without has_at_least_one_element.

Exporting multiple values without increasing the expressive power of a library is often frowned upon: it bloats the interface, it increases the footprint of the namespace, it requires additional maintenance.

Apply this advice when you can, but do not fret when you cannot. In general, it is less important than ordering the branches of your conditional.


Let’s look back at the examples from the introduction, the pieces of code that spun this whole thing. We can apply the advice above to make the code more readable.

Chaining options:

The original parsing of the directory option looked like this.

let dir =
    match conf_dir () with
    | Some p -> p
    | None -> match cli_dir () with
        | None -> def_dir
        | Some p -> p

It is difficult to read for several reasons:

More readable versions follow. In the first improved version, we expand the variable names and we reorder the branches in the match clauses.

let directory =
    match directory_from_configuration () with
    | Some path -> path
    | None -> match directory_from_command_line () with
        | Some path -> path
        | None -> default_directory

Note that the indentation can be confusing. Indeed, whilst syntactically the expressions are nested, conceptually they are more sequential.

let directory =
    match directory_from_configuration () with
    | Some path -> path
    | None ->

    match directory_from_command_line () with
    | Some path -> path
    | None ->

    default_directory

An even more readable version uses an abstraction to remove the repetitive boilerplate.

(* [>||>] separates multiple optional values and returns
   the left-most one (i.e., the first one) that is
   not [None].
   More formally: [None >||> f] is [f ()]
   and [Some v >||> _] is [v]. *)
let ( >||> ) o if_none = match o with
    | Some v -> v
    | None -> if_none ()

let dir =
    dir_from_configuration ()
    >||> fun () -> dir_from_command_line_argument ()
    >||> fun () -> default_dir

Initialisation:

The original code setting up the formatters was as follows.

List.iter
    (fun (formatter, channel) ->
        setup_formatter formatter
            (if is_a_tty channel then Ansi else Plain))
    [ (Format.std_formatter, Unix.stdout) ;
      (Format.err_formatter, Unix.stderr) ]

The use of List.iter here is unnecessary. It actually complicates the code both from a runtime point-of-view (the list with its tuples is allocated and then traversed dynamically) and from a syntactical point-of-view (the AST is deeper than the List.iter-less version is wide).

The choice to use List.iter seem to have been driven by the desire to avoid multiple syntactic calls to setup_formatter, to avoid syntactic repetition. However, repetition is completely acceptable and even makes sense when used reasonably and consistently:

let mode_of_channel channel =
    if is_a_tty channel then Ansi else Plain in
setup_formatter Format.std_formatter (mode_of_channel Unix.stdout);
setup_formatter Format.err_formatter (mode_of_channel Unix.stderr)