Java: Visitor-Pattern mit Generics
von Hubert Schmid vom 2012-01-15
Ich schreibe in letzter Zeit relativ viel über C++. Das liegt vor allem daran, dass sich in dieser Sprache gerade viel bewegt. Und dabei geht es nicht nur um Features, die in anderen, weit verbreiteten Programmiersprachen bereits existieren oder angedacht sind. Faszinierend finde ich vor allem die Änderungen, bei denen C++ komplett neue und andere Ansätze verfolgt.Andererseits möchte ich nicht den Eindruck erwecken, dass ich alle anderen Programmiersprachen langweilig finde. Und daher schreibe ich heute einen Artikel über Java, ohne dass es dafür einen besonderen Anlass gibt. Dafür habe ich mir das Visitor-Pattern vorgenommen, wie ich es typischerweise in Java verwende. Aus meiner Sicht handelt es sich dabei um eines der wichtigsten Entwurfsmuster für Java. Und außerdem ist es ein gutes Beispiel für die unterschiedlichen Eigenschaften der Generics im Vergleich zu den Template-Mechanismen anderer Programmiersprachen.KlassenhierarchieDas Visitor-Pattern wird in der Regel zusammen mit einer Klassenhierarchie eingesetzt. Und dafür habe ich mir ein möglichst einfaches Beispiel überlegt: 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 die meisten Anwendungsfälle die konkrete Ausprägung unwichtig ist, und nur in wenigen Anwendungsfällen zwischen den konkreten Ausprägungen unterschieden werden muss. Da sich dieses Konzept für die Anwendungsentwicklung als äußerst nützlich erwiesen hat, wird es von den meisten Objekt-orientierten Programmiersprachen direkt unterstützt. In Java werden dafür - wie in vielen anderen Programmiersprachen auch - virtuelle Methoden verwendet.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 in das gemeinsame Interface die Methode contains ein, die in den konkreten Klassen unterschiedlich implementiert ist. Der folgende Code-Ausschnitt zeigt dabei nur den Teil, der dafür hinzukommt.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) {
/* BUG: This implementation is broken. See below for details and a
* correct version. */
for (Actor actor : containedActors) {
if (actor.contains(individual)) {
return true;
}
}
return false;
}
}Dabei ist die Implementierung der Methode für die Klasse Group nur scheinbar trivial. Da der enthaltene Fehler für mein eigentliches Thema aber unbedeutend ist, werde ich erst am Ende darauf eingehen.Visitor-PatternIch kann mir in einer Anwendung zahlreiche weitere Anwendungsfälle vorstellen, in denen sich die konkreten Ausprägungen unterscheiden. 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 wir zahlreiche zusätzliche Abhängigkeiten auf andere Module bekommen - 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 fraglichen Klassenhierarchie kann zusätzliche Funktionalität von außen hinzugefügt werden - vergleichbar zu virtuellen Methoden.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);
}
}Eine klassische Umsetzung des Visitor-Patterns könnte beispielsweise so aussehen. Die zusätzliche Funktionalität kann nun realisiert werden, indem das Interface ActorVisitor implementiert wird. Und dabei entsprechen die zu implementierenden Methoden den konkreten Ausprägungen des Actor. Ein einfaches Beispiel dafür ist die Ausgabe auf einem Stream. Und wie im folgenden Code-Fragment bieten sich dabei häufig anonyme innere Klassen für die Implementierung 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 GenericsDas gerade gezeigte Beispiel ist eher theoretischer Natur. Denn in der Praxis kommt es häufig vor, dass der Visitor etwas zurückgegeben soll - anstatt Nebeneffekte zu produzieren. Und wenn ich mir nochmals die oben genannten Anwendungsfälle anschaue, so könnte ich mir beispielsweise vorstellen, dass der erste Visitor ein Widget und der zweite Visitor ein DAO zurückgibt.Bemerkenswerterweise lässt sich diese zusätzliche Eigenschaft mit den Generics aus Java relativ einfach umsetzen. Und obwohl mit diese Variante in fremdem Code bisher nicht aufgefallen ist, so handelt es sich aus meiner Sicht dennoch um die natürliche Erweiterung dieses Entwurfsmusters auf Generics. Die wesentliche Änderung dabei 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 wie folgt umsetzen. Und wichtig ist mir dabei, dass sowohl der Visitor keine (unnötigen) Nebeneffekte verursacht, als auch dass der Code statisch typisiert ist und keine Casts benötigt.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());
}Entsprechende Visitors kann man auch für die grafische Darstellung und die Speicherung in deiner Datenbank implementieren. Darauf verzichte ich an dieser Stelle aber ganz bewusst, weil diese Implementierungen aus meiner Sicht zum eigentlichen Thema keinen Mehrwert mehr liefern.GenericsIch selbst habe relativ viel Erfahrung mit Templates in C++. Und möglicherweise liegt es daran, dass ich diese Art der Verwendung der Generics für nicht selbstverständlich halte. Denn obwohl die Templates in C++ im Allgemeinen sehr viel mächtiger als Generics in Java sind, lässt sich dieses Konstrukt in C++ nicht so einfach umsetzen.Interessanterweise ist der wichtigste Unterschied hierfür gerade die sogenannte Type-Erasure, die ich häufig als Kritikpunkt an den Java-Generics wahrnehme. Im Gegensatz zu anderen Umsetzungen existieren die Typparameter in Java hauptsächlich zur statischen Typprüfung und werden während der Codegenerierung größtenteils durch explizite Typkonvertierung ersetzt. Aber genau dieser Mechanismus ermöglicht es andererseits, dass virtuelle Methoden einfach mit Generics kombiniert werden können, weil Letzteres zur Laufzeit praktisch nicht existiert.KorrekturIch habe relativ oben darauf hingewiesen, dass eines der Beispiele einen signifikanten Fehler enthält. Und ich gehe davon aus, dass die meisten Leser ihn bemerkt haben. Denn das Problem ist offensichtlich, dass ich an keiner Stelle ausgeschlossen habe, dass sich Gruppen direkt oder indirekt gegenseitig enthalten. Dadurch fehlt ein korrektes Abbruchkriterium für die Rekursion. Und das bedeutet wiederum, dass der Code in solch einem Fall zu einer Endlosrekursion führt, die wahrscheinlich in einem Stackoverflow endet.Im ursprünglichen Code lässt sich dieses Problem nicht trivial lösen. 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ürde man dadurch auch interne Implementierungsdetails an die Schnittstelle transportieren.Bemerkenswert finde ich, dass die Lösung mit einem Visitor in diesem Fall einfacher und sauberer. Dabei kommt eine Eigenschaft dieses Entwurfsmusters zu tragen, das virtuelle Methoden nicht haben: Über das Visitor-Objekt kann Zustandsinformation parallel zu den Parametern und Rückgabewerten transportiert werden. Und eine korrekte Implementierung der contains-Methode sieht damit beispielsweise wie folgt aus.@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;
}
});
}ExceptionsEs gibt einen wichtigen Punkt, den ich bisher verschwiegen habe. Und der betrifft die Ausnahmebehandlung, die leider in Java in meiner Wahrnehmung 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. Wie gehen wir aber dann mit der Situation um, wenn innerhalb der Visitor-Methoden Exceptions auftreten können? Die gute Nachricht ist, dass für Unchecked-Exceptions wie so häufig alles in Ordnung ist, denn diese werden neutral an den Aufrufer der accept-Funktion weitergereicht. Nur die Checked-Exceptions machen mal wieder Probleme, weil die Signatur der Visitor-Methoden diese nicht berücksichtigten kann. Aber schlußendlich ist das nur ein weiterer Grund dafür, warum man Checked-Exceptions in Java grundsätzlich vermeiden sollte, und warum die meisten neueren Bibliotheken und Frameworks genau diese Empfehlung aussprechen.FazitDas Visitor-Pattern gehört meiner Meinung nach zu den wichtigsten Entwurfsmustern, zumindest sofern die verwendete Programmiersprache keine bessere Lösung für diesen externen Dispatch bietet. Und 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. Und natürlich fände ich es schön, wenn der syntaktische Overhead nicht so hoch wäre. Aber im Vergleich mit anderen Programmiersprachen kommt Java - bis auf die leidige Ausnahmebehandlung - noch sehr gut weg.