Das Visitor Pattern in PHP: Operationen von Datenstrukturen sauber trennen
AI generated
<?php
8.4
PHP · OOP-Patterns · Visitor Pattern · Double Dispatch
Das Visitor Pattern in PHP
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.

18 Min. Lesezeit Double Dispatch · Visitable · AST PHP 8.2 · 8.3 · 8.4

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.

11. FAQ: Visitor Pattern in PHP

1Was ist Double Dispatch?
Zwei Methodenaufrufe, accept() und visit-Methode, kombinieren Knotentyp und Visitor-Typ für die Verhaltensentscheidung.
2Warum reicht Single Dispatch nicht?
PHP entscheidet nur anhand des Objekttyps, nicht anhand eines Arguments. Visitor braucht beide Typen gleichzeitig.
3Was ist das Expression-Problem?
Entweder leicht neue Operationen oder leicht neue Datentypen, nicht beides gleichzeitig ohne Kompromisse.
4Fachlogik in accept()?
Nein, accept() reicht nur weiter. Sonst geht die Trennung zwischen Datenstruktur und Operation verloren.
5match statt Visitor ausreichend?
Für kleine Knotenmengen ja, aber ohne die von PHPStan erzwungene Vollständigkeitsgarantie.
6Neuer Knotentyp, was passiert?
Jede Visitor-Implementierung muss um eine neue Methode ergänzt werden, sonst ist das Interface nicht erfüllt.
7Typischer Einsatzbereich?
Compiler, Interpreter, Codeanalyse-Tools mit einem AST, stabile Knotentypen, wachsende Operationszahl.
8Rekursive Strukturen verarbeiten?
Ja, solange zyklenfrei. Ein Zyklus führt zu Stack Overflow im rekursiven Visitor.
9Welcher Rückgabetyp?
mixed für heterogene Operationen, spezifischer Typ, wenn die Operation immer denselben Typ liefert.
10Für kleine Skripte sinnvoll?
Meist nicht, lohnt sich erst bei mehreren unabhängigen Operationen auf stabiler Datenstruktur.