SKIP TO CONTENT

Good Software Engineers Write Code That Minimizes Cognitive Load

It's not enough to write code that works. An important role in software engineering is to write code that is easily maintainable. And code cannot be easy to maintain if it is not also easy to read. One of the ways good software engineers do this is by paying attention to how much cognitive load their code induces on its readers.

Imagine this:

It's a busy Tuesday afternoon in the office. You just got done with 3 grueling back-to-back meetings. 3:47pm - not quite late enough to leave, and you don't have much energy left to start any deep focus work. So you decide to spend the rest of the day reading through a new code base that you'll need for next week.

You checkout the repository, laptop fans roaring as it indexes the project. A few minutes pass and finally you open up the first file and see this:

pub fn converting_request(r: Option<ApiRequest>) -> Result<HttpRequest, Error> {
    match r {
        Some(req) => {
            let mut http_r = HttpRequest::new();
            if valid_version(req.version) {
                if required_inputs(req) {
                    for f in req.fields {
                        match f.input {
                            Some(in) => {
                                http_r.headers_mut().insert(f.name, f.input);
                                // ...
                            },
                            None => {
                                if !OPT_FIELDS.contains(f.name) {
                                    return Err(Error::new("bad input"));
                                }
                            }
                        }
                    }
                } else {
                    return Err(Error::new("bad input"));
                }
            } else {
                // check legacy clients
                if req.client.id == "LegacyClient" || req.client.id.starts_with("Test") && TEST_TOKENS.contains(req.token) {
                    // ...
                } else {
                    return Err(Error::new("version error"));
                }
            }
        },
        None => {
            if CONFIG.ENV == Env::Beta || CONFIG.ENV == Env::Dev {
                match CONFIG.ENV_VARS.get(ENV_VAR_SECRET_KEY) {
                    Some(secret) => {
                        if secret_check(secret) {
                            let mut hr = HttpRequest::default();
                            if CONFIG.ENV == ENV::Beta {
                                *hr.uri_mut() = "https://beta.endpoint.com".parse().unwrap();
                            } else if CONFIG.ENV == ENV::Dev {
                                *hr.uri_mut() = "https://dev.endpoint.com".parse().unwrap();
                            } else {
                                return Err(Error::new("secret error"));
                            }
                            return Ok(hr);
                        } else {
                            return Err(Error::new("secret error"));
                        }
                    },
                    None => {
                        return Err(Error::new("converting error"));
                    }
                }
            } else {
                return Err(Error::new("error"));
            }
        }
    }
}

Head in your hands, you close your laptop, quietly pack your bag, and nonchalantly head towards the elevator.

End Scene

Even for someone who is familiar with Rust syntax, trying to understand the code above requires an unnecessary amount of effort. It's an exaggerated example, but code repositories can often devolve into similar hard-to-understand states just by virtue of multiple people contributing. Context gets lost over time, new conditionals get tacked on to existing if-else statements, time pressure convinces us it's okay to "temporarily" copy-paste code, etc.

So how do good software engineers alleviate this problem? They minimize, intentionally or otherwise, how much cognitive load is required to read their code.

What Is Cognitive Load?

Simply put, cognitive load is how much information someone can hold in their short term memory at any given moment. For instance, most people can do simple addition in their head:

4 + 9 = ?
54 + 63 + 3 = ?

But most will struggle if asked to add together many numbers at once:

12 + 398 + 2 + 84 + 7664 + 49 + 499 + 33 + 6 + 254 = ?

For coding, the most common way to exhaust someone's cognitive load is by writing complex, highly-nested conditional logic. Code organization and style also play a role, though often to a lesser extent.

Writing Readable Code

My methodology for writing readable code is as follows:

  1. Write working code first. Don't worry about anything else
  2. Review the code I just wrote
  3. Ask myself, "Will I easily understand this code 6 months from now?"
  4. If the answer is "no", refactor / clean up the code
  5. Go back to step 2

This process of self code reviewing might seem tedious, but I guarantee that all good software engineers do this to some extent. If you do this enough times, you'll find that you naturally start writing readable code right off the bat and reduce the time spent on refactoring.

A common trap that software engineers, especially newer ones, tend to fall for is hyper-optimizing for performance. With the kind of hardware we have nowadays, bending over backwards just to save a few microseconds of runtime is usually not worth the effort and loss in readability. In my experience, companies would much rather have maintainable software than have the most performant.

Tips For Minimizing Cognitive Load

I'll be the first to admit that the definition of "readable code" is subjective. That's part of what makes it difficult - there are no steadfast rules, only shifting guidelines based on popular opinion.

That being said, here are some of my (completely biased) tips for improving code readability to minimize cognitive load. Do note that these tips do not cover anything on code design, organization, or extensibility, which are the other important parts of writing maintainable software (perhaps in a future blog post).

Avoid Indentations When Possible

Indentations are a compounding expense to our cognitive bandwidth. Visually, deeply nested code makes it easy to lose your place while reading code. IDEs do make it a bit easier to navigate, but code is not always read in a feature-full editor (for instance, peer code reviews often happen inside a web browser like a git website). But more importantly, deeply nested code is a sign of increased complexity, which directly correlates with increased cognitive load.

One of the best techniques for reducing indentations is by inverting if statements. Here's a simple example of what I mean:

fn process_file(file_path: String) {
    if file_exists(file_path) {
        // imagine
        // a lot
        // of code
        // here
    }
}

vs

fn process_file(file_path: String) {
    if !file_exists(file_path) {
        return;
    }

    // imagine
    // a lot
    // of code
    // here
}

This technique not only saves on indentations but also makes it crystal clear what the exit cases are by explicitly handling them. If you look back at the first bad code example, notice how the error cases are scattered throughout the function, which makes it much harder to track.

The concept of exiting early can also be used to eliminate the need for else if and else statements (most of the time). This isn't as important for readability, but I figured I'd mention it since it's an easy optimization once you recognize it:

fn elo_to_rank(elo: u32) -> Rank {
    if elo >= 2500 {
        return Rank::GrandMaster;
    } else if elo >= 2400 {
        return Rank::InternationalMaster;
    } else if elo >= 2300 {
        return Rank::FideMaster;
    } else if elo >= 2000 {
        return Rank::CandidateMaster;
    } else {
        return Rank::Untitled;
    }
}

vs

fn elo_to_rank(elo: u32) -> Rank {
    if elo >= 2500 {
        return Rank::GrandMaster;
    }
    if elo >= 2400 {
        return Rank::InternationalMaster;
    }
    if elo >= 2300 {
        return Rank::FideMaster;
    }
    if elo >= 2000 {
        return Rank::CandidateMaster;
    }
    Rank::Untitled
}

It may seem obvious when laid out plainly like this, but you'd be surprised how many extraneous else statements I see out in the wild, even from seasoned software engineers.

Helper Functions

Helper functions play 2 crucial roles in readability:

  1. Breaking up large functions into smaller ones. Smaller functions means less things to think about at one time
  2. Labeling functionality with descriptive names via the function name

If you've been in the software space for some time, #1 will make a lot of sense - the concept of breaking down large work into smaller ones is something you learn early on in this field.

What might not be so obvious is that we can use the helper function names to provide a more qualitative description of the functionality it encapsulates. This is particularly helpful when complex code is unavoidable - it turns hard-to-read code into almost like a paragraph, or a table of contents, that you'd find in a book:

fn is_valid_username(username: String) -> bool {
    if exceeds_size_limits(username) {
        return false;
    }
    if contains_illegal_chars(username) {
        return false;
    }
    if starts_with_illegal_chars(username) {
        return false;
    }
    if contains_block_listed_words(username) {
        return false;
    }
    if is_taken(username) {
        return false;
    }
    true
}

Even a non-technical person would be able to understand this code because it reads similarly to regular English sentences.

But beware!

Too many helper functions might cause more confusion (or annoyance) than it helps with readability. You don't need to put every line of code in its own function. For instance, in the example above, exceeds_size_limits() should probably just be the conditional expression itself:

if username.len() <= MIN_SIZE || username.len() >= MAX_SIZE {
    return false;
}

Idiomatic Function Names

Interesting side note before I get into this topic - my first time working for a big tech company, I was very surprised to find that most of my colleagues were non-native English speakers. My naive 21-year-old self, born and raised in America, just assumed most of the people I'd work with would also be Americans. Just an observation I wanted to share, maybe as a primer for why I want to share this tip.

"Idiomatic" function names are ones that are natural to say / read for native speakers. Although it is not important for the logical correctness of the code, communicating clearly the intent of a function goes a long way for code comprehension.

Generally speaking, function names fall into 2 categories:

  1. A label for the thing that the function returns

    Example: fn default_http_request() -> HttpRequest

  2. An action that describes what the function does

    Example: fn validate_http_request(http_request: HttpRequest)

The "action" functions are the ones that really benefit from idiomatic names. Let's take a look at one of the helper functions from the previous example:

if contains_block_listed_words(username) {
    return false;
}

vs

if block_listed_words(username) {
    return false;
}

Yes, the intention of the function can still be easily determined. But we lost a crucial element - the specificity of the action. block_listed_words() does not make it clear if the whole username must appear on the block list or just any substring. contains makes it clear that it is the latter, leaving no ambiguity (is_blocklisted_word() would specify the former).

For my non-native English speaking peers, I know this part of the job can be hard. But don't worry - there's a pretty simple rule you can follow for naming action functions:

  1. The first word should be an imperative verb ("a verb form used to express a command or to give advice or instructions")

    Examples: contains, validate, execute

  2. If it helps with clarity, add the object noun that the verb affects

    Examples: contains_illegal_chars, validate_http_request, execute_strategy

Functions that return booleans are a special case. Really they should just sound natural when said in an "if sentence":

  • If it is_valid(), then ...
  • If taken(), then ...

I also try to avoid negative boolean functions (e.g. is_not_valid()). They can sound more idiomatic, but personally my head starts spinning when I have to deal with double negations (!is_not_valid()).

Code Comments

Funny enough, code comments are one of the more hotly debated topics in software engineering (seriously, I could write an entire blog post about it). But for the topic of readability and minimizing cognitive load, I'll only mention 2 things.

The first is that comments should be used to explain why, not what.

Comments that describe what the code is doing does not add any extra value or clarity. In fact, it could make readability worse by adding unnecessary clutter and duplicate information:

// verify the request
if verify_request(request) {
    // ...
}

Where comments do add value is by explaining why a certain piece of code is needed. Ideally, intention should be conveyed through the proper naming of things or through documentation. But in reality, software engineers need to be practical - and "why" comments are very practical:

// Legacy clients are not able to upgrade to the newest API version
// for xyz reasons. We can remove this check once they do upgrade.
//
// TODO: ISSUE_NUMBER / ISSUE LINK (Jira)
if is_legacy_client(api_request) {
    // ...
}

The second thing I want to mention about comments is that the "what" comments can be useful in some cases for creating visual fences around code. For instance, I often see something like this in unit tests:

#[test]
fn test_this_thing() {
    // Setup
    ...

    // Run Test
    ...

    // Validate
    ...
}

I don't do this myself, but I do see the merit of being able to quickly identify the different responsibilities of each section of code.

Consistency

My final tip that I'll share in this post is to be consistent with your code style. Humans are naturally driven to look for and recognize patterns - it is an evolutionary trait that enables us to process information more efficiently. Efficiency is simply getting the same output with less input (i.e. effort), which is to say that consistency reduces cognitive load.

If you name your request variable req, then use that same name everywhere. Don't randomly switch to request (and definitely not r).

If your functions always take context objects first, then always put context objects as the first parameter:

fn do_this(context: Context, data: Data);
fn do_that(context: Context, info: Info, is_test: bool);
fn dont_do_this(request: Request, context: Context);

Need to add a new constant variable? Great, put it with all the other ones at the top of the file:

// import statements

// constants
const FOO: &str = "foo";
const BAR: &str = "bar";

// code
fn foo();

const FIZZ: &str = "fizz"; // why am I here?
fn fizz();

I'm sure you get the idea.

Refactoring The Bad Code Example

Let's see what the bad code example looks like after applying some readability optimizations:

pub fn convert_request(api_request: Option<ApiRequest>) -> Result<HttpRequest, Error> {
    if api_request.is_none() {
        if is_non_prod_environment() && has_valid_secret_configured() {
            return test_request();
        }
        return Err(Error::new("ApiRequest is None"));
    }

    let api_request = api_request.unwrap();

    if !is_valid_version(api_request.version) {
        // Legacy clients are not able to upgrade to the newest API version
        // for xyz reasons. We can remove this check once they do upgrade.
        //
        // TODO: ISSUE_NUMBER / ISSUE LINK (Jira)
        if is_legacy_client(api_request) || is_test_client(api_request) {
            return http_request_for_legacy_clients(api_request);
        }
        return Err(Error::new("ApiRequest has invalid version"));
    }

    if !has_required_inputs(api_request) {
        return Err(Error::new("ApiRequest is missing required inputs"));
    }

    let mut http_request = HttpRequest::new();

    for field in api_request.fields {
        if field.input.is_none() && !OPTIONAL_FIELDS.contains(field.name) {
            return Err(Error::new("Required field is missing"));
        }

        http_request.headers_mut().insert(field.name, field.input.unwrap());

        // ...
    }
}

fn is_non_prod_environment() -> bool {
    CONFIG.ENV == Env::Beta || CONFIG.ENV == Env::Dev;
}

fn has_valid_secret_configured() -> bool {
    let secret = CONFIG.ENV_VARS.get(ENV_VAR_SECRET_KEY);

    if secret.is_none() {
        return false;
    }
    is_valid_secret(secret.unwrap())
}

fn test_request() -> HttpRequest {
    if !is_non_prod_environment() {
        panic!("Handle this better");
    }

    let mut http_request = HttpRequest::default();

    if CONFIG.ENV == ENV::Beta {
        *http_request.uri_mut() = BETA_ENDPOINT;
    }
    if CONFIG.ENV == ENV::Dev {
        *http_request.uri_mut() = DEV_ENDPOINT;
    }

    http_request
}

fn is_legacy_client(api_request: ApiRequest) -> bool {
    api_request.client.id == LEGACY_CLIENT_ID
}

fn is_test_client(api_request: ApiRequest) -> bool {
    api_request.client.id.starts_with(CLIENT_ID_TEST_PREFIX) && TEST_TOKENS.contains(api_request.token)
}

fn http_request_for_legacy_clients(api_request: ApiRequest) -> HttpRequest {
    // ...
}

Honestly, seeing this code would still make me want to leave work early. But this version has undeniably a much better flow to it:

  • The main public function can be read in a top-down fashion - no jumping around to figure out error cases
  • The well-named helper functions enable the public function to be read like a table of contents; if necessary, readers can jump into a helper function if they want to learn more about it
  • Error cases are handled as soon as possible, which means readers can stop thinking about them sooner and reclaim cognitive bandwidth
  • Everything is named appropriately and consistently. No more unnecessary confusion

Thanks for reading! I hope you found this post useful and / or insightful.

If you have any questions, comments, or suggestions, feel free to drop a message below, or on the GitHub Discussions thread directly.

If you have a topic that you'd like me to write about, leave a comment on the topics thread, or message me at [email protected].