Operationen von Datenstrukturen sauber trennen
Das Visitor Pattern trennt Operationen von der Klassenhierarchie, auf der sie arbeiten, sodass neue Operationen ergänzt werden können, ohne bestehende Datenklassen zu ändern. Am Beispiel eines Abstract Syntax Tree zeigt dieser Artikel Double Dispatch, das Visitable-Interface und die Grenzen dieses Ansatzes in PHP 8.4.
Inhaltsverzeichnis
- 1. Welches Problem das Visitor Pattern löst
- 2. Double Dispatch: der technische Kern des Visitor Pattern
- 3. Grundstruktur: Visitable-Interface und Visitor-Interface
- 4. Praxisbeispiel: Auswertung eines Abstract Syntax Tree
- 5. Neue Operationen ergänzen, ohne bestehende Klassen zu ändern
- 6. Das Erweiterungsproblem: neue Knotentypen sind teuer
- 7. Alternative in PHP: match-Expression statt klassischem Visitor
- 8. Häufige Fehler beim Visitor Pattern
- 9. Visitor Pattern im Vergleich zu Alternativen
- 10. Zusammenfassung
- 11. FAQ
1. Welches Problem das Visitor Pattern löst
Das Visitor Pattern löst ein Problem, das bei festen, aber vielfältig genutzten Klassenhierarchien auftritt: Wie fügt man neue Operationen hinzu, die auf jeder Klasse der Hierarchie unterschiedlich arbeiten müssen, ohne bei jeder neuen Operation alle beteiligten Klassen zu verändern? Ein typisches Beispiel ist ein Abstract Syntax Tree, kurz AST, mit Knotentypen wie Zahl, Addition und Multiplikation. Auf diesem AST sollen mehrere unabhängige Operationen laufen: Auswertung zu einem konkreten Zahlenwert, Ausgabe als lesbarer String, Optimierung durch Vereinfachung von Ausdrücken.
Ohne das Visitor Pattern würde jede dieser Operationen als Methode direkt in die Knotenklassen wandern, sodass NumberNode, AdditionNode und MultiplicationNode jeweils eine evaluate()-, eine toString()- und eine optimize()-Methode bekämen. Jede neue Operation würde bedeuten, alle Knotenklassen anzufassen, ein klarer Verstoß gegen das Open-Closed-Prinzip, weil bestehender Code für neue Anforderungen geändert werden muss, statt nur erweitert zu werden.
Das Visitor Pattern kehrt dieses Verhältnis um: Jede Operation wird als eigenständige Visitor-Klasse implementiert, die Knotenklassen selbst bleiben unverändert und bekommen nur eine einzige zusätzliche Methode, accept(), die niemals wieder angefasst werden muss. Neue Operationen entstehen fortan als neue Visitor-Klassen, ohne dass ein einziger bestehender Knotentyp verändert wird.
2. Double Dispatch: der technische Kern des Visitor Pattern
Der technische Trick hinter dem Visitor Pattern heißt Double Dispatch. PHP, wie die meisten objektorientierten Sprachen, unterstützt nur Single Dispatch: Welche Methode bei einem Aufruf tatsächlich ausgeführt wird, hängt allein vom Laufzeittyp des Objekts ab, auf dem die Methode aufgerufen wird, nicht vom Typ eines übergebenen Arguments. Für das Visitor Pattern reicht das nicht, weil sowohl der Typ des Knotens als auch der Typ des Visitors gleichzeitig über das tatsächlich auszuführende Verhalten entscheiden sollen.
Double Dispatch simuliert dieses Verhalten mit zwei aufeinanderfolgenden Methodenaufrufen. Zuerst ruft der Aufrufer $node->accept($visitor) auf, dieser erste Aufruf dispatcht anhand des Knotentyps. Innerhalb von accept() ruft der Knoten dann $visitor->visitNumberNode($this) auf, wobei der konkrete Methodenname vom konkreten Knotentyp abhängt. Dieser zweite Aufruf dispatcht anhand des Visitor-Typs. Zusammen ergeben beide Aufrufe die Kombination aus Knotentyp und Visitor-Typ, die Single Dispatch allein nicht liefern kann.
<?php
declare(strict_types=1);
/**
* Visitable interface: every node type must accept a visitor.
*/
interface NodeInterface
{
public function accept(NodeVisitorInterface $visitor): mixed;
}
/**
* Visitor interface: one method per concrete node type (double dispatch).
*/
interface NodeVisitorInterface
{
public function visitNumberNode(NumberNode $node): mixed;
public function visitAdditionNode(AdditionNode $node): mixed;
}
Diese beiden Interfaces bilden das Grundgerüst des Visitor Pattern. NodeInterface stellt sicher, dass jeder Knoten besucht werden kann, NodeVisitorInterface definiert genau eine Methode pro Knotentyp. Erst im Zusammenspiel beider Interfaces entsteht Double Dispatch, keines der beiden allein reicht dafür aus.
3. Grundstruktur: Visitable-Interface und Visitor-Interface
Die konkreten Knotenklassen implementieren NodeInterface und damit die accept()-Methode. Diese Methode ist bei jedem konkreten Knotentyp fast identisch, sie ruft lediglich die zu diesem Knotentyp passende Methode auf dem Visitor auf und übergibt sich selbst als Argument. Wichtig ist, dass accept() niemals fachliche Logik enthält, ausschließlich das Weiterreichen an den passenden Visitor-Methodennamen.
<?php
declare(strict_types=1);
final class NumberNode implements NodeInterface
{
public function __construct(
public readonly float $value,
) {
}
public function accept(NodeVisitorInterface $visitor): mixed
{
return $visitor->visitNumberNode($this);
}
}
final class AdditionNode implements NodeInterface
{
public function __construct(
public readonly NodeInterface $left,
public readonly NodeInterface $right,
) {
}
public function accept(NodeVisitorInterface $visitor): mixed
{
return $visitor->visitAdditionNode($this);
}
}
Beide accept()-Implementierungen sind bewusst trivial gehalten, sie tun nichts weiter, als den Aufruf an die richtige Visitor-Methode weiterzureichen. Die gesamte fachliche Logik einer Operation, sei es Auswertung, Ausgabe oder Optimierung, lebt ausschließlich in den Visitor-Implementierungen, nicht in den Knotenklassen selbst.
4. Praxisbeispiel: Auswertung eines Abstract Syntax Tree
Ein konkreter Visitor implementiert NodeVisitorInterface und definiert für jeden Knotentyp, was bei diesem Knoten tatsächlich passieren soll. Ein EvaluatingVisitor berechnet den numerischen Wert des Ausdrucksbaums, indem er sich rekursiv durch die Struktur bewegt: Für eine AdditionNode ruft er accept() auf den beiden Kindknoten auf und addiert die Ergebnisse. Für eine NumberNode gibt er einfach den gespeicherten Wert zurück.
<?php
declare(strict_types=1);
/**
* Concrete visitor: evaluates the expression tree to a numeric result.
*/
final class EvaluatingVisitor implements NodeVisitorInterface
{
public function visitNumberNode(NumberNode $node): float
{
return $node->value;
}
public function visitAdditionNode(AdditionNode $node): float
{
$left = $node->left->accept($this);
$right = $node->right->accept($this);
return (float) $left + (float) $right;
}
}
/**
* Concrete visitor: renders the expression tree as a readable string.
*/
final class PrintingVisitor implements NodeVisitorInterface
{
public function visitNumberNode(NumberNode $node): string
{
return (string) $node->value;
}
public function visitAdditionNode(AdditionNode $node): string
{
return sprintf(
'(%s + %s)',
$node->left->accept($this),
$node->right->accept($this),
);
}
}
// (3 + (4 + 5))
$tree = new AdditionNode(
new NumberNode(3),
new AdditionNode(new NumberNode(4), new NumberNode(5)),
);
echo $tree->accept(new EvaluatingVisitor()); // 12
echo $tree->accept(new PrintingVisitor()); // (3 + (4 + 5))
Beide Visitors arbeiten auf exakt derselben Baumstruktur, ohne dass die Knotenklassen selbst irgendetwas über Auswertung oder Textausgabe wissen. Genau das ist der Kern des Visitor Pattern: Operationen und Datenstruktur bleiben vollständig entkoppelt, jede neue Operation ist ein neuer Visitor, keine Änderung an NumberNode oder AdditionNode.
5. Neue Operationen ergänzen, ohne bestehende Klassen zu ändern
Der eigentliche Mehrwert des Visitor Pattern zeigt sich, sobald eine dritte, vierte oder fünfte Operation hinzukommt. Ein OptimizingVisitor, der konstante Additionen zur Kompilierzeit zusammenfasst, oder ein DepthCountingVisitor, der die maximale Verschachtelungstiefe des Baums ermittelt, lassen sich als komplett neue Klassen ergänzen. Weder NumberNode noch AdditionNode noch irgendeine bestehende Visitor-Klasse muss dafür angefasst werden.
Diese Eigenschaft macht das Visitor Pattern besonders wertvoll in Bereichen mit häufig wechselnden oder wachsenden Anforderungen an Operationen, aber einer vergleichsweise stabilen Datenstruktur. Compiler und Interpreter sind das klassische Anwendungsgebiet, weil ein AST typischerweise über die Lebensdauer eines Projekts stabil bleibt, während die Zahl der Operationen, die auf ihm laufen sollen, mit der Zeit wächst: Typprüfung, Optimierung, Codegenerierung, Formatierung, Linting.
6. Das Erweiterungsproblem: neue Knotentypen sind teuer
Das Visitor Pattern hat einen spiegelbildlichen Nachteil zu seinem größten Vorteil. Während neue Operationen billig sind, sind neue Knotentypen teuer. Fügt man einen neuen Knotentyp hinzu, etwa MultiplicationNode, muss NodeVisitorInterface um eine neue Methode visitMultiplicationNode() erweitert werden, und jede einzelne bestehende Visitor-Implementierung muss diese neue Methode ergänzen, sonst schlägt die Implementierung des Interface fehl.
Diese Eigenschaft ist als das Expression-Problem bekannt: Man kann entweder leicht neue Operationen oder leicht neue Datentypen hinzufügen, aber nicht beides gleichzeitig ohne Kompromisse. Das Visitor Pattern entscheidet sich bewusst für leicht erweiterbare Operationen auf Kosten schwerer erweiterbarer Datentypen. Vor dem Einsatz des Visitor Pattern lohnt sich daher die Frage, welche der beiden Dimensionen, Operationen oder Knotentypen, in einem konkreten Projekt tatsächlich häufiger wächst.
7. Alternative in PHP: match-Expression statt klassischem Visitor
PHP bietet seit Version 8.0 mit der match-Expression eine schlankere Alternative zum klassischen Visitor Pattern, zumindest für Fälle mit überschaubarer Anzahl an Knotentypen. Statt Double Dispatch über accept() und ein separates Visitor-Interface zu implementieren, kann eine Operation direkt mit match auf den Klassennamen des Knotens reagieren. Das spart die Interface-Definitionen, verzichtet aber auf die statische Absicherung durch PHPStan, dass wirklich jeder Knotentyp behandelt wird, es sei denn, man ergänzt einen expliziten default-Zweig, der bei unbekannten Typen eine Exception wirft.
<?php
declare(strict_types=1);
/**
* Lightweight alternative to the classic Visitor: match on ::class instead
* of implementing accept() and a separate visitor interface.
*/
function evaluate(NodeInterface $node): float
{
return match ($node::class) {
NumberNode::class => $node->value,
AdditionNode::class => evaluate($node->left) + evaluate($node->right),
default => throw new LogicException('Unknown node type: ' . $node::class),
};
}
$tree = new AdditionNode(new NumberNode(3), new NumberNode(4));
echo evaluate($tree); // 7
Diese match-basierte Alternative ist für kleine, projektinterne ASTs oft pragmatischer als das vollständige Visitor Pattern mit separaten Interfaces. Sobald jedoch mehrere unabhängige Operationen von mehreren Entwicklern gepflegt werden oder die Anzahl der Knotentypen wächst, gewinnt das klassische Visitor Pattern durch die von PHPStan erzwungene Vollständigkeit der Visitor-Interfaces an Wert, weil ein vergessener Knotentyp dann zu einem Compile-Zeit-Fehler statt zu einer Laufzeit-Exception wird.
8. Häufige Fehler beim Visitor Pattern
Der häufigste Fehler ist, fachliche Logik versehentlich doch in accept() zu verlagern, statt sie ausschließlich im Visitor zu halten. Sobald accept() mehr tut als den Aufruf weiterzureichen, geht die saubere Trennung zwischen Datenstruktur und Operation verloren, die das gesamte Muster rechtfertigt. Ein zweiter Fehler ist, den Rückgabetyp von accept() und den Visitor-Methoden zu eng oder zu weit zu fassen. mixed als Rückgabetyp funktioniert für heterogene Operationen wie Auswertung und Textausgabe, verschenkt aber Typsicherheit, wenn eine konkrete Operation immer denselben, bekannten Typ zurückgibt.
Ein dritter Fehler betrifft rekursive Strukturen ohne Terminierungsbedingung. Enthält ein Baum versehentlich einen Zyklus, etwa weil ein Knoten fälschlich sich selbst referenziert, führt ein rekursiver Visitor zu einem Stack Overflow, ohne dass die Fehlermeldung den eigentlichen Zyklus im Datenmodell erkennen lässt. Für Bäume, die aus externen Quellen wie Parsern stammen, lohnt sich eine defensive Prüfung auf Zyklen, bevor ein Visitor darauf losgelassen wird.
9. Visitor Pattern im Vergleich zu Alternativen
Die Entscheidung zwischen dem klassischen Visitor Pattern und schlankeren Alternativen hängt von der Stabilität der Knotentypen und der Anzahl der Operationen ab. Die folgende Tabelle vergleicht die gängigen Ansätze.
| Ansatz | Neue Operation | Neuer Knotentyp | PHPStan-Absicherung |
|---|---|---|---|
| Methoden in Knotenklassen | Alle Klassen ändern | Neue Klasse genügt | Gut, aber verletzt Open-Closed |
| Klassisches Visitor Pattern | Neue Visitor-Klasse | Alle Visitors ändern | Vollständigkeit erzwungen |
| match auf ::class | Neue Funktion | Alle match-Blöcke prüfen | Nur mit explizitem default |
| instanceof-Kette | Neue Funktion | Alle Ketten prüfen | Schwach, keine Vollständigkeitsprüfung |
Für stabile Datenstrukturen mit wachsender Zahl an Operationen, insbesondere in Compilern, Interpretern und Codeanalyse-Tools, bleibt das klassische Visitor Pattern die robusteste Wahl, weil PHPStan bei fehlenden Interface-Methoden zuverlässig einen Fehler meldet. Für kleine, projektinterne ASTs mit wenigen, stabilen Operationen ist die match-basierte Alternative oft der pragmatischere Weg.
Mironsoft
PHP-Architektur, Objektdesign und wartbare Backend-Systeme
Compiler, Interpreter oder Codeanalyse-Tool in PHP geplant?
Wir entwerfen saubere AST-Strukturen mit dem Visitor Pattern, damit neue Operationen wie Optimierung, Validierung und Codegenerierung ergänzt werden können, ohne bestehende Knotenklassen zu ändern.
AST-Design
Knotenhierarchie und Visitable-Interfaces sauber aufbauen
Visitor-Implementierung
Auswertung, Optimierung und Codegenerierung als eigene Visitors
PHPStan-Absicherung
Vollständigkeit aller Visitor-Implementierungen garantieren
10. Zusammenfassung
Das Visitor Pattern trennt Operationen von der Klassenhierarchie, auf der sie arbeiten, mithilfe von Double Dispatch über accept() und ein Visitor-Interface mit einer Methode pro Knotentyp. Neue Operationen entstehen als neue Visitor-Klassen, ohne dass ein bestehender Knotentyp geändert werden muss, ein direkter Gewinn gegenüber dem Open-Closed-Prinzip. Der Preis dafür ist das Expression-Problem: Neue Knotentypen erfordern Änderungen an jeder bestehenden Visitor-Implementierung.
In PHP 8.4 bietet die match-Expression eine schlankere Alternative für kleine, stabile Knotenmengen, verzichtet aber auf die von PHPStan erzwungene Vollständigkeitsprüfung, die das klassische Visitor Pattern automatisch mitbringt. Für Compiler, Interpreter und Codeanalyse-Tools mit wachsender Zahl an Operationen bleibt das klassische Visitor Pattern die robustere Wahl.
Das Visitor Pattern in PHP — Das Wichtigste auf einen Blick
Double Dispatch
Zwei Methodenaufrufe, accept() und die passende visit-Methode, kombinieren Knotentyp und Visitor-Typ.
Neue Operationen
Entstehen als neue Visitor-Klasse, ohne bestehende Knotenklassen anzufassen.
Expression-Problem
Neue Knotentypen erfordern Änderungen an jedem bestehenden Visitor. Kein kostenloses Mittel.
Alternative
match auf ::class ist für kleine, stabile Knotenmengen pragmatischer, aber schwächer typgesichert.