„… if you need more than 3 levels of indentation, you’re screwed anyway, and should fix your program.“
Linus Torvalds im Linux kernel coding style
Was ist kognitive Komplexität?
Kognitive Komplexität ist ein Begriff aus der Psychologie. Je mehr Konzepte und Verhältnisse man parallel im Kopf behalten muss, um eine Sache zu verstehen, desto größer ist die kognitive Komplexität. Wir als Menschen, die mit Software arbeiten, treffen tagtäglich auf diese Thematik. Je verschachtelter und konditionaler die Zeilen eines Codes sind, desto schwieriger ist es, diese zu verstehen.
Jedes Softwareprojekt hat eine eigene, intrinsische fachliche Komplexität. Wir können diese inhärente Komplexität meist nicht reduzieren. Somit ist es unser Ziel, allgemeinverständlichen Code zu schreiben, den andere Entwickler verstehen können, ohne über jedes fachliche Detail Bescheid zu wissen.
Eine geringe kognitive Komplexität ist eines der Ziele von Clean Code.
Warum ist eine hohe kognitive Komplexität schlecht?
Komplexerer Code ist per Definition schwieriger zu verstehen. Dies hat mehrere Nachteile. Einerseits erhöht es die Lernkurve für neue Entwickler oder Entwickler, die in ein Projekt zurückkehren. Und selbst Erfahrenen dampft irgendwann der Kopf, insbesondere wenn sie in mehreren verschiedenen komplexen Projekten involviert sind und zwischen ihnen hin- und her springen müssen.
Zudem wird Software fehleranfälliger, wenn viele hochkomplexe Bestandteile miteinander interagieren. Gute Testabdeckung kann hier natürlich helfen.
Ein weiteres Problem: Komplexität in Softwareprojekten neigt dazu, durch weitere Anforderungen oder Sonderfälle immer weiter zu steigen, und sich damit immer weiter zu verselbstständigen. Eine gewisse Achtsamkeit ist beim Schreiben von Code also unerlässlich.
Wie bewerten wir die kognitive Komplexität von Code?
Tools zur Bewertung der kognitiven Komplexität als Bestandteil einer statischen Codeanalyse (wie Sonar) oder Standalonetools (wie das „Code Complexity“ Plugin für Intelli-J) verwenden drei einfache Regeln zur Ermittlung eines Wertung.
Ab einem Komplexitätswert von standardmäßig mehr als 15 fordern diese übrigens meist zum Überarbeiten des Codes auf.
1. Ignoriere Strukturen, die Code leserlich zusammenfassen
Im Prinzip meint diese Regel lediglich, dass Strukturen wie der null-coalescing operator keine Erhöhung des Komplexitätswerts mit sich bringen.
Dieses Statement erhöht den Komplexitätswert um 1:
let userId: number = null
if(user !== null) {
userId = user.id
}
Dieses hingegen nicht:
let userId = user?.id
Auch Streams werden nicht bewertet, da sie üblicherweise sehr einfach zu lesen sind – selbst wenn viele Operatoren aneinander gekettet sind. Sie erhöhen allerdings den nesting score für den Code in ihnen.
2. Erhöhe die Komplexität um 1 für jede Struktur, die den Lesefluss unterbricht
Allgemeine Operatoren
Diese Regel meint, dass alle Strukturen die den natürlichen Lesefluss des Codes von oben nach unten und von links nach rechts unterbrechen zu einer Erhöhung der Komplexität führen.
Zu diesen Strukturen gehören:
- Schleifen: for, while, do while etc.
- Bedingungen: ternäre Operatoren, if, etc.
Für Operatoren wie else und else if wird die Komplexität nicht extra erhöht, sie erhöhen aber den nesting score.
Auch catch Blöcke erhöhen den Score, allerdings nur um +1 – egal wieviele Exceptions ein einzelner catch-Block abfängt. Jeder weitere catch-Block erhöht den Score ebenfalls um +1. Try und finally werden ignoriert.
Jedes switch-Statement erhöht den Score um +1, egal wieviele case es gibt. Die Bewertung zur Kognitiven Komplexität bevorzugt also switch über if/else if/else, da die Bedingungen anhand derer die einzelnen Zweige abgelaufen sind bei switch Statements ziemlich klar definiert sind, während sich Bedingungen in if-Konstrukten voneinander unterscheiden können.
Auch Rekursion erhöht den Score für jeden Verweis um 1.
Logische Operatoren
Logische Operatoren erhöhen den Score für Kombinationen. Die beiden folgenden Aussagen werden exakt gleich bewertet:
a && b
a && b && c && d
Beide erhöhen den Score um +1.
Folgendes Beispiel würde einen höheren Komplexitätswert aufweisen:
a && b || c && d
Dieses würde den Score um +3 erhöhen, jeweils +1 für die beiden Konjunktionen und +1 für die Disjunktion.
Sprünge und Kontrollstatements
Die Operatoren break und continue können zu einer Erhöhung des Scores führen, wenn sie als goto verwendet werden. Unterbrechen sie Schleifen komplett werden sie jedoch ignoriert. Return erhöht den Score nie.
3. Erhöhe die Komplexität zusätzlich für jede Verschachtelungsebene (nesting score)
Als letzte Besonderheit schauen wir auf den nesting score. Je tiefer Verschachtelt unsere Funktion ist, umso mehr erhöhen Operatoren den Score. Tiefe Verschachtelung wird also bestraft.
Die nesting-Regeln gelten ebenfalls für Streams. Zwar erhalten diese keine eigene Komplexitätswertung, sie erhöhen allerdings die Verschachtelungsebene für jeglichen Code innerhalb der Stream-Struktur.
Wie werden wir hohe kognitive Komplexität los?
Wir wollen, dass unser Code intuitiv zu verstehen ist. Alles was dabei unseren Lesefluss von oben nach unten und von rechts nach links stört macht dies schwieriger.
Um entsprechende Codestellen zu finden gibt es nützliche Tools, beispielsweise die Regel „Cognitive Complexity“ von Sonar oder das gleichnamige Plugin für Intelli-J.
Was erhöht die Komplexität?
- Verschachtelte und verknüpfte Bedingungen
- Verschachtelte ternäre Ausdrücke
- Verschachtelte Schleifen
- Kombinationen von 1-3
Wir sehen, dass tatsächlich das Maß wie viel Code Eingerückt ist (level of indentation) ein starker Indikator für Code-Komplexität ist.
Was verringert die Komplexität?
- Auslagern kombinierter Bedingungen in Methoden
- Umkehren von Bedingungen
- Auslagern von Verschachtelungen in Methoden
- Nutzen von Streams
- Eine interne Struktur von Methoden
Eines der Hauptziele beim Abbauen der kognitiven Komplexität ist es, dass wir Code einfach von oben nach unten lesen können ohne zu viel Kontext zu benötigen.
Nehmen wir ein einfaches Beispiel:
Hier haben wir zwar keinerlei sonderlich komplexe Operationen und Sonderfälle, trotzdem ist es bereits gar nicht mehr so einfach den Code von oben nach unten zu lesen.
Das sieht bereits deutlich besser aus. Im oberen Teil können wir eine Stuktur erkennen:
Zuerst werden Rechte abgefragt. Wenn diese korrekt sind, wird die eigentliche Funktionalität abgearbeitet. Wir können zudem grob sehen, was die Funktion tun soll, ohne dabei genau wissen zu müssen, was markInactive eigentlich fachlich bedeutet.
In diesem simplen Beispiel sind einige gute Techniken zu erkennen, mit denen man sehr einfach (und meist mit Hilfe von IDE-Refactoring-Tools) die wahrgenommene Komplexität reduzieren und Code leichter lesbar machen kann.
Zusammenfassung
Wie sehr Code eingerückt ist steht im Zusammenhang damit, wie komplex wir Code wahrnehmen. Selbst simple Dinge werden schwer zu lesen, sobald wir in mehreren Ebenen Bedingungen kontrollieren und im Gesamtzusammenhang der Funktion verstehen müssen.
Als weitere Lektüre ist das originale Whitepaper zu kognitive Komplexität von G. Ann Campbell von Sonar empfehlenswert: https://www.sonarsource.com/docs/CognitiveComplexity.pdf