Cyclomatische Complexiteit in C#: Alles wat je moet weten
Hoe hoger de score, hoe moeilijker de methode is om te lezen, te testen en veilig te wijzigen. Een score van 1 betekent een enkel recht pad; ongeveer 10 is de traditionele bovengrens die door Thomas McCabe wordt aanbevolen, en alles boven de 25 wordt door Microsoft's CA1502-analyzer als overmatig gemarkeerd.
Deze gids legt aan de hand van C#-voorbeelden uit hoe Cyclomatische Complexiteit wordt berekend, welke drempelwaarden in de praktijk belangrijk zijn, hoe je het kunt meten en visualiseren in echte .NET-codebases, en hoe je verder kunt kijken dan de ruwe score door deze te koppelen aan testdekking en analyse op IL-niveau.
Wat is Cyclomatische Complexiteit?
Cyclomatische Complexiteit werd in 1976 geïntroduceerd door Thomas J. McCabe als een manier om de structurele complexiteit van een stuk code te kwantificeren. Het idee komt voort uit de graaftheorie: elke methode kan worden weergegeven als een control flow graph waarbij knopen (nodes) blokken van instructies zijn en randen (edges) de sprongen daartussen. In die graaf wordt de Cyclomatische Complexiteit bepaald door de klassieke formule:
M = E - N + 2P
Waarbij:
- E het aantal randen is;
- N het aantal knopen;
- P het aantal verbonden componenten.
Voor een reguliere methode met één ingang en één uitgang versimpelt dit tot: 1 + het aantal beslissingspunten. Dit is de vorm die de meeste tools daadwerkelijk berekenen.
Wat dit getal eigenlijk vertelt, is het minimum aantal testgevallen dat nodig is om elk onafhankelijk pad door de methode te doorlopen. Daarom is Cyclomatische Complexiteit al bijna een halve eeuw relevant: het is een structurele metriek, maar het heeft een zeer concrete operationele betekenis voor iedereen die de code moet onderhouden of testen.
Definitie van Cyclomatische Complexiteit in C#
Voor een C#-methode is de Cyclomatische Complexiteit concreet: 1 + {het aantal van de volgende expressies gevonden in het lichaam van de methode}:
ifwhileforforeachcasedefaultcontinuegoto&&||catch- Ternaire operator
?: - Null-coalescing operator
?? andofor(pattern matching)
De volgende expressies tellen niet mee voor de CC-berekening:
elsedoswitchtryusingthrowfinallyreturn- Objectcreatie
- Methode-aanroepen
- Toegang tot velden
Twee belangrijke details:
elseverhoogt de score niet, omdat het alternatieve pad al is gecreëerd door de bijbehorendeif.- Een
switchdraagt één eenheid bij percase(en één voordefault), niet voor hetswitch-trefwoord zelf. C#-pattern-matching constructies (and,or, en de moderneswitch-expressie met patterns) tellen ook mee, op dezelfde manier als hun klassieke tegenhangers.
Voorbeeld van de impact van Cyclomatische Complexiteit in C#
Een complexe methode
Hieronder staat een complexe methode met verstrengelde if- en else-scopes. Het trefwoord if wordt zes keer gebruikt en && één keer. De Cyclomatische Complexiteit score is daarom 8:
public static class OrderLogic {
public static void ProcessOrder(
int orderId,
bool isPriority,
bool isInternational,
bool isGift,
bool isCouponApplied,
decimal orderTotal) {
if (orderId <= 0) {
Console.WriteLine("Invalid order ID.");
return;
}
if (isPriority) {
Console.WriteLine("Processing priority order.");
if (isInternational) {
Console.WriteLine("Processing international priority order.");
if (isGift) {
Console.WriteLine("This is a gift order.");
}
}
} else {
Console.WriteLine("Processing standard order.");
if (isInternational) {
Console.WriteLine("Processing international standard order.");
}
}
if (isCouponApplied && orderTotal > 100) {
Console.WriteLine("Applying discount for orders over $100.");
} else {
Console.WriteLine("No discount applicable.");
}
}
}
Acht onafhankelijke paden betekenen dat er minimaal acht tests nodig zijn om deze ene methode volledig te dekken. Bovendien zorgt dit voor veel cognitieve belasting telkens wanneer er een nieuwe bedrijfsregel moet worden toegevoegd. Dit is precies het soort methode waar onopgemerkt regressiefouten in sluipen.
De complexe methode refactoren naar meerdere simpelere methoden
De bovenstaande methode kan worden opgesplitst in verschillende minder complexe methoden (CC staat hier voor de score per methode):
public static class OrderLogic {
public static void ProcessOrder(
int orderId,
bool isPriority,
bool isInternational,
bool isGift,
bool isCouponApplied,
decimal orderTotal) { // CC 2
if (!IsValidOrder(orderId)) return;
ProcessOrderType(isPriority, isInternational, isGift);
ApplyDiscountIfEligible(isCouponApplied, orderTotal);
}
private static bool IsValidOrder(int orderId) { // CC 2
if (orderId <= 0) {
Console.WriteLine("Invalid order ID.");
return false;
}
return true;
}
private static void ProcessOrderType(
bool isPriority,
bool isInternational,
bool isGift) { // CC 5
if (isPriority) {
Console.WriteLine("Processing priority order.");
if (isInternational) {
Console.WriteLine("Processing international priority order.");
}
} else {
Console.WriteLine("Processing standard order.");
if (isInternational) {
Console.WriteLine("Processing international standard order.");
}
}
if (isGift) {
Console.WriteLine("This is a gift order.");
}
}
private static void ApplyDiscountIfEligible( // CC 3
bool isCouponApplied,
decimal orderTotal) {
if (isCouponApplied && orderTotal > 100) {
Console.WriteLine("Applying discount for orders over $100.");
} else {
Console.WriteLine("No discount applicable.");
}
}
}
Voordelen van refactoring
- Simpelere Control Flow: De hoofdmethode delegeert specifieke taken naar kleinere, gefocuste methoden.
- Makkelijker te testen: Elke kleine methode kan onafhankelijk worden getest.
- Lagere Cyclomatische Complexiteit per eenheid: De complexiteit is verspreid, waardoor elke methode makkelijker te begrijpen en te onderhouden is.
- Betere naamgeving: Methoden zoals
IsValidOrderofApplyDiscountIfEligibledocumenteren de intentie, zodat een lezer niet de hele logica hoeft te simuleren om de flow te begrijpen.
Let op: de totale CC over de vier methoden is iets hoger dan de oorspronkelijke 8. Dat is acceptabel. Voor onderhoudbaarheid is de complexiteit per methode bepalend, omdat dat de eenheid is waar een ontwikkelaar tegelijkertijd over nadenkt.
Drempelwaarden voor Cyclomatische Complexiteit: Wanneer is een score te hoog?
Er is geen enkel "heilig" getal, maar de literatuur convergeert naar dezelfde ranges:
| Cyclomatische Complexiteit | Risicoprofiel | Praktische interpretatie |
|---|---|---|
| 1 – 10 | Simpel, laag risico | Originele aanbeveling van McCabe. Makkelijk te testen en lezen. |
| 11 – 20 | Matig complex | Nog beheersbaar, maar een tweede paar ogen tijdens review is aanbevolen. |
| 21 – 50 | Complex, hoog risico | Moeilijk uitputtend te testen. Sterke kandidaat voor refactoring. |
| > 50 | Niet testbaar | "Bug magnets". Vaak legacy hotspots die opgesplitst moeten worden. |
Twee referentiepunten: McCabe adviseerde modules te splitsen die boven de 10 uitkomen. Microsoft’s CA1502-analyzer definieert "overmatige complexiteit" standaard als een score hoger dan 25. Mark Seemann pleit zelfs voor een strengere grens van rond de 7, gebaseerd op het menselijk kortetermijngeheugen.
In de praktijk hangt de drempel af van de codebase. Een parser of state machine zal routinematig in de range 15-25 zitten zonder objectief "slecht" te zijn. Bedrijfslogica met een score van 25 is daarentegen bijna altijd problematisch.
Cyclomatische Complexiteit meten in C#
Het gebruik van tools is essentieel om complexiteit te identificeren.
- NDepend: Meet de complexiteit van methoden in C#-code en biedt functies zoals "Search Methods by Complexity".
- Visual Studio: Bevat het commando "Calculate Code Metrics" (Analyze > Calculate Code Metrics) dat CC rapporteert per methode, type en assembly.
- Analyzers: De CA1502-analyzer kan in je build worden geïntegreerd om builds te laten falen bij methoden boven een bepaalde drempel. Roslyn-gebaseerde analyzers zoals SonarAnalyzer.CSharp, ReSharper of CodeRush tonen deze metriek ook direct in de editor.
Regels voor Cyclomatische Complexiteit in C#
Bij grote legacy-codebases is het onpraktisch om elke complexe methode direct te refactoren. Daarom is het essentieel om CC te meten tegen een baseline. Hierdoor kun je focussen op nieuwe of gewijzigde methoden.
Een effectieve benadering is het monitoren van methoden die nog complexer worden. Met NDepend's CQLinq kun je dit als volgt uitdrukken:
// <Name>Cyclomatic Complexity got worse</Name>
warnif count > 0
from m in JustMyCode.Methods
where
m.CodeWasChanged() &&
m.OlderVersion().CyclomaticComplexity < m.CyclomaticComplexity &&
m.OlderVersion().CyclomaticComplexity > 10
select new { m, OldComplexity = m.OlderVersion().CyclomaticComplexity, m.CyclomaticComplexity }
Deze query geeft een waarschuwing wanneer een reeds complexe methode (CC > 10) nog complexer is geworden tussen twee analyse-snapshots.
Visualisatie van Cyclomatische Complexiteit in C#
Een gekleurde treemap kan worden gebruikt om CC te visualiseren. In zo'n visualisatie stelt elk rechthoekje een methode voor:
- De grootte van het rechthoekje komt overeen met het aantal instructies in de methode.
- De kleur weerspiegelt de cyclomatische complexiteit.
Het voordeel van een treemap is dat hotspots direct opvallen: een groot, donkerrood rechthoek in een verder rustig gebied is precies de methode die een architectuuroverleg verdient.
Cyclomatische Complexiteit in C# en Tests
Cyclomatische Complexiteit geeft een ruwe schatting van hoeveel tests er nodig zijn om een methode volledig te testen. Door tests uit te voeren, kun je de code coverage bepalen. Een methode die slechts gedeeltelijk gedekt is, betekent dat niet alle onafhankelijke uitvoeringspaden zijn getest.
De regel "Methods should have a low C.R.A.P score" identificeert methoden die zowel een hoge CC hebben als slecht getest zijn (C.R.A.P staat voor Change Risk Analyzer and Predictor). De CRAP-score combineert complexiteit en coverage in deze formule:
// <Name>CRAP</Name>
from m in JustMyCode.Methods
let CC = m.CyclomaticComplexity
let uncov = (100 - m.PercentageCoverage) / 100f
let CRAP = (CC * CC * uncov * uncov * uncov) + CC
select new { m, CRAP }
De praktische les: een methode met CC = 30 en 100% coverage is veel minder risicovol dan een methode met CC = 12 en 0% coverage.
Verder gaan dan Cyclomatische Complexiteit
Pair Complexity with Branch Coverage
Twee methoden met dezelfde CC kunnen een totaal ander risicoprofiel hebben als de één volledig is gedekt door branch tests en de ander niet. Een CQLinq-query kan dit specifiek markeren:
// <Name>Uncovered code in complex methods</Name>
warnif count > 0
from m in JustMyCode.Methods
where m.CyclomaticComplexity > 10 && m.PercentageBranchCoverage < 100
select new { m, m.PercentageBranchCoverage, m.CyclomaticComplexity }
IL Cyclomatische Complexiteit voor Third-Party Code
NDepend biedt de eigenschap IL Cyclomatic Complexity, waarmee dezelfde metriek wordt toegepast op de .NET Intermediate Language (IL) in de DLL's van externe bibliotheken. Dit stelt je in staat om te meten hoe complex de interne methoden van een library daadwerkelijk zijn, wat een sterke indicator is voor hoe moeilijk het zal zijn om bugs in die library te debuggen.
Hoe Cyclomatische Complexiteit in C# te verminderen
Refactoring voor een lagere CC draait vooral om het extraheren van beslissingen uit het kritieke pad.
1. Extract Method
Haal een aaneengesloten blok beslissingen uit de methode en plaats deze in een nieuwe, goed benoemde methode.
2. Early Return / Guard Clauses
In plaats van diep geneste if-blokken, keer je direct terug bij ongeldige input. De CC-score blijft gelijk, maar de cognitieve belasting daalt aanzienlijk.
// Genest (Slecht)
public decimal CalculateFee(Order order) {
if (order != null) {
if (order.IsValid) {
if (order.Customer != null) {
return order.Total * 0.05m;
}
}
}
return 0;
}
// Guarded (Beter)
public decimal CalculateFee(Order order) {
if (order is null) return 0;
if (!order.IsValid) return 0;
if (order.Customer is null) return 0;
return order.Total * 0.05m;
}
3. Vervang Conditionals door Polymorfisme
Een lange switch op een type-discriminator is een klassieke code smell. Door gedrag te verplaatsen naar afgeleide klassen (Strategy of Template Method patterns), kan de centrale methode vaak worden teruggebracht naar CC = 1.
4. Gebruik moderne C# Pattern Matching en Switch Expressions
Switch-expressies moedigen een vlakkere, zij-effectvrije code aan dan ketens van geneste if's.
public static decimal DiscountFor(Customer c) => c switch {
{ IsVip: true, YearsActive: >= 5 } => 0.20m,
{ IsVip: true } => 0.10m,
{ YearsActive: >= 10 } => 0.08m,
{ YearsActive: >= 1 } => 0.03m,
_ => 0m
};
5. Gebruik Lookup Tables voor pure mappings
Als een methode hoofdzakelijk een lijst is van "input X map naar output Y", is een Dictionary<TKey, TValue> of een static readonly array bijna altijd superieur aan een keten van if's. Een dictionary-lookup heeft CC = 1, ongeacht het aantal items.
6. Booleaanse parameters zijn een Code Smell
Een methode die verschillende bool flags accepteert, verbergt vaak meerdere methoden in één. Het splitsen van bijvoorbeeld SendEmail(bool html, bool urgent, bool dryRun) in specifiekere methoden vermindert zowel de complexiteit als de kans op fouten bij het aanroepen.
Veelgestelde vragen
Wat is een goede Cyclomatische Complexiteit score in C#? Onder de 10 wordt als veilig beschouwd, tussen 10 en 20 behoeft aandacht, en boven de 25 wordt door Microsoft als overmatig gemarkeerd.
Verhoogt het else-trefwoord de Cyclomatische Complexiteit? Nee. De else-tak wordt al geïmpliceerd door de bijbehorende if. Alleen de if zelf telt.
Telt een switch-statement één keer of per case? Per case (en per default). Het switch-trefwoord zelf verhoogt de score niet.
Is de CC gelijk aan het aantal unit tests dat ik nodig heb? Het is een handige ondergrens, geen strikt contract. Het geeft het minimum aantal tests voor volledige pad-dekking, maar in de praktijk kunnen sommige paden onhaalbaar zijn of zijn er meer tests nodig voor verschillende datakombinaties binnen één pad.
Wat is het verschil tussen Cyclomatische Complexiteit en Cognitieve Complexiteit? Cyclomatische Complexiteit telt het aantal paden. Cognitieve Complexiteit (gepopulariseerd door SonarSource) weegt ook de nestingsdiepte en combinaties van booleaanse operatoren mee, omdat deze de code moeilijker leesbaar maken, zelfs als er geen nieuwe paden worden toegevoegd.
Hoe meet ik Cyclomatische Complexiteit in Visual Studio? Ga naar Analyze > Calculate Code Metrics > For Solution.
Werkt Cyclomatische Complexiteit op async- of LINQ-code? Ja. De meeste tools berekenen CC op het broncode-niveau. await zelf telt niet, maar de if, while en catch constructies eromheen wel. LINQ-operators zijn methode-aanroepen en tellen niet, maar de lambda's die aan hen worden meegegeven worden als eigen methoden geanalyseerd.
Conclusie
Cyclomatische Complexiteit is een van de oudste metrieken die nog steeds actief worden gebruikt, simpelweg omdat het direct correleert met reële pijn in het onderhoud. Een hoge score betekent meer paden om over na te denken, meer tests om te schrijven en meer plekken waar een bug zich kan verschuilen.
Een score op zich is echter slechts de helft van het plaatje. Een complexe maar zwaar getest methode is zelden het probleem; een matig complexe methode zonder tests die elke sprint wordt aangepast, wel. Door CC te koppelen aan coverage (of de CRAP-score), te focussen op veranderingen (delta's) in legacy-code en IL-complexiteit te gebruiken voor externe libraries, transformeer je een metriek uit de jaren '70 in een modern vroeg-waarschuwingssysteem voor technische schuld.
Groetjes,