Java: Grundregeln der Änderbarkeit von Methoden
Ich bin diese Woche auf Stackoverflow über eine Java-Klasse gestoßen, die im folgenden Listing vereinfacht wiedergegeben ist. Dabei ging es um die einfache Frage, wie die Methode getPath
in C++ aussehen würde. Interessant fand ich die Klasse allerdings aus einem ganz anderen Grund: Aus meiner Sicht ist die Implementierung der Methode getPath
von bemerkenswert schlechter Qualität. Das lässt sich in zwei Punkten zusammenfassen:
- Ineffizienz: Die Laufzeit der Implementierung ist asymptotisch quadratisch in der Tiefe der Baumstruktur. Erwarten würde ich dagegen eine lineare Laufzeit.
- Unklarheit: Die Implementierung ist schwer verständlich und erfordert genauere Betrachtung, obwohl sie nur aus wenigen Anweisungen besteht.
Effizienz und Klarheit erwarte ich von erfahrenen Softwareentwicklern. Doch darum geht es mir bei diesem Beispiel überhaupt nicht. Ich finde dieses Beispiel symptomatisch für Code, der scheinbar ordentlich ist, tatsächlich jedoch einige grundlegende Regeln verletzt, und dadurch weitere Änderungen unnötig aufwendig macht.
public class Node {
private final int id;
private final Node parent;
public Node(int id, Node parent) {
this.id = id;
this.parent = parent;
}
public Node(int id) {
this(id, null);
}
// ...
public ArrayList<Node> getPath() {
return getPath(new ArrayList<Node>());
}
public ArrayList<Node> getPath(ArrayList<Node> nodes) {
nodes.add(0, this);
if (parent != null) {
nodes = parent.getPath(nodes);
}
return nodes;
}
}
Diese Implementierung verletzt aus meiner Sicht die drei folgenden Grundregeln, auf die ich jeweils einzeln eingehen werde:
- Klare Benennung von Methoden
- Äußere Kapselung von Methoden
- Innere Kapselung von Methoden
Klare Benennung von Methoden
Die letzte Methode in obiger Klasse erwartet als Parameter eine ArrayList<Node>
und gibt auch wieder eine ArrayList<Node>
zurück. Unabhängig von der Implementierung der Methode ist damit bereits offensichtlich, dass der Name getPath
unpassend ist. Doch wie sollte man die Methode stattdessen nennen?
Dafür gehe ich nochmals einen Schritt zurück: Die klare Benennung von Methoden ist aus zwei Gründen wichtig. Sie verbessert erstens die Aussagekraft und zweitens den Schnitt. Denn eine schwierig zu benennende Methode ist ein starkes Indiz für einen schlechten Schnitt.
Genau so ist es in obigem Beispiel: Hätte der Entwickler versucht, einen aussagekräftigen Namen für die Methode zu finden, dann wäre ihm aufgefallen, dass die Methode schlecht geschnitten ist, und dass es einen anderen Schnitt gibt, der sich besser benennen lässt. Einen konkreten Vorschlag dafür mache ich am Ende.
Äußere Kapselung von Methoden
Das Konzept der Kapselung
wird leider häufig missverstanden. Gefühlt ist es mittlerweile zu privaten Member-Variablen mit öffentlichen Zugriffsmethoden entartet. Dabei wird insbesondere vernachlässigt, dass auch viele Methoden zur inneren Struktur gehören – und nicht zur äußeren Schnittstelle.
Umgekehrt bedeutet äußere Kapselung, dass von Außen nur zugänglich ist, was auch Bestandteil der Schnittstelle ist. Die zweite getPath
-Methode aus obigem Beispiel ist öffentlich, erweckt jedoch den Eindruck, dass sie nur aus Implementierungsgründen existiert. Besser wäre es sie private
zu deklarieren. Dadurch würde die Black-Box
-Sicht gestärkt und spätere Änderungen der Implementierung vereinfacht.
Innere Kapselung von Methoden
Neben äußeren Schnittstellen gibt es in objektorientierten Programmiersprachen auch innere Schnittstellen. Damit sind virtuelle Methoden gemeint, die erstens aus der Implementierung heraus aufgerufen werden, und die sich zweitens in Unterklassen überschreiben lassen. Mehrere Entwurfsmuster adressieren dieses Thema: Das Template-Method-Pattern
fokussiert sich beispielsweise auf konkrete Erweiterungspunkte, und das Non-Virtual-Interface-Pattern
trennt strikt zwischen innerer und äußerer Schnittstelle.
In Java ist der Default, dass jede Methode überschrieben werden kann. Das ist keinesfalls selbstverständlich. Sowohl in C++ als auch in C# müssen Methoden dafür explizit mit dem Schlüsselwort virtual
ausgezeichnet werden. Ich halte Letzteres für die bessere Entscheidung: Erweiterungspunkte sollten sorgfältig und bewusst gewählt werden. Denn die Änderbarkeit der Implementierung ist in der Regel wichtiger als die Erweiterbarkeit von Außen.
Für obiges Beispiel bedeutet das: Zumindest die zweite getPath
-Methode sollte mit final
deklariert werden. Damit wird sichergestellt, dass beim rekursiven Aufruf die richtige Implementierung ausgeführt wird. Den Bedarf für Erweiterbarkeit an dieser Stelle kann ich hingegen nicht erkennen.
Ergebnis
Unter Berücksichtigung dieser drei Grundregeln würde obiger Code vielleicht wie folgt aussehen. Damit ist er zwar weiterhin weder effizient noch besonders verständlich. Doch zumindest befindet man sich in einer besseren Ausgangssituation, um den Code tatsächlich zu refaktorisieren und mit geringem Aufwand weiterzuentwickeln.
public final List<Node> getPath() {
List<Node> result = new ArrayList<>();
prependNodeWithAncestorsTo(result);
return result;
}
private final void prependNodeWithAncestorsTo(List<Node> nodes) {
nodes.add(0, this);
if (parent != null) {
parent.prependNodeWithAncestorsTo(nodes);
}
}