How to Create Readable Code

The ease of editing sounds like the best reason.

Requirements change, features get added, bugs appear, and eventually someone will need to edit our code.

In order to edit it without causing issues, they need to understand what they’re editing and how their edits will change the behavior.

This gives us a new heuristic: Readable code should be easy to edit safely.

What makes code easier to edit?At this point, we might feel a compulsion to rattle off rules again.

“Code is easier to edit when the variable names are expressive.

” Nice try, but all we’ve done is rename “readability” to “ease of editing.

” We’re looking for new insights here, not the same rule-by-rule memorization in a fake mustache and wig.

Let’s start by setting aside the fact that we’re talking about code.

Programming has been around for a couple decades, a dot on the timeline of human history.

If we restrict ourselves to that dot, we’re drawing our ideas from a shallow well.

Instead, let’s look at readability through the lens of interface design.

Our lives are filled with interfaces, digital and otherwise.

A toy has features that make it roll or squeak.

A door has an interface that lets it open, close, and lock.

A book arranges data in pages, allowing for faster random access than a scroll.

Formal design training tells us even more about these interfaces; ask your design team for more information.

Failing that, we’ve all used good interfaces, even if we don’t always know what makes them good.

Code creates interfaces.

But the code itself, together with its IDE, is also an interface.

This user interface is aimed at a very small population of users: our teammates.

For the rest of this post, we’ll refer to them as “users,” to stay in the headspace of UI design.

With that in mind, consider some sample user flows:The user wants to add a new feature.

To do this, they must find the right spot and add the feature, without also adding bugs.

The user wants to fix a bug.

They’ll need to find the source of the bug and edit the code so it stops happening, without introducing different bugs.

The user wants to verify an edge case acts a certain way.

They’ll want to find the right code, then trace through the logic to simulate what would happen.

And so on.

Most flows follow a similar pattern.

We’ll be looking at concrete examples for ease of understanding, but remember, we always want to keep the general principles in mind, rather than falling back to a list of rules.

We can assume that our users won’t be able to beeline to the right code.

This goes for hobby projects too; it’s easy enough to forget the location of a feature, even if I’m the one who originally wrote it.

So our code should be searchable.

If we’re supporting search, we’re going to need some SEO.

Expressive variable names come in here.

If the user can’t find a feature by moving up the callstack from a known point, they can start typing keywords into search.

Now, not every name needs to have every keyword.

When our users search for code, they only need to find a single entry point and can work outward from there.

We need to get them close to where they want to be.

Include too many keywords, and they’ll be frustrated by noisy search results.

If the user can immediately convince themselves that ‘this level of logic is correct,’ they’re able to forget all previous layers of abstraction, freeing up mental space for subsequent layers.

Users may also search via autocompletion.

They have a general idea of what function they need to call or what enum case they want to use, so they’ll start typing and pick the autocomplete that makes sense.

If a function is only meant to be used in specific cases or has caveats that require careful reading, we can signal that with a longer name.

When the user is reading the autocomplete list, they’ll typically avoid the complicated-looking option unless they know what they’re doing.

Likewise, short, generic names are likely to be viewed as the default option, suitable for casual users.

Make sure they don’t do anything surprising.

We shouldn’t put setters into simple-looking getters, for the same reason a customer-facing UI wouldn’t show a “View” button that mutates their data.

In a customer-facing UI, common options like pause have little to no text.

As options get more advanced, the text gets longer, prompting users to slow down.

Screenshot: PandoraAnd they’ll want to find that information at a skimming pace.

In most cases, compiling takes time, and running may require manually visiting many, many different edge cases.

When possible, our users would prefer to read the code’s behavior, rather than throwing in breakpoints and running the code.

To skip running, they need to satisfy two conditions:They understand what the code is trying to do.

They’re confident that it’s doing what it claims.

Abstraction helps satisfy the first condition.

Users should be peeling back layers of abstraction until they reach their desired level of granularity.

Think along the lines of a hierarchical UI.

We allow users to make large navigations first, then more precise ones as they get closer to the logic they want to read in detail.

Sequentially reading through a file or method takes linear time.

As soon as the user can click up or down through callstacks, they’ve switched to doing a tree search.

Given a well-balanced hierarchy, that only takes logarithmic time.

Lists have their place in UIs, but consider carefully whether a single context needs to contain more than a couple of method calls.

Hierarchical navigation is much quicker when there are fewer options in each menu.

The ‘long’ menu on the right has only 11 rows.

How often do we write methods with more lines?.Screenshot: PandoraFor the second condition, different users have different strategies.

In low-risk situations, comments or method names may be sufficient proof.

For riskier, more complicated areas, or when users have been burned by stale comments, they’re likely to be ignored.

Sometimes, even method and variable names will be met with skepticism.

When this happens, the user has to read much more of the code and hold a larger model of the logic in their head.

Small, easy-to-grasp contexts come to the rescue again.

If the user can immediately convince themselves that “this level of logic is correct,” they’re able to forget all previous layers of abstraction, freeing up mental space for subsequent layers.

When the user is in this mode, individual tokens start to matter more.

An element.

visible = true/false bool flag is easy to parse in isolation, but it requires mentally combining two different tokens.

If instead, the flag is element.

visibility = .

visible/.

hidden, contexts involving the flag can be skimmed, without having to read the name of the variable to find out that it’s about visibility.

¹ We’ve seen the same design improvements in customer-facing UIs.

Over the past couple of decades, confirmation buttons have evolved from OK/Cancel to more descriptive options like Save/Discard or Send/Keep Editing.

The user can figure out what’s going on by looking at the options themselves, rather than needing to read the whole context.

At a glance, the top ‘Offline Mode’ banner shows our current state.

The bottom toggle conveys the same information, but only after looking at the context.

Screenshot: PandoraUnit tests can also help us past the proof-of-behavior condition.

They act as more trustworthy comments because they’re less vulnerable to staleness.

This still involves a build.

However, any team with a good CI pipeline has already run the tests, so the user can skip this step for existing code.

In theory, safety comes from understanding.

Once our user understands the current behavior of the code, they should be able to edit it safely.

In practice, engineers are human.

Our brains take the same shortcuts as anyone else’s brain.

The less we have to understand, the safer our actions will be.

Readable code should offload most of the error-checking to a machine.

Debug asserts are one way of doing this, though they require building and running.

Worse, they may not catch edge cases if the user forgets about that path.

Unit tests can be better at exercising commonly forgotten edge cases, but once the user has made changes, they also require time to run.

In short, readable code must be usable.

And maybe, as a side effect, it’ll look pretty too.

To get the fastest turnaround time, we use compiler errors.

These rarely require a full build and may even appear in real time.

How do we take advantage of them?.Broadly, we want to look for situations where the compiler gets very strict.

For example, most compilers don’t care if an “if” statement is exhaustive, but will carefully check “switch” statements for any missing cases.

If a user is trying to add or edit a case, they’re safer if all the previous conditionals were exhaustive switches.

The moment they change the cases, the compiler will flag all the conditionals they need to reexamine.

Another common readability problem is using primitives in conditionals.

Especially when an application parses JSON, it’s tempting to write lots of if-statements around string or integer equality.

Not only does this open the door for misspellings, but it also makes it challenging for users to know which values are possible.

There’s a big difference between having to check the edge cases when every string is possible and checking the edge cases when two-three discrete cases are possible.

Even if primitives are captured in constants, the user is one impending deadline away from assigning an arbitrary value.

If we use custom objects or enums, the compiler blocks invalid arguments and provides a clear list of valid ones.

Similarly, prefer a single enum over multiple bool flags if some flag combinations are invalid.

For example, imagine a song that can be buffering, fully loaded, or playing.

If we represent that as two bool flags, (loaded, playing), the compiler permits the invalid input (loaded: false, playing: true).

However, if we use an enum, .

buffering/.

loaded/.

playing, the invalid state is not even possible.

“Disable invalid combinations of settings” would be a basic feature in a customer-facing UI.

But when we’re writing the code inside the app, we often forget to grant ourselves the same protection.

Invalid combinations are disabled before interaction; customers don’t have to think about which configurations are inconsistent.

Screenshot: AppleOver the course of this user flow, we’ve arrived at the same rules we had at the start.

But now we have a process for generating and customizing them.

We ask ourselves:Will this code be easy for users to find?.Will it clutter search results for unrelated features?Once found, can the user quickly confirm the code’s current behavior?Can users lean on machine validation to edit or reuse this code safely?In short, readable code must be usable.

And maybe, as a side effect, it’ll look pretty too.

FootnoteBooleans may feel more reusable, but reusability implies interchangeability.

Imagine two flags, tappable and cached.

They represent concepts on completely different axes.

But if both flags are boolean, we can casually swap between them, sneaking non-trivial statements (“caching is related to tappability”) into a tiny line of code.

With enums, we’re forced to create explicit, testable “unit conversion” logic whenever we want to form this kind of relationship.

.. More details

Leave a Reply