Reducing cognitive complexity of existing code

If you performed a search for “cognitive complexity” and stumbled upon this article, then you might be using Code Climate. Code Climate may have complained that the “cognitive complexity” of a certain method or class is too high and needs to be reduced below a certain threshold. Cognitive complexity is a measure of how difficult (or easy) it is to understand the code. There are plenty of sources out there explaining how cognitive complexity is scored—basically, the more you use if statements, loops, and other structures that mess with the flow of execution, the higher the score, and the number increases faster at deeper nesting levels. Rather, I will focus on techniques you can use to make the existing code simpler for Code Climate and yourself to understand.

The below techniques are geared toward the Java programming language, but depending on your language of choice you may be able to adapt those techniques to fit your needs.

Things to do before you start

Use the right tool for the job.

It is better to be able to measure the cognitive complexity from inside your favorite IDE than it is to wait for Code Climate to do it after you check your code in. In IntelliJ I use a plugin called Code Complexity by Nikolay Bogdanov.

By default, the plugin displays the scores as percentages of the “simple score” threshold. Personally, I prefer to show the original scores. To do this, go to File > Settings > Tools > Code Complexity, then check Show the original score instead of percentages. Additionally, I suggest changing the “very complex score” threshold to match the maximum score allowed by Code Climate per your organization.

Write unit tests.

If unit tests have already been written for the functions in question, great! Otherwise, you’d better spend some time wrapping your mind around the code and coming up with unit tests that cover every use case. Reducing cognitive complexity often involves significant changes to those functions, and you could easily break something if you are not careful.

Understand what you are trying to accomplish by reducing the complexity.

The main objective here is to maintain code quality; high quality code is easy to understand and maintain, resulting in fewer bugs over the long term. Cognitive complexity is an indicator of code quality that isn’t exactly straightforward. It is true that a high complexity score is a sign that the code needs refactoring. However, unlike the game of Hearts, the goal is not to achieve the lowest (complexity) score possible. Rather, the score need only be reasonably low enough such that the code quality is significantly improved (as long as Code Climate is happy). In some situations, shooting for a cognitive complexity of zero is not in your best interest. There is a good reason why control structures exist, and you should use them to your advantage.

Technique 0: Rewrite the darn method/class from scratch.

Under most circumstances you shouldn’t have to do this. Try applying the below techniques first; these should help you understand the code more easily. If you do have to rewrite, make sure you have a complete understanding of what that code does, and should be doing, for every use case the function covers. From that understanding you should be able to devise a simpler algorithm that effectively accomplishes the same thing.

Technique 1: Replace familiar patterns with well-known functions.

Consider the following class:

import java.time.LocalDate;

public class Person {
	private String firstName;
	private String lastName;
	private LocalDate dateOfBirth;

	public Person(String firstName, String lastName, LocalDate dateOfBirth) {
		this.firstName = firstName;
		this.lastName = lastName;
		this.dateOfBirth = dateOfBirth;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj == null || getClass() != obj.getClass()) {
			return false;
		}

		Person other = (Person) obj;

		if (firstName != null) {
			if (!firstName.equals(other.firstName)) {
				return false;
			}
		} else if (other.firstName != null) {
			return false;
		}

		if (lastName != null) {
			if (!lastName.equals(other.lastName)) {
				return false;
			}
		} else if (other.lastName != null) {
			return false;
		}

		if (dateOfBirth != null) {
			if (!dateOfBirth.equals(other.dateOfBirth)) {
				return false;
			}
		} else if (other.dateOfBirth != null) {
			return false;
		}
		
		return true;
	}
}

The above equals method has a cognitive complexity of 15, which by default is considered “very complex”. The pattern repeated throughout the method is an old-fashioned way of comparing each nullable member within the two objects. Java 7 introduced the Objects.equals(Object, Object) function to check two nullable objects for equality. Replacing this pattern with Objects.equals makes the code far simpler to read and write, and it significantly reduces its cognitive complexity. In the example below, the cognitive complexity has been reduced from 15 to 4. And yes, it is much easier to understand.

@Override
public boolean equals(Object obj) {
	if (this == obj) {
		return true;
	}
	if (obj == null || getClass() != obj.getClass()) {
		return false;
	}

	Person other = (Person) obj;

	return Objects.equals(firstName, other.firstName)
			&& Objects.equals(lastName, other.lastName)
			&& Objects.equals(dateOfBirth, other.dateOfBirth);
}

Technique 2: Extract sections of the code into their own methods.

By using Extract Method on a complex block of code, you essentially transfer a part of the cognitive complexity score from the original method to the new one. The score in the original method is decreased further if the extracted code:

  • Was already nested.
  • Had been duplicated within the original method (in which case Extract Method would have been applied more than once).

Technique 3: Use separate methods for each use case.

Consider this technique if the following conditions apply:

  • The method has one or more parameters.
  • The values of the parameters impact how the method behaves.
  • The parameters passed to the method are either hardcoded or fall under some known category at the time of the method call.

In this situation, it may be helpful to split the method into less complex methods that cover smaller classes of inputs.

For example, suppose you are tasked with implementing a new date library that is better than the built-in one provided by the java.time package (or at least fits your organization’s needs). The code might look something like this at first:

import lombok.Value;

@Value
public class MyDate {
	private static final int[] DAYS_OF_MONTH = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
	private static final int[] DAYS_OF_MONTH_LEAPYEARS = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
	int year;
	int month;
	int day;

	// Cognitive complexity: 19
	public MyDate plusDays(int days) {
		int tempDay = day + days;
		int tempMonth = month, tempYear = year;
		int daysOfCurrentMonth;
		do {
			daysOfCurrentMonth = daysOfMonth(tempMonth, tempYear);
			if (tempDay > daysOfCurrentMonth) {
				// Spilled over into a future month.
				tempDay -= daysOfCurrentMonth;
				tempMonth++;
				if (tempMonth > 12) {
					// Spilled over into a future year.
					tempMonth = 1;
					tempYear++;
					if (tempYear == 0) {
						// Year 0 is invalid. Increment the year again.
						tempYear++;
					}
				}
			} else if (tempDay < 1) {
				// Spilled over into a past month.
				tempMonth--;
				if (tempMonth < 1) {
					// Spilled over into a past year.
					tempMonth = 12;
					tempYear--;
					if (tempYear == 0) {
						// Year 0 is invalid. Decrement the year again.
						tempYear--;
					}
				}
				tempDay += daysOfMonth(tempMonth, tempYear);
			} else {
				break;
			}
		} while (true);
		return new MyDate(tempYear, tempMonth, tempDay);
	}

	@Override
	public String toString() {
		return year + "/" + month + "/" + day;
	}

	private static int daysOfMonth(int month, int year) {
		return isLeapYear(year)
				? DAYS_OF_MONTH_LEAPYEARS[(month - 1) % 12]
				: DAYS_OF_MONTH[(month - 1) % 12];
	}

	private static boolean isLeapYear(int year) {
		return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
	}
}

Okay, so plusDays is less than optimal if the input is large, and I didn’t yet implement date validation in the constructor. If I need a better algorithm, I could just rewrite the method from scratch (Technique 0). But for demonstration purposes, let’s work with the algorithm we currently have.

The method body for plusDays has a lot of nesting going on, resulting in a cognitive complexity of 19, which is quite complex. After giving ourselves some time to wrap our minds around what the function does, we notice the following:

  • It returns what the new date would be after adding (or subtracting) a certain number of days from the current date.
  • There is logic that increments or decrements the month as the days are added or subtracted, spilling over into the next or previous month.
  • There is logic that increments or decrements the year as the months are also incremented or decremented, spilling over into the next or previous year.

If we analyze this a bit further, we see that a part of the logic applies only when the days parameter is positive, whereas another part of the logic applies only when days is negative. Moreover, the function effectively returns a copy of the current MyDate object if days is zero. Hence, we can split plusDays as follows:

// Cognitive complexity: 12
public MyDate plusDays(int days) {
	if (days <= 0) {
		throw new IllegalArgumentException("days must be greater than 0");
	}

	int tempDay = day + days;
	int tempMonth = month, tempYear = year;
	int daysOfCurrentMonth;
	do {
		daysOfCurrentMonth = daysOfMonth(tempMonth, tempYear);
		if (tempDay > daysOfCurrentMonth) {
			// Spilled over into a future month.
			tempDay -= daysOfCurrentMonth;
			tempMonth++;
			if (tempMonth > 12) {
				// Spilled over into a future year.
				tempMonth = 1;
				tempYear++;
				if (tempYear == 0) {
					// Year 0 is invalid. Increment the year again.
					tempYear++;
				}
			}
		} else {
			break;
		}
	} while (true);
	return new MyDate(tempYear, tempMonth, tempDay);
}

// Cognitive complexity: 12
public MyDate minusDays(int days) {
	if (days <= 0) {
		throw new IllegalArgumentException("days must be greater than 0");
	}
	int tempDay = day - days;
	int tempMonth = month, tempYear = year;
	do {
		if (tempDay < 1) {
			// Spilled over into a past month.
			tempMonth--;
			if (tempMonth < 1) {
				// Spilled over into a past year.
				tempMonth = 12;
				tempYear--;
				if (tempYear == 0) {
					// Year 0 is invalid. Decrement the year again.
					tempYear--;
				}
			}
			tempDay += daysOfMonth(tempMonth, tempYear);
		} else {
			break;
		}
	} while (true);
	return new MyDate(tempYear, tempMonth, tempDay);
}

// Cognitive complexity: 0
public MyDate copy() {
	return new MyDate(year, month, day);
}

12 is clearly smaller than 19 (and that’s after adding the validation checks). Depending on your organization’s Code Climate settings, that might be good enough. C’s get degrees.

Technique 4: Use streams instead of control structures.

Loops make the code tougher to understand because they mess with the flow of execution—even more so when the loops are nested. On the other hand, Code Climate loves it when you convert these into streams.

Below are two programs that do the same thing: print all pairs of primes (larger number first) where both numbers are less than 100.

import org.apache.commons.math3.primes.Primes;

public class Main {
	// Cognitive complexity: 10
	public static void main(String[] args) {
		for (int i = 1; i < 100; i++) {
			if (Primes.isPrime(i)) {
				for (int j = 0; j < i; j++) {
					if (Primes.isPrime(j)) {

						System.out.println("Both " + i + " and " + j + " are prime.");
					}
				}
			}
		}
	}
}
import org.apache.commons.math3.primes.Primes;

import java.util.stream.IntStream;

public class Main {
	// Cognitive complexity: 0
	public static void main(String[] args) {
		IntStream.range(1, 100)
				.filter(Primes::isPrime)
				.forEach(i -> IntStream.range(0, i)
					.filter(Primes::isPrime)
					.forEach(j -> System.out.println("Both " + i + " and " + j + " are prime.")));
	}
}

As you can see, converting the control structures into streams essentially knocks out the cognitive complexity score.

Compared to for loops, streams perform significantly slower, especially for large inputs. If that is a concern, then you may want to consider a different technique to reduce complexity.

Technique 5: Replace if-else-if blocks with switch-case blocks.

Switch-case blocks are intuitively easier to understand than if-else-if blocks; as soon as you read the switch statement, you can tell that the value inside the switch statement is going to determine what happens next. Code Climate’s parser agrees in the sense that it rewards you with a much better score. Unfortunately, this technique only applies to primitives, enums, and strings.

// Cognitive complexity: 4
public String getNicknameOld() {
	if (Objects.equals(firstName, "Abraham")) {
		return "Abe";
	} else if (Objects.equals(firstName, "Christopher")) {
		return "Chris";
	} else if (Objects.equals(firstName, "Matthew")) {
		return "Matt";
	} else {
		return null;
	}
}
// Cognitive complexity: 0
public String getNicknameNew() {
	return switch (firstName) {
		case "Abraham":
			yield "Abe";
		case "Christopher":
			yield "Chris";
		case "Matthew":
			yield "Matt";
		default:
			yield null;
	}
}

Technique 6: Combine nested if statements with their outer ones.

The following code snippets are equivalent:

// Cognitive complexity: 3
if (A) {
	if (B) {
		doSomething();
	}
}
// Cognitive complexity: 2
if (A && B) {
	doSomething();
}

And so are these:

// Cognitive complexity: 4
if (A) {
	if (B) {
		doSomething();
	} else {
		doSomethingElse();
	}
}
// Cognitive complexity: 3
if (A && B) {
	doSomething();
} else if (A) {
	doSomethingElse();
}

Personally, I don’t like this technique in the latter case because it actually increases the cyclomatic complexity. The second snippet redundantly checks the value of A, even if it already knows that A is false.

Sources:

Leave a Reply

Your email address will not be published. Required fields are marked *