Java: Visitor-Pattern mit Generics
Das Visitor-Pattern gehört aus meiner Sicht immer noch zu den wichtigsten Entwurfsmustern in Java. Dabei musste es sich wie viele Dinge an die Veränderungen der Programmiersprache anpassen. Insbesondere haben sich die Generics stark ausgewirkt.
Leider kenne ich keine gute Dokumentation der Entwurfsmuster, die diese Entwicklung berücksichtigt. Daher beschreibe ich in diesem Artikel, wie zumindest ich das Visitor-Pattern in Java aktuell einsetze. Dabei gehe ich auch kurz auf die besonderen Eigenschaften der Java-Generics und die Unterschiede zu anderen Programmiersprachen ein.
Klassenhierarchie
Das Visitor-Pattern wird in der Regel zusammen mit einer Klassenhierarchie eingesetzt. Dafür habe ich ein einfaches Beispiel konstruiert: Das Rechtekonzept einer Anwendung sieht vor, dass Rechte sowohl an Einzelpersonen als auch an Gruppen vergeben werden können. Dabei bestehen Gruppen aus Einzelpersonen oder rekursiv aus weiteren Gruppen. Aus Sicht der Rechtevergabe bietet es sich daher an, von Einzelpersonen und Gruppen zu abstrahieren. Der folgende Code-Ausschnitt enthält die minimale Kernfunktionalität.
interface Actor {
String getDisplayName();
}
final class Individual implements Actor {
private String displayName;
@Override
public String getDisplayName() {
return displayName;
}
}
final class Group implements Actor {
private String displayName;
private Actor[] containedActors;
@Override
public String getDisplayName() {
return displayName;
}
}
Eine solche Abstraktion bietet sich insbesondere dann an, wenn für viele Anwendungsfälle die konkreten Ausprägungen unwichtig sind, und nur in wenigen Fällen zwischen ihnen unterschieden werden muss. Da sich dieses Konzept für die Anwendungsentwicklung als sehr nützlich erwiesen hat, wird es von den meisten Objekt-orientierten Programmiersprachen durch sogenannte virtuelle Methoden direkt unterstützt
In meinem Beispiel gibt es dafür einen naheliegenden Anwendungsfall. Und zwar soll geprüft werden, ob eine Einzelperson einem Actor
angehört. Dafür füge ich der Schnittstelle die Methode contains
hinzu, die in den konkreten Klassen unterschiedlich implementiert ist. Der folgende Ausschnitt zeigt den dafür notwendigen zusätzlichen Code.
public interface Actor {
// ...
boolean contains(Individual individual);
}
public final class Individual implements Actor {
// ...
@Override
public boolean contains(Individual individual) {
return equals(individual);
}
}
public final class Group implements Actor {
// ...
@Override
public boolean contains(final Individual individual) {
/* NOTE: This implementation is broken. See below
* for details and a fixed version. */
for (Actor actor : containedActors) {
if (actor.contains(individual)) {
return true;
}
}
return false;
}
}
Die Implementierung ist relativ einfach und wenig überraschend. Ich möchte an dieser Stelle allerdings darauf hinweisen, dass die Methode der Klasse Group
einen potentiellen Fehler enthält, auf den ich aber erst weiter unten genauer eingehen werde.
Visitor-Pattern
Ich kann mir in einer Anwendung zahlreiche weitere Anwendungsfälle in Zusammenhang mit meinem Beispiel vorstellen, in denen die konkreten Ausprägungen unterschieden werden müssen. Eine Anforderung könnte beispielsweise sein, dass die Berechtigungen in einer grafischen Oberfläche angezeigt werden, und dabei Einzelpersonen und Gruppen durch unterschiedliche Symbole gekennzeichnet werden. Oder die Berechtigungen müssen in eine Datenbank gespeichert werden, wofür ebenfalls zwischen den konkreten Ausprägungen unterschieden werden muss.
Man könnte nun die gemeinsame Schnittstelle Actor
für alle diese Unterschiede um entsprechende Methoden erweitern. Und technisch würde das auch funktionieren. Allerdings würde es dazu führen, dass zahlreiche zusätzliche Abhängigkeiten auf andere Module entstehen – wie die Bibliothek für grafische Oberflächen und die Persistenzschicht.
In vielen Fällen möchte man so eine Verflechtung von unterschiedlichen Teilen der Anwendung vermeiden, damit der Code langfristig wartbar und erweiterbar bleibt. Und am einfachsten wäre es, wenn es eine externe
Entsprechung zu den virtuellen Methoden gäbe. Java bietet diese Möglichkeit – wie viele andere, statisch typisierte Programmiersprachen – nicht direkt an. Und genau an dieser Stelle hilft das Visitor-Pattern. Durch eine einmalige Erweiterung der Klassenhierarchie kann zusätzliche Funktionalität von außen hinzugefügt werden – vergleichbar zu virtuellen Methoden. Die klassische Umsetzung dafür sieht so aus:
public interface ActorVisitor {
void visitIndividual(Individual individual);
void visitGroup(Group group);
}
public interface Actor {
// ...
void accept(ActorVisitor visitor);
}
public final class Individual implements Actor {
// ...
@Override
public void accept(ActorVisitor visitor) {
visitor.visitIndividual(this);
}
}
public final class Group implements Actor {
// ...
@Override
public void accept(ActorVisitor visitor) {
visitor.visitGroup(this);
}
}
Die zusätzliche Funktionalität kann nun realisiert werden, indem das Interface ActorVisitor
implementiert wird. Dabei entsprechen die zu implementierenden Methoden den konkreten Ausprägungen des Actor
. Ein einfaches Beispiel ist die Ausgabe auf einem Stream. Wie im folgenden Code-Fragment bieten sich für die Implementierung anonyme innere Klassen an.
void print(Actor actor, final PrintStream out) {
actor.accept(new ActorVisitor() {
@Override
public void visitIndividual(Individual individual) {
out.println("[INDIVIDUAL] " + individual.getDisplayName());
}
@Override
public void visitGroup(Group group) {
out.println("[GROUP] " + group.getDisplayName());
}
});
}
Visitor-Pattern mit Generics
Das gerade gezeigte Beispiel ist unrealistisch einfach. In den meisten Fällen soll der Visitor nicht nur Nebeneffekte produzieren sondern auch etwas zurückgeben – wie beispielsweise ein Widget
für die grafische Oberfläche oder ein DAO
für die Persistenz.
Bemerkenswerterweise lässt sich dies mit den Generics aus Java relativ einfach umsetzen. Die wesentliche Änderung gegenüber dem klassischen Pattern ist, dass der Rückgabetyp void
(oder irgendein anderer konkreter Typ) durch einen Typparameter ersetzt wird. In meinem Beispiel sieht das wie folgt aus.
public interface ActorVisitor<ReturnType> {
ReturnType visitIndividual(Individual individual);
ReturnType visitGroup(Group group);
}
public interface Actor {
// ...
<ReturnType> ReturnType accept(ActorVisitor<ReturnType> visitor);
}
public final class Individual implements Actor {
// ...
@Override
public <ReturnType> ReturnType accept(ActorVisitor<ReturnType> visitor) {
return visitor.visitIndividual(this);
}
}
public final class Group implements Actor {
// ...
@Override
public <ReturnType> ReturnType accept(ActorVisitor<ReturnType> visitor) {
return visitor.visitGroup(this);
}
}
Damit lässt sich die Ausgabefunktionalität aus dem letzten Beispiel einfach umschreiben. Die beiden wichtigen Eigenschaften sind, dass erstens der Visitor keine (unnötigen) Nebeneffekte produziert, und dass zweitens der Code statisch typisiert ist und ohne Typ-Casts auskommt.
public static void print(Actor actor, PrintStream out) {
String prefix = actor.accept(new ActorVisitor<String>() {
@Override
public String visitGroup(Group group) {
return "[GROUP] ";
}
@Override
public String visitIndividual(Individual individual) {
return "[INDIVIDUAL] ";
}
});
out.println(prefix + actor.getDisplayName());
}
Entsprechend lassen sich der Visitor für den Anwendungsfall der grafischen Oberfläche mit new ActorVisitor<Widget>
und für die Persistenz mit new ActorVisitor<DAO>
implementieren.
Generics
Diese Art und Weise der Generics-Verwendung ist etwas gewöhnungsbedürftig. Zumindest ist sie es aus meiner Sicht. Das kann allerdings auch daran liegen, dass ich wesentlich länger mit Templates in C++ als mit Generics in Java gearbeitet habe. Sicher ist auf jeden Fall, dass dieses Konstrukt in C++ nicht funktioniert, obwohl die C++‑Templates im Allgemeinen weit überlegen sind.
Interessanterweise ist der entscheidende Unterschied hierfür gerade die sogenannte Type-Erasure, die ich häufig als Kritikpunkt an den Java-Generics wahrnehme. Im Gegensatz zu anderen Umsetzungen werden die Typparameter in Java praktisch nur zur statischen Typprüfung verwendet, und während der Codegenerierung werden sie größtenteils durch explizite Typkonvertierung ersetzt. Aber gerade dieser Mechanismus ermöglicht es andererseits, dass virtuelle Methoden einfach mit Generics kombiniert werden können. Das heißt zur Laufzeit existiert nur eine einzige Instanz der Methode, die für alle Typparameter gleichermaßen verwendet wird, und diese Instanz lässt sich ganz normal überschreiben.
Korrektur
Ich habe in einem der vorherigen Beispiele auf einen Fehler hingewiesen, den ich jetzt diskutieren und korrigieren möchte. Das Problem ist eigentlich offensichtlich: Ich habe an keiner Stelle ausgeschlossen, dass sich Gruppen direkt oder indirekt selbst enthalten. Tritt dieser Fall auf, so läuft die contains
-Methode in eine Endlosrekursion, die wahrscheinlich erst durch einen Stackoverflow endet.
Ich habe das Thema aufgeschoben, weil es sich im ursprünglichen Code nicht trivial lösen lässt. Eine Möglichkeit wäre sicherlich, die contains
-Methode um einen zusätzlichen Parameter zu erweitern, der die Menge von Gruppen enthält, die bereits betrachtet wurden. Allerdings würden dadurch interne Implementierungsdetails an der Schnittstelle sichtbar werden, was man im Allgemeinen verhindern sollte.
Bemerkenswert finde ich, dass die korrekte Implementierung einfacher und sauberer ist, wenn man mit einem Visitor arbeitet. Dabei kommt eine besondere Eigenschaft dieses Entwurfsmusters zu tragen: Im Gegensatz zu virtuellen Methode kann das Visitor-Objekt Zustandsinformation parallel zu Parametern und Rückgabewerten über Methodenaufrufe hinweg transportieren. In diesem Fall genügt es, die Menge von besuchten Gruppen im Visitor selbst zu speichern. In der folgenden, korrekten Implementierung der contains
-Methode verwende ich dafür die Member-Variable seen
.
@Override
public boolean contains(final Individual individual) {
return accept(new ActorVisitor<Boolean>() {
private final Set<Group> seen = new HashSet<Group>();
@Override
public Boolean visitIndividual(Individual other) {
return other.contains(individual);
}
@Override
public Boolean visitGroup(Group group) {
if (seen.add(group)) {
for (Actor actor : group.containedActors) {
if (actor.accept(this)) {
return true;
}
}
}
return false;
}
});
}
Exceptions
Es gibt einen wichtigen Punkt, den ich bisher verschwiegen habe. Er betrifft die Ausnahmebehandlung, die leider in Java auch nach 17 Jahren noch nicht die Aufmerksamkeit erfährt, die ich mir wünschen würde.
Bei den Rückgabetypen funktionieren die Java-Generics sehr gut. Für Exceptions sind sie allerdings nicht sinnvoll anwendbar. Die Frage ist, wie man mit der Situation umgeht, in der innerhalb der Visitor-Methoden Exceptions auftreten können? Die gute Nachricht ist, dass für Unchecked-Exceptions alles in Ordnung ist, denn diese werden neutral an den Aufrufer der accept
-Funktion weitergereicht. Die schlechte Nachricht ist, dass die Checked-Exceptions mal wieder Probleme machen. Denn die Signatur der Visitor-Methoden kann diese nicht individuell berücksichtigen.
Schlussendlich ist das nur ein weiterer Beleg dafür, dass das Ansatz der Checked-Exceptions in Java zu kurz gedacht ist. Ich kann diesbezüglich nur empfehlen, der allgemeinen Bewegung zu folgen und auf Checked-Exceptions in Java so weit wie möglich zu verzichten.
Fazit
Das Visitor-Pattern gehört meiner Meinung nach zu den wichtigsten Entwurfsmustern, zumindest sofern die verwendete Programmiersprache keine bessere Lösung für den externen Dispatch bietet. In Java fügt sich dieses Muster meiner Meinung nach besonders gut ein, was einerseits an den anonymen, inneren Klassen liegt und andererseits an dem Zusammenspiel mit den Generics. Der syntaktische Overhead ist leider relativ hoch. Aber im Vergleich zu anderen Programmiersprachen kommt Java – bis auf die leidige Ausnahmebehandlung – noch sehr gut weg.