Java: Grundregeln der Änderbarkeit von Methoden

von Hubert Schmid vom 2013-08-18

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:

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

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); } }