Can you fit all of this code in your head?

Measuring how hard code is to understand with our new working memory metric

Date

Oct 15, 2020

Pile of books
Photo by Sharon McCutcheon on Unsplash

Introduction

As developers we've all read code that's too hard to understand in a single pass.

We start reading from the top and see lots of variables declared. Then there's a complex expression. Then an if statement with a complex conditional, then a complex function call, and suddenly we realise we don't have any idea what it does. The code has exceeded the number of variables we can pack into our head at one time, and it's going to need some serious thinking time and another cup of coffee to understand.

At Sourcery we've created a new code quality metric, called working memory, that captures the level of difficulty of understanding code. It is based on the maximum number of variables that need to be held in our working memory when reading the code from top to bottom. Working memory is generally held to be a very limited resource, with a range of 5-9 items for an adult.

Reducing working memory overhead

So how do we improve code that's too complex for working memory? The saving grace that allows us to analyse code successfully is that if several pieces of state can be chunked together then we just need to hold the chunk in working memory rather than all of the pieces.

One of the elements of good coding style is recognising where such chunking is possible, and pulling complex logic out into well-named variables or functions.

def original_function(employee_database):
    for name, employee in employee_database.items():
        if (
            has_passed_probation(employee)
            and (is_paid_monthly(employee) and is_end_of_month)
            or (is_paid_weekly(employee) and is_end_of_week)
        ):
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)

In this code the complex conditional has to be held in mind while evaluating its body. If we extract this out into a variable the code becomes easier to understand:

def function_extract_variable(employee_database):
    for name, employee in employee_database.items():
        paid_today = (
            has_passed_probation(employee)
            and (is_paid_monthly(employee) and is_end_of_month)
            or (is_paid_weekly(employee) and is_end_of_week)
        )
        if paid_today:
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)

Here the paid_today variable takes a bit of understanding, but from this point on we can just use the variable without having to hold all of the elements that went into working it out.

def function_extract_method(employee_database):
    for name, employee in employee_database.items():
        if is_paid_today(employee):
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)


def is_paid_today(employee):
    return (
        has_passed_probation(employee)
        and (is_paid_monthly(employee) and is_end_of_month)
        or (is_paid_weekly(employee) and is_end_of_week)
    )

If we go one step further and extract is_paid_today into a function the original code becomes even easier to understand. We only need to look at the logic of how is_paid_today is calculated if we're interested in it specifically.

Another thing that can tax our working memory is when logic dealing with different problems is interleaved.

salary = work_out_salary(employee_id)

if is_on_probation(employee_id):
    send_out_hr_letter(employee_id)

if salary < THRESHOLD:
    send_bonus(employee_id)

Here you must hold the salary variable in your head while mentally dealing with the code for sending out letters, even though it is unrelated.

if is_on_probation(employee_id):
    send_out_hr_letter(employee_id)

salary = work_out_salary(employee_id)
if salary < THRESHOLD:
    send_bonus(employee_id)

If the different areas of responsibility are separated the program becomes easier to read and it becomes easier to split into sub-methods.

Existing code metrics do not capture these working memory considerations. For example two of the more commonly used are:

  • Cyclomatic complexity, which relates to how many different control flow branches exist in the code.

  • The more recently developed Cognitive complexity , which focuses on readability via penalising nested code structures, recursion and excessively long sequences of logical operators.

Neither capture the complexity that is introduced by juggling large numbers of variables in working memory.

The working memory metric

The working memory of a line of code is calculated as the number of distinct pieces of program state that you must hold in memory to analyse it. These are:

Rule 1

The cognitive load of the line itself:

  • Those variables, constants and function calls used on the line of code itself.

Rules 2 and 3

The existing baseline state that must be held in working memory while analysing the line:

  • Those variables that are declared above the line and used below it.
  • Those variables, constants and function calls used in conditional tests that affect the line.

A complex variable such as self.price or self.price.currency counts twice for the working memory calculations, once for the full variable and once for the base object - self in this case.

The working memory of a method is defined as the greatest working memory of any line contained within it.

Worked examples

To illustrate these rules here are some worked examples of code snippets and their associated working memory scores.

Rule 1 - Variables, constants and function calls used on the line of code itself

We'll start with a very simple example:

a = b + c

The working memory of this statement is 3, since there are three distinct variables used within it - a, b and c.

Let's move on to an example with a function call and a constant:

a = max(b + c, 10)

The working memory of this statement is 5 - one each for a, b, c, max and 10.

Rule 2 - Variables that are declared above the line and used below it

Now let's incorporate the second rule.

z = 3
a = b + c
print(z)

The a = b + c line in this code snippet has a working memory of 4:

  • Using rule 1 we count the 3 variables a, b and c used on the line
  • Using rule 2 there is one additional variable z that is declared before the line and used afterwards

Here's another example to illustrate that we only count distinct variables.

def set_price(self, b, c):
    self.price = b + c
    print(b + c)

The self.price = b + c line in this code snippet has a working memory of 4:

  • Using rule 1 we count the variables self, self.price, b and c.
  • Using rule 2 variables b and c are declared before the line and used afterwards, but since we already included them using rule 1 they are not added again.

Rule 3 - Variables, constants and function calls used in conditional tests that affect the line

def function(var):
    if condition_holds(var):
        a = b + c

Moving on, the working memory of the a = b + c line here is 5.

  • Using Rule 1 again we have 3 from the variables.
  • We now use Rule 3 to include the working memory burden of the conditional that the line is contained in. This adds 2 (The call to condition_holds and the use of the var variable).

A statement is considered affected by a conditional if it is within the if or while. A statement within an elif or else branch is considered affected by all parts of the conditional that lie above it.

def function(var):
    q = 1
    while condition_holds(var):
        a += b + c
    print(q)

This example uses all three rules. Here the working memory of the a += b + c line is 6, since the q variable is declared before it and used after it, so stays in memory.

For a final example let's look at an example with an else clause that also calls a function on self.

def example_function(self, var, b, c):
    if condition_holds(var):
        a = b + c
    else:
        self.alter_vars(b, c)
        a = b - c
    return a

The working memory here is 7. The most complex line is self.alter_vars(b, c). This contributions to working menory are:

  • self, self.alter_vars, b, c from the line itself
  • a is in memory since it is referenced above and below the line
  • condition_holds and var from the if conditional test

Implications for improving the working memory of existing code

This metric captures a new facet of what it means to write easily understood code that other code metrics do not. It therefore shows the benefits of the following types of refactoring:

Extracting out complex conditionals

Let's circle back to our original example of a complex conditional. Here's our original function:

def original_function(employee_database):
    for name, employee in employee_database.items():
        if (
            has_passed_probation(employee)
            and (is_paid_monthly(employee) and is_end_of_month)
            or (is_paid_weekly(employee) and is_end_of_week)
        ):
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)

The working memory of the original_function is 9.

The run_payroll line has:

  • Rule 1: name, employee, employee.salary, run_payroll
  • Rule 3: has_passed_probation, is_paid_monthly, is_paid_weekly, is_end_of_month, is_end_of_week

Here are two possible ways of refactoring it:

def function_extract_variable(employee_database):
    for name, employee in employee_database.items():
        paid_today = (
            has_passed_probation(employee)
            and (is_paid_monthly(employee) and is_end_of_month)
            or (is_paid_weekly(employee) and is_end_of_week)
        )
        if paid_today:
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)

If we extract out the variable, the working memory reduces to 8.

The paid_today line is now the most complex:

  • Rule 1: paid_today, employee, has_passed_probation, is_paid_monthly, is_paid_weekly, is_end_of_month, is_end_of_week
  • Rule 2: name
def function_extract_method(employee_database):
    for name, employee in employee_database.items():
        if is_paid_today(employee):
            run_payroll(name, employee.salary)
            letter = write_letter(name)
            send_to_printer(letter)


def is_paid_today(employee):
    return (
        has_passed_probation(employee)
        and (is_paid_monthly(employee) and is_end_of_month)
        or (is_paid_weekly(employee) and is_end_of_week)
    )

When extracted into a function, the working memory becomes 5.

The run_payroll line has:

  • Rule 1: name, employee, employee.salary, run_payroll
  • Rule 3: is_paid_today

Not redeclaring local variables with the same name

Redeclaring variables with the same name and using them for a different purpose can add to the mental overhead of understanding a function.

This code snippet has a working memory of 5. This is because the self.example_method(b, c) line has a, b, c, self and self.example_method in memory.

def example_function(b, c):
    a = get_value()
    do_something_with(a)

    self.example_method(b, c)

    a = get_other_value()
    do_other_thing_with(a)

Changing the second use of a to use a new name reduces the working memory to 4, since a is now not used later so can be discarded from memory.

def example_function(b, c):
    a = get_value()
    do_something_with(a)

    self.example_method(b, c)

    d = get_other_value()
    do_other_thing_with(d)

Moving variables closer to their usage

The working memory of this example if 7, with the most complex line being b = self.combine(f, c), which has a, b, c, f, other_condition_holds, self and self.combine in working memory.

def example_function(self, b, c):
    a = get_value()
    while other_condition_holds():
        f = self.do_something_with(b, c)
        if f:
            b = self.combine(f, c)

    if condition_holds(b, c):
        self.example_method(a, b, c)

Since a isn't used until the end of the function we can move this statement next to its usage, dropping the working memory to 6.

def example_function(self, b, c):
    while other_condition_holds():
        f = self.do_something_with(b, c)
        if f:
            b = self.combine(f, c)

    if condition_holds(b, c):
        a = get_value()
        self.example_method(a, b, c)

Conclusion

This new metric is a valuable additional tool to use when analysing the complexity of code, particularly in conjunction with existing metrics such as the cognitive complexity. It approaches the problem from a new angle, using the established psychological theory of working memory.

To check out the working memory of your code you can use our PyCharm or VS Code plugins. Code metrics for a function are shown when you hover over its definition.

In-line metrics
In-line metrics for a function using Sourcery VS Code extension

You can also see the working memory of your code via the quality reports you get when using our GitHub bot.

Quality Report
Sourcery Quality Report via GitHub bot

Thanks to Ovidiu Serban and Alex Raison for their assistance with the article.