Good Code is Like Good Writing
Good writing is harder to write than it is to read; bad writing is the opposite. You can learn this by reading a lot of good and bad writing. If you have to grade student essays, you pick up on it very quickly: bad essays hurt to read.
Writing has two parts: you have to understand what you’re writing about, and you have to communicate what you understand in a clear way. If either is missing, your writing will be muddy and unclear. Again, it’s like teaching: you have to know your subject matter to teach it, but “teaching” is also a separate skill of its own that you can get better at.
Programming is the same way, because programming is about communication. More or less all code will be rewritten many times – maybe hundreds – over the course of its working life. It will spend much more time being read than being written; and even if you spend one hundred hours writing a given piece of code well, that will pay off in the time saved when refactoring and improving it, or reading it to understand the context of other code that interfaces with it.
Good writing shares several similarities with good code:
-
It is hierarchical: it is clear which ideas are subordinated to which. If a point requires a six-step syllogism to support it, the writing should make clear that that is why those syllogisms are there. If a piece of code requires six steps to accomplish a task, the structure should make it evident that that is what they are for.
-
It is sequential: there should be a clear order. Another way of saying this is that it should be fairly easy to read a program in the same order that a computer will execute it. Some declarative designs frustrate this: but computers, and by extension programming, are a way to accomplish data transformation, which is always sequential in nature.
-
As little outside knowledge as possible is required to understand it. There is a balance here, because if you assume no outside knowledge, you will have to re-define basic concepts in a way that drowns what you’re actually writing about. Choosing the right set of “jargon” – the problem-specific words that you use without defining – is an essential skill for a coder or a writer.
-
It doesn’t contain unnecessary repetition of ideas.
1. Good code has a clear hierarchy and a natural sequence
A natural sequence means that it should be possible to trace the computer’s execution path through the code without too much trouble. This isn’t to say that control flow should be completely linear, without loops, conditionals, or function calls; it would be impossible to ship software under those rules. However, the more non-linear the code is, the harder it is to follow, and the more difficult it is to prove that any given section of code runs (or does not run, or only runs once) under given input conditions. That is, it causes bugs.
John Carmack’s essay on inlined code expands on this point much better than I can, along with Gerard Holzmann’s Rules for Developing Safety Critical Code, developed at JPL; I highly recommend both.
The root idea here is that, just by reading the code, it should be quickly possible to get a general sense of all the paths the computer can take through it. Deep nesting and deep call chains are the enemy of this goal. Another way of expressing the rule is, “The code should flow roughly the same as if you were describing the process to a friend.” And if you think, “There’s no way I could explain this algorithm to someone!”… you may need to redraft that algorithm.
2. Good code has a consistent style
This is about more than just tabs versus spaces, Allman versus K&R, and all the rest of it. Is it easy for readers to guess what a function does from its name? Is it easy for writers, knowing what function they need, to guess what its name will be? Is it easy to know whether or not a given function will be okay with a NULL pointer in its parameters? Is this unhandled case a bug, or deliberate because no action is needed? A consistent style helps make sure the answer to these questions is “Yes”.
For example: in my codebases, the terms Make and New have specific
meanings. Something constructed by Make is stack-allocated,
pass-by-value, and does not contain memory that needs to be freed;
whereas something constructed by New is heap-allocated,
pass-by-reference, and has a corresponding Free function that must be
called to safely destroy it. This makes it easy to infer which objects
need special treatment and which do not.
3. Good code has next-to-no comments
It’s a counter-intuitive statement, but copious comments are a sign of
bad software for the same reason that big user manuals are a sign of bad
design. Comments are not code: they do not run. As software evolves,
they have a tendency to get out-of-date: and when they describe hidden
constraints (“the source pointer must not be NULL”), they excuse
unclear code.
The most defensible form of comment is one at the top of the program which describes its general structure or design philosophy. Those things are least likely to change and hardest to extract directly from the source code. The smaller the block of code a comment describes, the more likely it is to be a crutch: if the block was well-written, its constraints would be obvious, and the comment wouldn’t be needed.
At minimum, there should be no comments of the form “must” or “must not”; those should be converted to assertions.
4. Good code has few dependencies
Dependencies are someone else’s code. Sometimes written well, sometimes written badly, they are always written in a style which differs from yours. Style is under-appreciated: as mentioned above, it is part of what makes code readable, which means it takes time to adjust to someone’s coding style. Large dependency chains also make projects more annoying to install and increase the amount of things someone has to understand to understand the codebase. For both these reasons, they make it more difficult for engineers who have worked on one of your projects to move to another.
Aside from clarity, there is also stability to consider. Dependencies break and require maintenance; the more a project has, the more frequently its dependency graph will contain breakages – some serious, some not – due to upgraded dependencies becoming incompatible with one another. At minimum, any serious software company should be vendoring its project dependencies; preferably, the dependency count for a project should be in the single digits or at worst low dozens. Build systems which automatically pull in hundreds or thousands of dependencies (Rust and Node are particular offenders) are a road to madness as things break far downstream due to version changes – not to mention the effect on compile times.
On my personal projects, I take this philosophy to the extent of compiling code using musl rather than glibc. I’ve had too many ordinary-looking executables refuse to run on Linux systems even a single year out of date due to a glibc minor-version mismatch. It is not acceptable in a project that cares about reliability.
5. Good code rhymes when it repeats
There’s an old dictum in software engineering: “Don’t Repeat Yourself” (DRY). It is usually construed to mean something like, “if you have two similar or identical blocks of code, consider breaking them out into a function.”
I think DRY is good practice but shouldn’t be understood quite that way. A better way to think about it is, “Good code rhymes when it repeats”.
Let’s take a real example. Right now I’m working on a web app written
in C. Depending on command-line switches, it can run in either CGI or
HTTP mode. These look pretty similar – in both cases you need to ingest
enough data (method + URI + headers + body) to construct and then handle
a Request object. However, in HTTP mode this can be happening on
hundreds of requests at the same time; the headers come from a different
place (the input stream vs. the environment) and require different
processing; and so on.
Applying DRY here to mean, “abstract CGI and HTTP handling into a single process” would muddle the clarity of the code. So, they are separate. However, they are similar processes, and the code which does each looks similar: by reading one, you could infer pretty accurately what the other might look like. Good code rhymes when it repeats.
Good code is harder to write than it is to read. Good code is very hard to write. It’s worth it, though: the code itself contains fewer bugs, and since it’s easy to understand, code which interacts with it will contain fewer bugs too. As a result, the project is easier to read, easier to update, more stable, and more fun to work on. All worth aiming for.