Next Level Lesbarkeit
Es gibt nichts Schlimmeres, als zwei Stunden vor einem Spielfilm zu sitzen, dessen Handlung schwer verfolgbar ist, in dem die Charaktere inkonsistente Entscheidungen treffen und die Szenen nicht zueinander passen? Aber sicher: sich acht Stunden am Tag mit Quellcode zu befassen, der ähnlich geschrieben ist!
Wir Entwickler lesen sehr viel Code. Vielleicht nicht acht Stunden pro Tag, aber sehr viel. Tatsächlich verbringen wir viel mehr Zeit mit Lesen als mit Schreiben und es ist einfach zu sehen, wieso. Um etwas ändern zu können ist es unerlässlich, es zuerst zu verstehen. Und zwar nicht nur zu verstehen wie es technisch funktioniert, sondern was es eigentlich "bedeutet" – "wieso" es so geschrieben worden ist.
Wie beim Gestalten von Spielfilmen ist unsere Aufgabe als Entwickler daher nicht nur, zu zeigen, dass am Ende das Gute gewinnt – also dass unser Code kompiliert und funktioniert. Unsere Aufgabe ist es eigentlich, eine Geschichte zu erzählen, Information über unsere Fachlichkeit an unsere Leserinnen und Leser zu übermitteln, damit diese in der Lage sind, diese Fachlichkeit zu verstehen und potentiell zu ändern.
Spielfilme nutzen nicht nur die Schauspieler und den Dialog, sondern Beleuchtung, Platzierung, Framing, Geräusche, Musik, Bewegung usw. um Gefühle, Vorahnungen, Bedeutung, also Informationen zu kommunizieren. Genauso können wir auch mehrere Werkzeuge gleichzeitig einsetzen, um die Bandbreite des Informationsübertrags zu maximieren.
Wie gut man diese einsetzt kann in fünf Stufen eingeteilt werden, von "0" (geringe Bandbreite, schlecht lesbar) bis "4" (hohe Bandbreite, gut lesbar).
Level 0: Funktioniert
Der Held oder die Heldin gewinnt zwar, das Publikum ist aber emotional nicht eingebunden. Die Handlung ist wirr und kompliziert, mit langen und sinnlosen, schlecht gelichteten Szenen, die nicht immer logisch aneinander passen und nur da sind, weil das Geschehen weitergehen muss. Am Ende gibt es oftmals offene Punkte oder sogar Logikfehler.
Das entspricht etwa prozeduralem Code, mit langen Methoden, tief geschachtelten Schleifen und Bedingungen. Klassen werden nur genutzt, weil die Methoden syntaktisch nicht allein stehen dürfen und heißen oftmals "Service" oder "Manager". Es gibt keinen richtigen privaten Zustand, alles sind entweder nur Daten, die mit getter-Methoden abgreifbar sind, oder "Services" bei denen meist nur andere "Services" injiziert werden.
Wir alle haben solchen Code schon mal gesehen und wahrscheinlich sogar schon mal geschrieben, daher braucht dieser Punkt keine lange Erklärung. Was allerdings wichtig ist, sind die Konsequenzen. Weil die Methoden technologischer Natur sind und oftmals nur "Schritte" in einem größeren Ablauf, muss man zwangsweise den ganzen technologischen Ablauf lesen, um zu verstehen, was dahinter steckt. Das heißt alle Methoden nach einander lesen, während man versucht, alle Daten im Kopf zu behalten und gleichzeitig den Rechner gedanklich zu simulieren.
Wenn man so viele Methoden lesen muss, kommt der Gedanke schnell, dass es sehr viel besser wäre, wenn diese Methoden kurz und übersichtlich wären. Das bringt uns zu Level 1.
Level 1: Aussehen
Die meisten Software-Projekte achten heute schon darauf, dass der eigentliche Code einigermaßen gut aussieht. Das heißt, kurze Methoden, nicht zu tief geschachtelt, sinnvoll benannt, usw. Das entspricht ungefähr den "Clean Code"-Regeln. Man könnte vielleicht argumentieren, dass das Konzept von "Clean Code" mehr umfasst, in der Praxis jedoch wird selten mehr darunter verstanden.
Nehmen wir das folgende Beispiel aus "Weld", der Referenzimplementation für CDI:
@Override
public boolean isOverridable() {
return !isStatic() && !isFinal() && !isPrivate();
}
private boolean isPrivate() {
return Modifier.isPrivate(this.introspectedMethod.getModifiers());
}
Der Entwickler hat sich hier die Mühe gemacht, die isPrivate()-Methode explizit zu schreiben anstatt die Codezeile direkt in die isOverridable()-Methode einzufügen. Damit ist isOverridable() lesbarer geworden, und man muss nicht einmal die isPrivate()-Methode lesen, um zu wissen, was los ist. Sehr gut gemacht.
Nehmen wir noch ein Beispiel aus dem "spring-petclinic"-Projekt:
public class Vets {
private List<Vet> vets;
public List<Vet> getVetList() {
if (vets == null) {
vets = new ArrayList<>();
}
return vets;
}
}
Nach den obigen Kriterien ist diese Klasse lesbar geschrieben. Die Klasse hat nur eine kurze, fokussierte und überschaubare Methode. Sie ist konsistent formatiert, sie kommuniziert Absicht, sie macht alles, was man von "Clean Code" normalerweise erwarten würde. Ein Code Review würde wahrscheinlich keine Mängel finden.
Die Szene ist also gut gelichtet, im Fokus und sieht generell gut aus, aber der Dialog besteht nur aus kontextlosen Sätzen, die man für Später im Kopf behalten muss.
Das Problem ist nicht das Aussehen, sondern der Mangel an Information. So wie diese Klasse geschrieben ist, können die Leser nichts anderes machen, als diese Klasse mental zu speichern und immer wieder im Kopf abrufen, wenn sie benutzt wird. In sich hat sie sehr wenig bis gar keinen Informationsgehalt. Sogar einfache technische Fragen können wir hier nicht beantworten:
- Wieso nutzt es ein ArrayList und würde ein LinkedList performanter sein?
- Wieso nutzt es eine Liste, würde eine Menge oder eine generische Collection auch funktionieren?
- Wieso ist es änderbar? Würde eine unveränderbare Implementation auch gehen?
- Könnten wir etwas anderes als Vet-Objekte zurückgeben? Wird das ganze Objekt immer gebraucht?
Das heißt auch, dass wir diese Klasse nur ändern könnten, nachdem wir die ganze Applikation nach Nutzungen durchgesucht haben und die Stellen alle einigermaßen verstanden haben. Das wirkt regelrecht vernichtend auf die Wartbarkeit und auch auf die Verständlichkeit.
Der Code sieht also jetzt besser aus, leider haben wir nicht verhindern können, dass die Leser anderswo suchen müssen und gedanklich an vielen Orten auf einmal sein müssen. Der Grund dafür ist, dass Wissen im Code "verteilt" worden ist.
Level 2: Lokalisiert
Der Dialog und das Geschehen sind im Kontext, man kann die Szene nicht nur verstehen, sondern sie ermöglicht auch Schlussfolgerungen im Bezug auf die Charaktere, deren emotionalen Zustand oder persönliche Ziele.
Wenn man diese Schlussfolgerungen im Code ermöglicht, und dadurch Muster und abstrakte Konzepte kommuniziert, müssen die Leser sich nicht mehr an Code oder technische Details erinnern. Stattdessen lernen sie etwas relevantes über die ganze Applikation, was auch an anderen Stellen wertvollen Kontext geben kann.
Um das zu ermöglichen, brauchen die Leser nichts weiter als den ganzen Kontext für ein Stück Code zu sehen. Dieser Kontext sollte vollständig genug sein, damit die Leser für bestimmte Fragen nicht ständig an anderer Stelle suchen müssen.
Wenn man betrachtet, wo überall die Vets-Klasse oben verwendet wird, stößt man eigentlich nur auf eine Stelle: Sie wird verwendet, um eine JSON-Antwort über HTTP zu liefern. Geben wir also diesen fehlenden Kontext dazu:
public final class Vets {
private final List<Vet> vets;
public Vets(List<Vet> vets) {
this.vets = vets;
}
public Json toApiFormat() {
return new JsonArray(
vets.stream()
.map(Vet::toApiFormat)
.collect(Collectors.toList()));
}
}
Diese Klasse ist nicht nur lesbar sondern verständlich. Sie ist sogar nicht nur in sich selbst verständlich, vielmehr kommuniziert sie zusätzliche Hinweise und Anhaltspunkte. Beispielsweise, dass es in dieser Applikation wahrscheinlich um JSON APIs geht. Es generiert die Erwartung, dass andere Objekte dann auch mitwirken, diese API zu gestalten, und, dass Veterinäre wahrscheinlich für nichts anderes in dieser Applikation verwendet werden, da es nicht anders hier steht.
Das ist eine enorme Menge an Information für ungefähr die gleiche Menge an Code. Das Tolle daran ist, dass es teilweise unterschwellig wirken kann, also auch wenn die Leser nicht alles bewusst wahrnehmen, kann es späteres Wissen schon vorbereiten. Es gibt außerdem auch direkte praktische Vorteile. Wenn etwas geändert werden muss, sind die Konsequenzen hier gleich sichtbar. Das ist eine unverzichtbare Eigenschaft von Wartbarkeit.
Es gibt auch weniger eindeutiger Beispiele, wie das hier von "Weld":
public class ResolvableType {
...
public boolean hasGenerics() {
...kurze Implementation...
}
public boolean isEntirelyUnresolvable() {
...kurze Implementation...
}
...
}
Hier gibt es auf den ersten Blick auch keine Probleme. Die Klasse hat kurze, gut benannte Methoden, sie ist lesbar, usw. Wenn man aber "verstehen" will was diese Methoden genau aussagen wollen oder wofür diese Methoden gebraucht werden, muss man wieder Nachforschungen anstellen. Als Ergebnis findet man diese Methode an einer ganz anderen Stelle:
public static Class<?>[] resolveTypeArguments(
Class<?> clazz, Class<?> genericIfc) {
ResolvableType type = ResolvableType.forClass(clazz)
.as(genericIfc);
if (!type.hasGenerics() || type.isEntirelyUnresolvable()) {
return null;
}
return type.resolveGenerics(Object.class);
}
Diese Methode ist ein Kontext für die zwei obige Methoden. Tatsächlich wird isEntirelyUnresolvable() nur hier verwendet. Beim genauen Lesen kann man auch entdecken, dass diese Methode eigentlich alles in ResolvableType machen möchte, da Aufrufe immer mit type anfangen. Was wäre, wenn diese Methode einfach in ResolvableType wandern würde?
public class ResolvableType {
...
public boolean hasGenerics() {
...kurze Implementation...
}
public Class<?>[] resolveTypeArguments() {
if (!hasGenerics() || isEntirelyUnresolvable()) {
return null;
}
return resolveGenerics(Object.class);
}
private boolean isEntirelyUnresolvable() {
...kurze Implementation...
}
...
}
Die Methode ist jetzt nicht nur lesbarer, die ganze ResolvableType-Klasse ist jetzt verständlicher, da sie jetzt grundlegenden Kontext für die Methoden beinhaltet. Was noch besser ist, die isEntirelyUnresolvable()-Methode kann jetzt privat sein, da sie nirgendwo sonst verwendet wird.
Also die gleiche Menge an Code, die gleiche Menge an public-Methoden kommuniziert sehr viel mehr Information. Was wir in beiden Fällen gemacht haben, ist einfach den Kontext von außen in die Klasse zu holen. Anstatt die Klassen für alle Arten von Gebrauch zu gestalten, was den Informationsgehalt für die Leser minimieren würde, sollten Klassen ganz eng auf die Bedürfnisse der Applikation geschnitten sein. Damit haben wir die Möglichkeit, sehr viel mehr sinnvolle Information zu kommunizieren.
Sind wir hier aber fertig? Was ist mit resolveTypeArguments()? Wozu wird das gebraucht und sollten wir den Kontext für diese Methode auch hierher verlagern? Dieser Kompromiss gehört auf die nächste Stufe.
Level 3: Anforderungsrelevant
Unser Spielfilm ist jetzt fast unterhaltsam. Die Szenen sind nicht nur in sich selbst gut gestaltet und verständlich, sie haben etwas mit der Handlung zu tun. Die Handlungen, Ziele, emotionale Zustände von unseren Charakteren werden benutzt um die Geschichte besser zu erzählen.
Klassen, die nichts über die aktuelle Applikation und deren Anforderungen erzählen, versäumen eine gute Gelegenheit mit den Lesern zu kommunizieren. Das gilt insbesondere für Klassen technischer Natur, die nur wegen Implementationsdetails wie Frameworks, Bibliotheken oder "Design Patterns" existieren.
"Keine Angst, wir machen Domain-Driven Design, unser Klassen sind domainrelevant!", sagen manche Projekte an diese Stelle. Wirklich? Alle Klassen? Was ist mit DTO-, Service-, View-, Model-, Controller-, Repository-, usw. Klassen? Würde man diese Dinge als User oder gar Domain-Experte verstehen? Und alle Methoden auch?
Für die Leser sind Gründe, wieso eine Klasse nicht lesbar und verständlich ist, egal. Manchmal sind die Leser sich gar nicht bewusst, wie viel mentale Kapazität der Code wegnimmt. Leider sind viele heutige Architekturmuster, -Frameworks und andere Werkzeuge technologielastig und unterstützen, dass die meisten Objekte anforderungsrelevant sein sollten nicht, auch wenn kleine Änderungen wie Klassen umbenennen oder Methoden verschieben manchmal reichen würden.
Nehmen wir wieder ein Beispiel aus dem spring-petclinic-Projekt:
public class PetTypeFormatter implements Formatter<PetType> {
...
}
Diese Klasse ist eindeutig technischer Natur. Spring transformiert andere Objekte mit diese Formatter-Klasse zu String und zurück. Es muss einfach existieren, wenn man Spring verwendet. Es ist aber der Kontext für eine andere Klasse:
public class PetType extends NamedEntity {
// Leer!
}
Diese Klasse ist tatsächlich leer, weil der einzige Grund, wieso sie existiert, in der PetTypeFormatter-Klasse zu finden ist. Das ist eine Level-1-Klasse. Sie ist extrem "Clean", weil sie leer ist, dafür ist sie extrem nutzlos für die Leser weil sie nichts aussagt...
Die zwei Probleme kann man gleichzeitig lösen, in dem man PetTypeFormatter mit PetType integriert:
public class PetType extends NamedEntity {
public Json toApiFormat() {
return new JsonValue(getName());
}
}
Diese Lösung ist viel verständlicher, explizit, weniger Code, sogar weniger Klassen. Es gibt ähnlich einfache Änderungen wie das Umbenennen von Klassen, z. B. diese:
public class VetController {
public String showVetList(...);
public Vets showResourcesVetList(...);
}
Hier sind Sachen einfach falsch benannt. Ein Controller ist ein technischer Begriff von Spring. Die Anforderungen – wenn es welche geben würde – für diese Applikation würden wahrscheinlich nicht Controller fordern, sondern eine HTTP-Schnittstelle, über die man diese Applikation ansprechen kann. Auf Englisch würde man vielleicht "Endpoint" sagen. Obwohl das auch ein technischer Terminus ist, es ist auch etwas, das die User fordern, also gehöre es zum gemeinsamen Verständnis der Applikation.
Wenn man also auf Level 3 ankommt, findet man "relevante" Information in allen Klassen die man öffnet. Wenn man aber neu in einem Projekt ist, wo soll man überhaupt anfangen zu lesen? Muss man alles lesen um eine Applikation verstehen zu können, auch wenn man sich nur einen Überblick verschaffen will? Wie kann man ein Feature finden? Um das alles für die Leser einfach zu machen, muss man auf Level 4 aufsteigen.
Level 4: Organisiert
Die Szenen sind so organisiert, dass die Zuschauer Information nach und nach, abhängig vom Geschehen erhalten. Vielleicht stellen sie zuerst eine Verbindung zu unseren Charakteren her, dann führt sie unser Held oder die Heldin in einen Konflikt, dann erzählen Sie, wie dieser überwunden werden kann. Dabei führt jede Szene organisch zur nächsten.
Der Code sollte auch Information "progressiv" darstellen. Die allerwichtigsten Konzepte und Ideen sollten "am Anfang" stehen, das heißt im obersten "Package" der Software. "Unter-Packages" sollten stattdessen mehr und mehr Details preisgeben aber keine höheren Konzepte mehr einführen. Das erlaubt den Lesern, sich gleich einen Überblick zu verschaffen und auf jeder Stufe der Package-Hierarchie sich zu entscheiden, ob und welche Details noch wichtig wären.
Visuell sieht dieses Konzept so aus (s. Abb. 1).
Das ist eine Darstellung der Abhängigkeiten zwischen Paketen von "Spring Core". Das Wichtige daran ist, dass die Klassen im root-Package keine Abhängigkeiten auf Unterpakete haben. Das heißt, es ist in sich selbst verständlich. Das ist etwas, was auf alle Ebenen gelten muss.
Ein anderes Beispiel aus "Gerec" (ein REST-Client-Bibliothek) ist in Abb. 2 zu sehen und ein negatives Beispiel aus "Weld" in Abb. 3.
Man kann gleich erkennen, dass das root-Package Abhängigkeiten auf Unterpakete hat. Das heißt, es ist nicht der Anfang der Story. Es ist aber noch schlimmer: es gibt überhaupt keinen Startpunkt. Alle Pakete sind abhängig von praktisch jedem andere. Man muss also "alles" lesen, um zu verstehen, was hier los ist.
Unser Geschichte muss also irgendwo anfangen, aber nach diesem Anfang müssen wir auch ans Ziel kommen. Die Gabelungen auf diesem Weg sind unsere Pakete. Pakete müssen deshalb zwangsweise entlang fachlicher Konzepte führen, da es die einzig objektive Terminologie darstellt, die alle im Projekt verstehen müssen.
Fazit
Lesbaren Code zu schreiben ist sehr viel mehr als nur kurze Methoden. Es umfasst das Design, einzelne Objekte, sowie Pakete und letztendlich die ganze Architektur der Applikation. "Alles" hat Auswirkungen auf die Lesbarkeit, was auch bedeutet, dass wir alles nutzen können, um unserem Leser zu helfen, sich in unserem Code zurecht zu finden.
Hier sind nochmal die fünf Stufen kurz erklärt:
- Level 0: Der Code funktioniert zwar, ist aber extrem schwer zu verfolgen oder zu ändern.
- Level 1: Klassen und Methoden sind kurz, gut benannt. Lesen ist einfach, aber Wissen zu sammeln ist immer noch schwierig.
- Level 2: Wissen ist lokalisiert. Methoden arbeiten mit lokalen Daten und Kontext ist vorhanden.
- Level 3: Wissen ist nicht nur da, es ist fachlich und relevant für die Applikation.
- Level 4: Pakete geben fachliches Wissen progressiv von "oben" nach "unten" preis.