Minimizing Cyclomatic Complexity with Pattern Matching
Using too many switch or if..then..else statements makes your code harder to maintain. Today, I show alternatives on how to shrink your cyclomatic complexity.
After 30 years of writing code, I can still remember one of my co-workers mentioning what his brother (also a developer at the time) said to him one day: A truly object-oriented program wouldn't contain any if..then..else
or switch
statements in the code.
That type of thinking feels like a meditative way of coding...maybe almost transcendental.
However, I feel like the more I continue to write code, the more those words bring clarity to how I think about coding.
I've already mentioned how I feel about switch statements and how to fix them, but after some recent experimenting with conditionals, I found out even more interesting details.
There are a number of ways to determine the complexity of a program like lines of code, but for this post, we'll be discussing cyclomatic complexity.
Why did I write this?
- After writing Real-World Refactoring: Available Cars, I was curious as to how much complexity would be introduced with a switch statement.
What is Cyclomatic Complexity?
While I've covered cyclomatic complexity before, it pertains to how many decisions you have in your code base.
Every program starts out with a cyclomatic complexity of 1. Every time it encounters a decision (a condition), it increments by 1.
It also depends on the scope of your analysis. An entire program could have a very high cyclomatic complexity (CC) where a module could have a CC of 6.
The higher the number, the more complex your program is considered and how hard it is to maintain. So you want to be aware of each scope's (program, class, and method) cyclomatic complexity.
Sidenote: If you're into code-katas, I would recommend trying the Gilded Rose kata. It has a ton of 'if' conditions and would truly test your refactoring skills.
Identifying Cyclomatic Complexity
The primary tool I use for identifying CC is Visual Studio 2022/2019. It's under Analyze -> Calculate Code Metrics.
Some other tools you could use to identify CC include JetBrain's Resharper (review) and NDepend (review) for static code analysis.
Also, any IDE that implements extensions will have at least 2-3 extensions supporting code metrics where cyclomatic complexity is considered a base code metric and will almost always be included.
The Experiment
I decided to take a simplistic approach to CC and created a simple console app in .NET 6 with top level statements. You could copy and paste the code below and it should work for .NET 6.0.
I started out with a simple if..then..else
.
var age = 35;
// if..then
if (age < 21)
{
Console.Write(Under21());
}
else if (age >= 65)
{
Console.Write(Over65());
}
else
{
Console.Write(Over21());
}
string Under21()
{
return "This person is under 21";
}
string Over21()
{
return "This person is over 21";
}
string Over65()
{
return "This person is eligible for Social Security";
}
As you can see, it's a pretty simple program and starts with a CC of 1.
When we add the two additional if..then, we see it jump to 3.
A CC of 3.
I can guarantee you have a number of these throughout your application. With this basic if..then..else, you can imagine how fast this could get out of hand.
NOTE: From this point on, you know the structure of the console app so we'll just focus on the decision types throughout the rest of the post.
Switch-ing it up
Next in line is, yes, my favorite, the switch statement.
For such a simple application, what do you think the switch statement will report for a CC?
switch (age)
{
case < 21:
Console.Write(Under21());
break;
case >= 65:
Console.Write(Over65());
break;
default:
Console.Write(Over21());
break;
}
How about that? CC of 4.
It even bumped up the Maintainability index by four.
Inline Conditional (or Ternary operator)
Here's the shortcut of writing if..then..else
statements on one line.
Console.Write(age < 21 ? Under21() : age >= 65 ? Over65() : Over21());
Any thoughts on what the CC is for this small piece of code?
If you said it was the same as the if..then..else
and said 3, you're correct.
CC of 3 for ternary operators.
Shrinking Cyclomatic Complexity
"Hiding" Cyclomatic Complexity is almost an art and a science.
I believe once every developer reaches a point in their career and have mastered their language of choice, they see things a little bit differently and experience more elegant ways to write more maintainable code.
With that said, I've found two techniques which achieve the same goal, but doesn't use any conditional code (well, at least not found by the cyclomatic complexity analyzer).
Technique 1 - Collections/Lists
When I was asked to refactor some code, I found a number (7!) of one-level if..then
statements that would add validations to a list based on a condition.
I've boiled the code down to a pseudo-code sample. The code looked like this:
var list = new List<ValidationObject>();
if (complexBizLogic1Changed(obj))
{
list.Add(MyValidationRule1());
}
if (complexBizLogic2Changed(obj))
{
list.Add(MyValidationRule2());
}
if (complexBizLogic3Changed(obj))
{
list.Add(MyValidationRule2());
}
return list;
Seems a bit repetitive, right? But it's also similar to our age code from above, isn't it?
Let's look at this from a different perspective.
The process is: we take a list of conditions, and if one's true, we perform an action.
List of conditions...Did you catch that? If we use lists with LINQ, we can eliminate any conditionals.
Here's the refactor:
var list = new Dictionary<Func<int, bool>, string>
{
{ num => num < 21, Under21() },
{ num => num >= 65, Over65() },
{ num => num >= 21, Over21() }
};
var result = list.Where(e => e.Key(age))
.Select(e => e.Value)
.First();
Console.Write(result);
As a first pass, this seems to work (and yes, I know I could've used a .FirstOrDefault()
or .Single()
).
But what's our CC?
Yes, you read that right. A CC of 1.
Because we don't have any switch or if statements in the code, we are testing the conditions by simply using a collection to return true values. For this example, we will always receive a message so at least one of the items in the collection is true.
After mentioning that, I know what you're thinking: How can I perform an else in this technique? Use the discard parameter and return a true to catch everything else.
var list = new Dictionary<Func<int, bool>, string>
{
{ num => num < 21, Under21() },
{ num => num >= 65 && num < 110, Over65() },
{ num => num >= 21, Over21() },
{ _ => true, VampireOrElfStatus() }
};
I've used the collection technique a number of times, but there is an additional technique if you are using .NET 6.0.
Technique 2 - Expression Pattern Matching
This is for those who are up on .NET 6.0.
A new term was introduced in .NET 5.0 called pattern matching and they improved on it in 6.0.
There are a number of ways to implement expression pattern matching, but the primary one I'm interested in is the comparing discrete values (However, if you want your mind completely blown, check out the multiple inputs).
With expression pattern matching, this boils the code down even further making it a tighter and more terse statement.
When using our age code from above, we can use the age in, yes, a switch statement.
Console.Write(GetDescription(age));
string GetDescription(int param) =>
param switch
{
< 21 => Under21(),
>= 65 => Over65(),
_ => Over21()
};
Oh man, another switch statement. I bet it has a CC of 4, right?
Wow! A CC of 1?
WITH conditionals in the switch?
Ok, this is one of those times when I will make an exception for a switch statement in my code.
However...
...is this one of those times when a switch statement is not truly a switch statement and it's simply "pattern matching" the conditions?
Hmm...makes me wonder what is truly happening under the covers.
Finding Candidates for Minimizing Cyclomatic Complexity
Of course, you have to have an eye for finding this type of code and know how to refactor it properly.
Here's how to find possible candidates to refactor.
1. Find one of the conditions in your code
Locate a ternary operator or a large switch
statement or multiple if..then..else
statements.
2. See if there is a pattern to the conditionals
Usually, switch statements are dead giveaways, but there are times when a haphazard if..then
can be refactored as well (like some of the above code).
3. Implement either the Collection or Pattern Matching technique
Determine what makes sense in the context of your code as to which technique to use (or whether to implement anything at all).
Conclusion
In this post, we covered what cyclomatic complexity is, some suspects for possible complexity in an application, and two techniques on how to lower your cyclomatic complexity overall.
Of course, in the long run, you decide what makes sense. These are only suggestions based on what I found through my research.
Did I miss a conditional? Could the code be any simpler? Post your comments below and let's discuss.