Ob Software schon und noch korrekt ist, wird im Review zurecht zuerst geprüft. Durch mangelhafte Korrektheit ist die Zukunftsfähigkeit des Softwareentwicklungsprozesses unmittelbar kompromittiert. Früher oder später werden Inkorrektheiten im Produktivbetrieb durch Anwender aufgedeckt, gemeldet und führen dann zu Verwirbelungen des Entwicklungsprozesses. Nachbesserungen sind der Feind des Neuen, Wertvollen. Sie kosten nicht nur Kapazität, die für Neuerungen nötig ist, sondern darüber hinaus einen Preis. Der besteht in Mehraufwand durch Fehlersuche und erneuten Aufbau eines mentalen Modells. Jeder unentdeckte Fehler, der in Produktion geht, ist eine Schuld, die mit Zinsen beglichen werden muss – und das immer zur Unzeit.
Doch auch wenn der Review nur 100% korrekte Software zum Release freigäbe, wäre ihre Zukunftsfähigkeit noch nicht zwangsläufig optimal. Denn auch korrekte Software will verändert werden. Die nächste Anforderung nach Erweiterung ihrer Funktionalität oder Erhöhung der Effizienz kommt bestimmt. Und dann soll es möglichst einfach sein, diesen Wunsch im Code umzusetzen. Logik muss verändert und ergänzt werden. Wie einfach ist das möglich? Das ist die Frage nach der Wandelbarkeit von Software.
Wie die Korrektheit hat auch die Wandelbarkeit zwei Seiten, die es zu betrachten gilt. Deren erste ist die…
Verständlichkeit als Aspekt der Wandelbarkeit
Bevor Code verändert werden kann, muss er verstanden werden. Wie wird das existierende Verhalten überhaupt im Zusammenspiel der vielen Module hergestellt? Wer das nicht versteht, kann nicht beurteilen, was wo verändert und/oder hinzugefügt werden muss, um zu neuem Verhalten zu kommen. Der Lösungsansatz für das Neue ist abhängig vom Vorhandenen.
Code wird viel häufiger gelesen, als geschrieben. Denn bevor eine Zeile Code geschrieben wird, müssen 5, 10, 50, gar Hunderte Zeilen Code gelesen werden. Durch das Lesen wir ein mentales Modell aufgebaut (oder aktualisiert), wie Verhalten aktuell entsteht. In diesem mentalen Modell werden Veränderungen als Lösungsalternativen simuliert. Und erst am Ende wird das veränderte Modell wieder in Code gegossen.
Aus diesem Grunde sollte Code für das Lesen optimiert sein. Wer das KISS-Prinzip aufruft (Keep It Simple, Stupid), der meint (oder sollte meinen) Simplizität, die die Ressource „Lesezeit“ des Entwicklers möglichst wenig belastet. (Simplizität wird hier verstanden als Funktion der knappsten Ressource.)
Für die Wandelbarkeit ist es also kontraproduktiv, die Zeit zu optimieren, die gebraucht wird, um Code zu schreiben. Der schnell geschriebene Einzeiler, der besonders knappe, elegante Code, die Kenntnis von IDE-Shortcuts… das alles ist unwichtig oder gar kontraproduktiv, falls es die Verständlichkeit des Codes negativ beeinflusst.
Verständlichkeit ist insofern auch relativ bzw. subjektiv. Sie entsteht im Auge des Lesers. Der Schreiber von Code kann sie oft nur schwer abschätzen und überschätzt sie schnell, weil er von sich im Moment des Schreibens ausgeht. Das mentale Modell, das er in dem Moment jedoch hat, kann nicht bei einem Leser vorausgesetzt werden – der der Schreiben selbst schon in 1 Stunde, 1 Woche oder 1 Monat sein kann.
Was ist Verständlichkeit?
Verständlich ist Code, für den zunächst die Frage „Wie entsteht die Funktionalität/Effizienz durch die existierende Logik?“ leicht zu beantworten ist. Und ist darauf eine Antwort gefunden, sollte die Frage „Welchen Effekt auf Funktionalität und Effizienz hat eine Veränderung von Logik?“ leicht zu beantworten sein.
Beide Fragen beziehen sich auf Logik an einem Ort und ihre Beziehung zu Logik an anderen Orten. Es geht also um die Struktur von Logik zur Herstellung von Verhalten. Klar erkennbare Bedeutungen und Zusammenhänge sind nötig. Dann ist Code „easy to reason about“.
Logik selbst fehlt Bedeutung. Sehen Sie selbst: Welche Funktionalität stellt die folgende Logik her?
var b = 0;
foreach(var c in System.IO.File.ReadAllLines(a))
if (!string.IsNullOrWhiteSpace(c)) b += int.Parse(c);
Wie lange brauchen Sie, um die Funktionalität in einem Satz zu beschreiben?
Wird die Verständlichkeit größer durch Verwendung moderner Sprachfeatures wie Linq in C# (oder Streams ab Java 8)?
var b = File.ReadAllLines(x)
.Where(y => !string.IsNullOrWhiteSpace(y))
.Select(int.Parse)
.Aggregate(0, (z, w) => z + w);
Logik – das sind Transformationen, Kontrollstrukturen und I/O – hat selbst keine Bedeutung. Die entsteht erst im Verlauf einer Interpretation durch einen Betrachter. Indem er Logik studiert, findet er heraus, wie durch sie Input in Output verwandelt wird. Dem gibt er abschließend eine Bedeutung.
Im Beispiel würde der Input in Form einer Datei (Dateiname in a
bzw. x
) mit dem Inhalt
1
2
3
z.B. in den Output (Variable b
) 6
transformiert.
Was bedeutet das aber? Was ist der Zweck?
Rein aus den einzelnen Funktionsaufrufen wie ReadAllLines()
, +=
, foreach()
, Parse()
, IsNullOrWhiteSpace()
ergibt sich die Antwort nicht automatisch. Ihr Arrangement in einer bestimmten Reihenfolge und Schachtelung muss immer aktiv gedeutet werden durch den Codekonsumenten.
Es sei denn… Ja, es sei denn, der Codeproduzent gibt Hilfestellung durch „sprechende Namen“.
Daten explizit machen und bedeutungsvoll benennen
Vorgegeben sind die Namen von verwendeten Funktionen wie oben gelistet. Die sind allerdings nur sprechend in Bezug auf ihre technische Domäne, z.B. den Umgang mit Dateien oder Zeichenketten.
Die Bedeutung, auf deren Suche der Codekonsument ist, ist jedoch eine in der Domäne, für die Verhalten hergestellt werden soll. Die ist orthogonal zur technischen Domäne von Frameworks, die die Logik benutzt.
Es ist ja gerade die Aufgabe des algorithmischen Entwurfs, technische gegebene Funktionalität so zu arrangieren, dass ein neuer Effekt für die Problemdomäne entsteht. Das ist das ureigene Metier des Softwareentwicklers. Hier ist seine Kreativität und Expertise gefordert.
Für das obige Logikbeispiel hat die (bewusst formal formulierte) Aufgabe gelautet „Finde die passenden technischen Funktionen und arrangiere sie so, dass sie zusammen den Zweck erfüllen, ganze Zahlen notiert auf separaten Zeilen einer Textdatei zu summieren.“
Wie die Logik für diese Aufgabe am Ende ausfällt, hängt von der Kenntnis der technischen Funktionen und der Kreativität des Entwicklers ab. Wer Linq nicht kennt, kann Linq nicht benutzen. Wer ReadAllLines()
nicht kennt, kann die Funktion nicht nutzen und muss auf anderem Weg an die Zeilen der Textdatei kommen. Auch hier wird wieder die Relativität der Verständlichkeit von Code deutlich.
Soll Logik nun mit Bedeutung aufgeladen werden, ist das erste Mittel die Verwendung von Variablen (und Konstanten). Die sollten auf den Zweck hin benannt werden. Ihre Namen sollten bedeutungsvoll sein.
Das erste Beispiel könnte z.B. so mit Bedeutung aufgeladen werden:
var sum = 0;
foreach(var line in File.ReadAllLines(filename))
if (!string.IsNullOrWhiteSpace(line)) sum += int.Parse(line);
filename
, line
, sum
sind Bezeichnungen aus der Domäne. Es geht ja um Dateien (filename
) in deren Zeilen (line
) Zahlen stehen, die summiert (sum
) werden sollen.
Wer die Domäne und das Problem kennt, wird sich nun in der Logik leichter zurechtfinden. Wer das Problem nicht kennt, wird schneller darauf kommen.
Optimal ist die Lesbarkeit aber immer noch nicht. Es fehlt z.B. ein Name für einen zentralen Begriff der Domäne: Zahl. Die Zahlen sind nur repräsentiert durch int.Parse()
. Das zu erkennen, erfordert jedoch wieder Deutungsarbeit. Besser wäre es, die Bedeutung im Code explizit zu machen.
Ähnlich ist es für die Zeilen der Datei. Eine einzelne Zeile ist zwar benannt (line
), doch der Gesamtinhalt der Datei findet sich nicht repräsentiert. ReadAllLines()
beschafft die Zeilen und ist ein recht sprechender Name, doch der Aufruf ist eingeschachtelt im Schleifenkopf. Dort fällt er nicht so auf. Besser also, den Dateiinhalt in eine eigene Variable herausziehen:
var sum = 0;
var linesFromFile = File.ReadAllLines(filename);
foreach (var line in linesFromFile)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
Daten der Domäne überhaupt explizit zu machen über Variablen und diese dann auch noch „sprechend“ oder „selbsterklärend“ zu benennen, ist ein erster wesentlicher Schritt zu guter Verständlichkeit von Code.
Leider wird der immer wieder übergangen. Vermeintlicher Zeitmangel, der Unwille bzw. die Unfähigkeit, sich in den zukünftigen Codekonsumenten hineinzuversetzen, ungenügendes Domänenverständnis, unklarer Lösungsansatz, Optimierungswille… es gibt viele Gründe, warum Namen fehlen oder schwer verständlich sind.
Spätestens im Review darf es dafür jedoch kein Pardon geben!
Logik bedeutungsvoll zusammenfassen mit Modulen
Variablennamen können einzelne Codezeilen mit Bedeutung aufladen, z.B.
var number = int.Parse(line);
Der Name number
ist noch recht allgemein, doch er passt zur Domäne, die auch allgemein ist. Es sollen nur „irgendwelche“ Zahlen aufsummiert werden. Wozu? Was das für Zahlen sind? Das ist nicht bekannt.
In anderen Domänen mag das klarer sein. Dort sollten dann spezifischere Namen benutzt werden, z.B.
var height = int.Parse(line);
oder
var age = int.Parse(line);
Aus den Bedeutungen einzelner Zeilen ergibt sich allerdings nicht automatisch eine Bedeutung für alle zusammen. Die muss auch bei schönster Benennung von Variablen und Konstanten noch durch Logikstudium erarbeitet werden. Das kostet Zeit.
Um diese Zeit zu sparen oder zumindest zu reduzieren, sollte Logik, die inhaltlich zusammengehört, mit einem eigenen Namen versehen werden. Das Mittel dazu sind die kleinsten Module: Funktionen.
Für das Beispiel könnte eine zusammenfassende Funktion z.B. so lauten:
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
}
Wer die Logik sieht, bekommt durch den Funktionsnamen und den Parameter und den Resultatstyp sofort mitgeteilt, worum es geht. Keine Deutung nötig. Der Codekonsument kann dann entscheiden, ob er dennoch die Logik genauer studieren möchte, um zu verstehen wie der Zweck en detail erreicht wird. Um den Zweck zu erkennen, ist das aber nicht mehr wie vorher zwingend nötig.
Mit guten Funktions- und Datennamen ist der Codekonsument in der Lage, durch die Logik zu „springen“. Er springt beim Funktionsnamen ab und benutzt die Namen in der Logik als Trittsteine. Statt jede Zeile ausführlich zu studieren, kann der Blick größere Einheiten erfassen, die durch die Namen repräsentiert werden.
Voraussetzung für hilfreiche Funktionsnamen ist natürlich wieder ein gewisses Einfühlungsvermögen des Codeproduzenten. Der muss vorhersehen, welcher Name einem späteren Codekonsumenten schnellstmöglich Klarheit verschafft. Wie informativ ist der Name bei Blick auf die Funktion, wie informativ ist der Name bei Blick auf einen Aufruf der Funktion?
var totalNumberOfSales = aggregator.SumUpFileContent("numbers.txt");
Die Namensgebung fällt umso leichter, je geringer der Umfang der unter einem Namen zusammengefassten Logik. Für die obigen 8 Zeilen, liegt der Name auf der Hand. Für 50, 100, 500, 5000 Zeilen jedoch… ist es viel schwieriger, „sprechende“, domänenrelevante Namen zu finden. Je mehr Logik in einer Funktion steht, desto wahrscheinlicher, dass die mehr als eine Verantwortlichkeit hat. Die Einhaltung des SRP (Single Responsibility Principle) ist also Voraussetzung für gute Namen.
Auch die Zusammenfassung von Logik zu Funktionen ist eine zentrale Leistung des Softwareentwicklers. Das ist Abstraktion par excellence: Aus der Vielheit (mehrere Zeilen Logik) wird eine Gemeinsamkeit herausdestilliert (hier: Zweck). Das Viele wird zu einem neuen Ganzen auf höherer Ebene verbunden. Der Name für das Ganze steht für die Kohäsion der Teile.
Gleichzeitig wird durch die Zusammenfassung von Logik in einer Funktion eine Klammerung vorgenommen. Die Logik hinter dem Namen hängt enger untereinander zusammen als mit anderer Logik. Der Name steht also auch für Trennung, er entkoppelt.
Dieses Vorgehen funktioniert auf mehreren Ebenen in der Softwareentwicklung. Immer, wenn „viel auf einem Haufen liegt“, kann Ordnung hergestellt werden durch Zusammenfassung. Was eben noch bedeutungsfrei aufgrund von Unübersichtlichkeit war, bekommt Bedeutung durch eine benannte Klammer.
- Logik kann zu Funktionen zusammengefasst werden.
- Funktionen können zu Klassen zusammengefasst werden.
- Klassen können zu Bibliotheken zusammenfasst werden.
- Funktionen und Klassen können außerdem noch zu Namensräumen zusammengefasst werden. (Namensräume sind allerdings „schwächere“ Zusammenfassungen als Klassen und Bibliotheken, da sich in ihnen in vielen Sprachen keine Sichtbarkeiten unterscheiden lassen. Namensräume definieren keinen Kontrakt. Sie gehören für uns daher nicht zur Kategorie der Module.)
Der Review prüft, ob diese Mittel genutzt wurden, um Logik mit Bedeutung aufzuladen. Manchmal ist es einfach zu erkennen, wo Bedeutungsgrenzen verlaufen, manchmal schwieriger. Es gibt harte, formale Kriterien für die Grenzziehung. Doch letztlich ist es eine Sache Ihrer Erfahrung und des ständigen Bemühens, Ihren „Bedeutungssinn“ zu schärfen, mit dem Sie Verantwortlichkeiten in Logik erkennen.
Wie steht es z.B. mit der Logik in der obigen Methode? Ist die Bedeutung schon klar genug durch den Methodennamen und die Variablennamen? Hat die Logik nur eine Verantwortlichkeit oder sind mehrere darin vermischt?
Den Modulumfang begrenzen
Namen vermitteln auf einen Blick Bedeutung. Was auf einen Blick erfassbar ist, kann leicht mit einem mentalen Modell abgeglichen werden bzw. es aufbauen helfen.
Deshalb gehört zur Beurteilung der Verständlichkeit im Review auch die Beurteilung des Umfangs von Modulen. Wie viele Zeilen Logik enthält eine Methode? Wie viele Methoden enthält eine Klasse? Wie viele Klassen enthält eine Bibliothek? Passt der Modulinhalt „in den Kopf“? Ist also erstens die Zahl der Elemente überschaubar und sind zweitens die Beziehungen zwischen diesen Elemente klar?
Als Begrenzung für den Umfang liegt eine durchschnittliche Bildschirmhöhe nahe. Solange die komplette Logik noch auf den Bildschirm passt, kann sie im wahrsten Sinn des Wortes mit einem Blick erfasst werden. Je nach Orientierung des Bildschirms und Fontgröße begrenzt das die Länge von Methoden auf vielleicht maximal 50-70 Zeilen. Das ist immer noch viel – doch es ist viel weniger als „in der freien Wildbahn“ zuweilen zu finden ist. Methoden mit 500, gar 5000 Zeilen durchsetzen viele Codebasen.
Sobald das, was Sie versuchen zu verstehen, über den Bildschirm hinaus reicht, wenn Sie also immer wieder genötigt sind zu scrollen, um das Ganze zu sehen, müssen Sie deutlich mehr kognitiven Aufwand treiben, um Probleme zu lösen. Sie können nicht mehr alle Bestandteile „im Kopf jonglieren“, sondern müssen Teile „nachladen“, „auffrischen“. Das gilt es zu vermeiden.
Ohne weitere Kriterien ist eine Begrenzung auf die Bildschirmhöhe natürlich nur ein erster, pauschaler Schritt zu verständlicherem Code. Das gilt für Methoden, Klassen und Bibliotheken. Passt die Liste ihrer Elemente auf einen Bildschirm? Das wären vielleicht 50-70 Methoden bzw. 50-70 Klassen in einem Class- bzw. Project-Browser.
Kleinere Zahlen sind wünschenswert. Doch als erste Näherung und Maximalwerte mögen sie taugen. Für manche Codebasen stellen sicherlich schon sie eine Herausforderung dar.
Letztlich sind solche Zahlen aber natürlich willkürlich. Es kommt auch nicht wirklich auf die Zahl an, sondern darauf, dass das, was verstanden werden soll mit wenig Aufwand „in den Kopf passt“. Für Mengen, die mit einem Blick zu überschauen sind, ist das wahrscheinlicher als für größere. Es gibt allerdings auch gelegentlich größere Mengen, die leicht zu verstehen sind, wenn darin ein klares Muster erkennbar ist. Viel häufiger sind jedoch Mengen, die schon bei 50 Elemente schwer verständlich sind. Dann hilft nur Zerlegung in Untermengen mit jeweils höherer Kohäsion und guter Entkopplung.
Zerlegung des wenig Zusammengehörigen und Zusammenfassung des hoch Kohäsiven: das sind die wesentlichen strukturierenden Handlungen des Softwareentwicklers.
Den Lesefluss natürlich gestalten
Verständlichkeit ist nicht nur eine Sache der Benennung von Bedeutungseinheiten und ihrer Größenbegrenzung. Damit werden ja nur die Strukturelemente besser erkennbar. Wie steht es aber um die Zusammenhänge?
Softwareverhalten entsteht durch einen Kontrollfluss. Logik wird sequenziell abgearbeitet. (Von der Möglichkeit der Parallelverarbeitung sei hier zunächst abgesehen. Ihr Review stellt weitere Anforderungen.) Wer verstehen will, wie Logik Verhalten herstellt, muss also diesen Fluss deutlich erkennen können.
Am leichtesten ist ein Verarbeitungsfluss verstanden, wenn er so angeordnet ist, dass er der gewohnten Leserichtung entspricht. Code ist Text. Text wird in der westlichen Welt von oben nach unten und von links nach rechts gelesen. Logik sollte dieser Gewohnheit entsprechen.
Leider jedoch widerspricht der Aufbau von Logik dieser simplen Regel oft. Selbst eine klare Sequenz wie Lesen, Verarbeiten, Ausgeben wird im Code „verklausuliert“. Dort finden sich Konstrukte wie:
Console.WriteLine(b.Append(Console.ReadLine()));
Das können Sie als Entwickler natürlich irgendwie verstehen – nur ist das mühsamer als eine Sequenz, die dem „natürlichen“ Lesefluss entspricht. Hier muss die Leserichtung „mit Gewalt“ auf „von rechts nach links“ gedreht werden. Das Verständnis gerät dabei für einen Moment ins Stocken.
Wie viel flüssiger ist dagegen dieser Code zu verstehen:
var a = Console.ReadLine();
b.Append(a);
Console.WriteLine(b);
Von oben nach unten steht dort die Sequenz der Verarbeitung. Selbst mit schlechten Namen für die Daten ist zumindest klar, „was läuft“.
Geschachtelte Funktionsaufrufe sind gut gemeint. Sie sollen einerseits Zeit bei der Codeproduktion sparen, andererseits helfen sie, die Zeilenzahl einer Methode zu reduzieren. Beides geht jedoch auf Kosten der Lesezeit des Codekonsumenten. Der wird mehr belastet.
Anordnungen von Logik, die den Lesefluss behindern, sind daher zu vermeiden. Die Gewohnheit „von oben nach unten und von links nach rechts“ sollte so häufig wie möglich bedient werden.
In dieser Hinsicht ist auch Funktionale Programmierung hilfreich. Wo üblicherweise und ohne sie geschrieben wird
f(g(h(x));
erlaubt sie die Schreibweise:
h(x, y => g(y, z => f(z)));
Mit funktionaler Programmierung können Sie Funktionen geschachtelt in der Reihenfolge notieren, in der sie aufgerufen werden, selbst wenn das nur unter bestimmten Bedingungen geschieht oder nur optional.
In F# geht es sogar noch einfacher:
x |> h |> g |> f
Wie gesagt: Bei der Verständlichkeit geht es nicht darum, für den Codeproduzenten zu optimieren, sondern für den Codekonsumenten. Wenn Code in Leserichtung ein paar Tastendrücke mehr brauchen sollte, dann ist das kein Bug, sondern womöglich ein Feature.
Das ist für den objektorientierten Programmierer ungewohnt; er ist auf die Umkehrung des Blickes trainiert. Doch diese Gewohnheit kann man ablegen. Wer einige Male mit funktionalen Features wie Lambda Ausdrücke und Closures in Sprachen wie C#, Java 8 oder auch Ruby, Python usw. gearbeitet hat, wird kaum zurück wollen. Lesefluss in gewohnter Richtung ist ein großer Gewinn für die Verständlichkeit.
Schon das obige zweite Beispiel für schwer verständlichen Code
var b = File.ReadAllLines(x) .Where(y => !string.IsNullOrWhiteSpace(y)) .Select(int.Parse) .Aggregate(0, (z, w) => z + w);
war besser zu verstehen als das erste. Denn hier ist der Lesefluss auch ungebrochen von oben nach unten: Zeilen werden gelesen, Leerzeilen werden herausgefiltert, Zeileninhalte werden in Zahlen gewandelt, Zahlen werden summiert. Vier klare und überschaubare Teilverantwortlichkeiten sind durch Linq zu einer natürlichen Sequenz verknüpft.
Aber auch ohne funktionale Features wie Linq oder Lambda Ausdrücke lässt sich die Lesbarkeit steigern, in dem schlicht auf geschachtelte Aufrufe verzichtet wird. Führen Sie stattdessen lieber hier und da temporäre Daten ein. Das hat oben schon den Code lesbarer gemacht:
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
Die Variablennamen sind nicht nur sprechend, es gibt sie vor allem überhaupt. Durch lines
und number
wurde die Schachtelung reduziert. Der Lesefluss ist stärker von oben nach unten als in der initialen Version des Codes.
Verzweigungen beschneiden
Wenn der Produktionsfluss für das Verhalten dem Lesefluss entspricht, kann Logik leichter interpretiert werden. Das mentale Modell wird beim Lesen Schritt für Schritt flüssig aufgebaut.
Wohin soll der Blick jedoch wandern bei einer Fallunterscheidung mit if-then-else
oder switch-case
oder try-catch
? Letztlich müssen 2 oder mehr Lesepfade verfolgt werden. Bei einer Schleife wie for
schlägt das Lesen sogar einen Purzelbaum.
Kontrollstrukturen stellen Verzweigungen im Produktionsfluss dar. Sie sind nötig – erschweren jedoch das Verständnis. Alternativen im Kopf zu behalten und zu überlegen, die das Verhalten in dem einen oder anderen Fall aussieht, ja, überhaupt zu erkennen, wann welcher Fall vorliegt… das erfordert einigen mentalen Aufwand.
Die Zahl der Verzweigungen, die eine Funktion – also ein überschaubarer Block Logik – enthält, sollte daher begrenzt werden. Jede Kontrollstruktur in einer Funktion senkt ihre Verständlichkeit deutlich. Das spiegelt auch die Metrik Zyklomatische Komplexität wider.
Versuchen Sie daher, es bei nur einer Kontrollstruktur je Funktion zu belassen. Daraus folgt auch: Schachteln Sie Kontrollstrukturen nicht.
Aber was, wenn mehr Kontrollstrukturen nötig sind oder sogar Schachtelung? Dann lagern Sie Kontrollstrukturen in eigene Funktionen aus. Das führt auch zu einer ganz natürlichen Reduktion des Umfangs von Funktionen. Für Funktionen, deren Logik auf einen Bildschirm passt, müssen Sie nicht Zeilen zählen, sondern vor allem die Zahl der Kontrollstrukturen konsequent begrenzen.
Wenn Sie die Verästelung des Verzweigungsbaumes bewusst kontrollieren und begrenzen, fällt das mentale Modell für die Logik pro Funktion deutlich einfacher aus.
Die bisher schon recht verständliche Funktion für die Summierung von Zahlen in einer Datei
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
if (!string.IsNullOrWhiteSpace(line)) {
var number = int.Parse(line);
sum += number;
}
return sum;
}
kann also noch eine Überarbeitung vertragen.
Zwei Kontrollstrukturen sind eine zu viel. Das if
wandert in eine eigene Funktion:
public int SumUpFileContent(string filename) {
var sum = 0;
var lines = File.ReadAllLines(filename);
foreach (var line in lines)
sum += Parse_line_to_number(line);
return sum;
}
private int Parse_line_to_number(string line) {
if (!string.IsNullOrWhiteSpace(line))
return int.Parse(line);
else
return 0;
}
Die neue Funktion hat eine ganz enge Verantwortlichkeit: Sie übersetzt einen Text in eine Zahl. Enthält der Text keine Zahl, wird 0 als Ergebnis geliefert. Wie geprüft wird, dass der Text wohlgeformt ist, verbirgt sich nun hinter einer Signatur. Das Verfahren kann sich damit jederzeit ändern, ohne dass die konsumierende Logik angefasst werden müsste.
Jede Kontrollstruktur stellt im Grunde eine Sinneinheit dar. Der eine explizite Bedeutung zu geben, in dem Sie sie in eine Funktion extrahieren, liegt also nahe. Seien Sie sensibel für diese „Hinweise“ der Logik. Sie hilft Ihnen aus sich heraus bei der Modularisierung.
Das ernst genommen, kann der Beispielcode noch ein weiteres Mal refaktorisiert werden. Auch das foreach
hat ja eine benennbare Bedeutung:
public int SumUpFileContent(string filename) { var lines = File.ReadAllLines(filename); return SumUpLines(lines); } private int SumUpLines(IEnumerable<string> lines) { var sum = 0; foreach (var line in lines) sum += Parse_line_to_number(line); return sum; } private int Parse_line_to_number(string line) { if (!string.IsNullOrWhiteSpace(line)) return int.Parse(line); else return 0; }
Das ist nochmal eine Steigerung der Verständlichkeit. Jede Methode ist nun sehr klein von der Zeilenzahl her und gleichzeitig sehr fokussiert in der Aufgabe.
Kontrollstrukturen lassen sich nicht vermeiden, ja, sie sind geradezu ein Herzstück von Logik; ohne sie bliebe Software trivial. Doch der Mehraufwand, den Sie beim „reasoning about code“ verursachen, lässt sich kompensieren durch Begrenzung ihrer Nutzung pro Funktion und Kapselung in eigenen Methoden. So kann das Lesen und Verstehen besser fließen.
Das Abstraktionsniveau einheitlich gestalten
Logik ist das Mittel, mit dem Verhalten hergestellt wird. Wenn Sie Logik lesen, dann sehen Sie, wie das funktioniert. Logik-Anweisungen sind technische Bausteine, aus denen ein Gebäude mit Zweck für die Problemdomäne errichtet wird.
Variablennamen und Modulnamen geben diesem Wie der Logik dann eine Bedeutung. Sie stellen Abstraktionen dar, weil es mit ihnen möglich ist, beim Lesen von den technischen Details abzusehen.
Die folgende Transformation ist pure Technik, sie hat keine Bedeutung. Die Frage „Was passiert in der Codezeile?“ (oder auch: „Wozu gibt es diese Codezeile?“, „Was ist der Beitrag dieser Codezeile zum Ganzen?“) bedarf einigen Interpretationsaufwandes:
int.Parse(line)
Wenn dem Ergebnis jedoch mittels eines Namens eine Bedeutung in einer Domäne zugeordnet wird, wird aus dem Wie ein Was, z.B.
var number = int.Parse(line);
oder
var quantity = int.Parse(line);
Das Wie ist weiterhin sichtbar, doch der Name davor erlaubt dem Betrachter, es zu ignorieren. So kann er schneller ein Verständnis für Logik aufbauen, ohne sofort alle Details zu studieren. Im Grunde reicht es, die Variablennamen von oben nach unten zu scannen, um den Produktionsfluss der Logik zu erkennen. Das könnte für die Summierung des Dateiinhaltes so aussehen:
var lines = …
var numbers = …
var sum = …
return sum;
Auf einem höherem Abstraktionsniveau als dem der Logik ließe sich damit die Frage beantworten, was da eigentlich passiert in all den Zeilen. „Aha, es werden Zeilen von irgendwoher beschafft, dann werden Zahlen beschafft (wahrscheinlich aus den Zeilen), und schließlich wird eine Summe beschafft (wahrscheinlich aus den Zahlen).“
Das funktioniert – doch vielleicht haben Sie es gespürt, irgendwie ist das noch mühsam. Die Bedeutungen bezeichnen nur Daten, das Verhalten müssen Sie sich denken. Deshalb auch die Spekulationen wie „wahrscheinlich aus den Zeilen“ usw. Wenn Sie es genauer wissen wollen, müssen Sie doch wieder rechts von den Variablennamen auf die Logik schauen.
Das ist ok und unvermeidlich, allerdings stellt es einen Wechsel im Abstraktionsniveau dar. Eben war es noch hoch bei Betrachtung des Namens, dann ist es niedrig beim Studium der Logik.
Solcher Wechsel strengt an. Das ist, als würden Sie einen Text in zwei Sprachen lesen. Some sentences might be in German, some in English. Das können Sie grundsätzlich. Since you’ve learned English in school or on the job. Doch erfordert es more mental effort, zwischen the two languages zu wechseln.
Code, der unterschiedliche Abstraktionsniveaus enthält, ist wie eine Straße mit Schlaglöchern. Wo die Straßendecke glatt ist, können Sie mit hoher Geschwindigkeit fahren. Doch dann ein Schlagloch und Sie bremsen vorher ab oder werden unschön überrascht und es kracht.
Besser eine Straße ohne Schlaglöcher, ein Text nur in einer Sprache, Code auf einheitlichem Abstraktionsniveau.
Das lässt sich erreichen durch Kapselung der Logik in eine Funktion, z.B.
var lines = Read_text_from(filename);
var numbers = Convert_to_numbers(lines);
return Calculate_sum(numbers);
Jetzt ist das Abstraktionsniveau nochmal gestiegen. Es ist keine Logik mehr sichtbar. Alles ist mit sprechenden Namen der Domäne benannt.
Es gibt also (mindestens) drei Abstraktionsniveaus im Code:
- Logik ohne Namen
- Logik benannt durch einen Variablennamen
- Logik verborgen hinter einem Funktionsnamen
Selbstredend ist Code umso verständlicher, je höher das Abstraktionsniveau. Auf Level 3 können Sie viel schneller erkennen, was da passiert als auf Level 2 oder 1. Im Review wollen Sie daher möglichst viel Code auf hohem Niveau sehen.
Aber es hilft nichts: irgendwo muss Logik zu sehen sein, denn sonst wird ja kein Verhalten erzeugt. Code nur auf Level 3 ist nicht möglich.
Hier ein Beispiel für eine Methode, wie sie auch im Zusammenhang mit dem Summieren von Zahlen in einer Datei entstanden sein könnte:
internal class FileProvider { public int[] Load(string filename) { var lines = System.IO.File.ReadAllLines(filename); var numbers = new List<int>(); for (var i = 0; i < lines.Length; i++) if (!string.IsNullOrWhiteSpace(lines[i])) numbers.Add(int.Parse(lines[i])); return numbers.ToArray(); } }
Es wurde also schon ein Teil der Logik herausgezogen in eine Funktion. Die beschafft sogar gleich die Zahlen aus einer Datei, nicht nur die Textzeilen wie oben mit Read_text_from()
angedeutet.
Der Zweck der Funktion ist recht fokussiert – dennoch stolpert das Verständnis beim Lesen des Codes. Bemerken Sie auch Schlaglöcher?
Die Funktionsdefinition dient der Level 3 Abstraktion. Welche Abstraktionslevel finden sich jedoch in der Funktion?
Hier die Logikzeilen ergänzt um das Abstraktionsniveau. Sie sehen, es ist eine Mischung.
var lines = System.IO.File.ReadAllLines(filename); // 2 var numbers = new List<int>(); // 2 for (var i = 0; i < lines.Length; i++) // 1 if (!string.IsNullOrWhiteSpace(lines[i])) // 1 + 1 numbers.Add(int.Parse(lines[i])); // 2 return numbers.ToArray(); // 2
Level 2 und 1 wechseln sich ab. Diese Logik folgt nicht dem SLA (Single Level of Abstraktion Principle).
Über die ersten beiden Zeilen fließt der Blick in gleicher Geschwindigkeit. Doch dann ein Schlagloch: die for
-Schleife. Was ist ihr Zweck? Das ist nicht anhand eines Namens erkennbar, also muss der Interpretationsaufwand erhöht werden. Danach wieder ein Schlagloch: das if
. Was ist dessen Zweck? Wieder kein Name, also nochmals mehr Interpretationsaufwand, während die Interpretation des for
noch nicht abgeschlossen ist. Die Bedingung des if
ist eine weitere Transformation auf Level 1. Die Verständnisfahrt ist sehr holprig.
Solche häufigen Wechsel des Abstraktionsniveaus sollten vermieden werden. Ohne Schachtelung von Kontrollstrukturen – wie oben empfohlen – wäre das Problem hier natürlich schon entschärft:
public int[] Load(string filename) { var lines = System.IO.File.ReadAllLines(filename); // 2 var numbers = new List<int>(); // 2 for (var i = 0; i < lines.Length; i++) // 1 numbers.Add(Parse(lines[i])); // 3 return numbers.ToArray(); // 2 } private int Parse(string line) { if (!string.IsNullOrWhiteSpace(line)) // 1 return int.Parse(line); // 1 else // 1 return 0; // 1 }
Dennoch enthält Load()
anschließend immer noch zwei Abstraktionsniveaus, nein, sogar drei. Denn durch Einsetzen von Parse()
beim Sammeln der Zahlen in einer Liste wurde die Zeile im Niveau angehoben. Dort kommen nun Variablenname und Domänenfunktionsname direkt zusammen.
Zur Beantwortung der Frage„Was tut die Schleife?“ muss sich der Codekonsument allerdings immer noch mühsam durch den Kontext arbeiten.
Leichter wäre es, würde das for
auch einen Namen bekommen. Dazu reicht aber nicht eine Variable, denn die Schleife selbst erzeugt kein Ergebnis, das zugewiesen werden könnte. Also hilft nur eine Kapselung in eine Funktion:
public int[] Load(string filename) { var lines = System.IO.File.ReadAllLines(filename); // 2 return Parse(lines).ToArray(); // 3 } private IEnumerable<int> Parse(IEnumerable<string> lines) { foreach (var line in lines) // 1 yield return Parse(line); // 3 } private int Parse(string line) { if (!string.IsNullOrWhiteSpace(line)) // 1 return int.Parse(line); // 1 else // 1 return 0; // 1 }
Statt einer großen Funktion mit Logik auf unterschiedlichen Abstraktionsniveaus gibt es nun mehrere kleine Funktionen, die entweder nur ein Abstraktionsniveau enthalten (Parse(string)
) oder bei unterschiedlichen Abstraktionsniveaus sehr überschaubar sind (Parse(IEnumerable<string>)
) oder durch die Differenzierung der Funktionen im Abstraktionsniveau abgehoben wurden (Load()
).
Bei den Parse()
-Methoden lässt sich nichts mehr tun. Auch wenn der Unterschied zwischen Level 1 und Level 3 bei Parse(IEnumerable<string>)
zwar groß ist, die Schleife ist nun auf eine Zeile Kopf und eine Zeile Inhalt zusammengedampft. Weniger geht nicht.
Aber wie steht es mit Load()
? Der Unterschied zwischen Level 2 und Level 3 könnte noch ausgeglichen werden.
Das ist einer Ermessensfrage. Wichtig ist es, zunächst den Unterschied überhaupt wahrzunehmen. Stört er jedoch den Lesefluss und das Verständnis?
In diesem konkreten Fall eher nicht. ReadAllLines()
ist schon ein sprechender Name. Diese Logik weiter zu kapseln z.B. in Load_text_from_file()
brächte kaum Verständnisvorteil.
Aber in anderen Situationen kann der Abstraktionsniveauunterschied eine Motivation darstellen, noch weitere Funktionen herauszuziehen, um ein einheitliche(re)s Level zu bekommen.
Drei verschiedene Abstraktionsniveaus lassen sich also leicht unterscheiden. Gibt es weitere? Ja, auch Domänenfunktionen selbst können auf unterschiedlichem Abstraktionsniveau liegen.
Funktionen, die nur Logik enthalten (z.B. Parse(string)
) (sog. Operationen), liegen tiefer als Funktionen, die Logik enthalten, aber auch andere Domänenfunktionen aufrufen (z.B. Load()
) (sog. Hybride). Und solche Funktionen liegen wiederum auf einem niedrigeren Abstraktionsniveau als Domänenfunktionen, die nur andere Domänenfunktionen aufrufen (sog. Integrationen). Es sind also mindestens fünf Level zu unterscheiden:
- Reine Logik
- Benannte Logik
- Operation
- Hybrid
- Integration
Kommentare auf das Warum konzentrieren
Kommentare stehen einerseits im Ruf, die Lesbarkeit von Code zu erhöhen. Andererseits wird empfohlen, auf Kommentare zu verzichten, weil sie Code verrauschen und mit ihm aus dem Tritt geraten können.
Wie Sie es also machen, es scheint verkehrt.
Wir empfehlen jedoch eine dritte Position: angemessene Kommentare sind hilfreich. Sie müssen also nicht auf Kommentare verzichten – sich aber wahrscheinlich etwas umorientieren.
Die erste Regel im Hinblick auf Kommentare lautet für uns: so wenig wie möglich und so viel wie nötig.
Wenn Sie die Lesbarkeit steigern wollen, greifen Sie zuerst zu einem der anderen vorgestellten Mittel. Kommentare sollen nicht fehlende oder schlechte Namen oder undurchsichtige Kontrollstrukturen kompensieren. Ist die Logik, also das Wie, jedoch mit sprechenden Namen versehen und bedeutungsvoll gekapselt, ist das Was deutlich zu erkennen.
Allerdings kann es immer noch eine Verständlichkeitslücke geben. Überprüfen Sie dann, ob die mit automatisierten Tests geschlossen werden kann. Sie zeigen den Code in Anwendung. Das verstärkt ein Verständnis für das Was.
Erst wenn jetzt immer noch eine Lücke besteht, setzen Sie Kommentare ein. Die sollen sich dann jedoch auf das beziehen, was nicht mit Logik, Namen und Modulen ausgedrückt werden kann. Das ist das Warum.
Kommentare, die Beweggründe, Konzepte, Voraussetzungen beschreiben oder einen Überblick über den Lösungsansatz geben oder Begriffe erklären (Glossar), sind willkommen, ja sogar geboten.
Zusammenfassung
Verständlicher Code ist
- sprechend
- geradlinig
- flach
- überschaubar
- nivelliert
- begründet
Das zu überprüfen, ist für den Review wahrscheinlich die anspruchsvollste Aufgabe. Hier ist Augenmaß und Fingerspitzengefühl nötig. Erfahrung spielt eine große Rolle. Mit ein wenig gutem Willen, lassen sich jedoch große Fortschritte auch in legacy code machen.
Wenn sich gerade zu Anfang dabei heftige Diskussionen ergeben, bleiben Sie am Ball. Wo lange keine Reviews gemacht wurden, muss erst auf diesem Wege ein gemeinsames Verständnis erarbeitet werden. Das ist wie ein neuerliches Storming und Norming in der Teambildung.
Mit der Zeit werden dann auch die vielfach zu sehenden umfangreichen Coding Guidelines überflüssig. Wo Teams sich regelmäßig zum Review treffen, findet eine Angleichung automatisch statt.
Weitere Artikel in dieser Serie:
- Teil I – Maturität
- Teil II – Regressionsfreiheit
- Teil III – Verständlichkeit
- Teil IV – Testbarkeit