Measuring how hard code is to understand with our new working memory metric
Oct 15, 2020
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.
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 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:
The cognitive load of the line itself:
The existing baseline state that must be held in working memory while analysing 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.
To illustrate these rules here are some worked examples of code snippets and their associated working memory scores.
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
.
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:
a
, b
and c
used on the linez
that is declared before the
line and used afterwardsHere'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:
self
, self.price
, b
and c
.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.def function(var):
if condition_holds(var):
a = b + c
Moving on, the working memory of the a = b + c
line here is 5.
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 itselfa
is in memory since it is referenced above and below the linecondition_holds
and var
from the if
conditional testThis 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:
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:
name
, employee
, employee.salary
, run_payroll
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:
paid_today
, employee
, has_passed_probation
, is_paid_monthly
,
is_paid_weekly
, is_end_of_month
, is_end_of_week
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:
name
, employee
, employee.salary
, run_payroll
is_paid_today
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)
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)
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.
You can also see the working memory of your code via the quality reports you get when using our GitHub bot.
Thanks to Ovidiu Serban and Alex Raison for their assistance with the article.