Les constructions visit
/accept
du pattern visiteur sont un mal nécessaire en raison de la sémantique des langages de type C (C#, Java, etc.). L'objectif du pattern visiteur est d'utiliser le double dispatch pour router votre appel comme vous vous y attendriez en lisant le code.
Normalement, lorsque le pattern visiteur est utilisé, une hiérarchie d'objets est impliquée où tous les nœuds sont dérivés d'un type de base Node
, désigné dorénavant par Node
. Instinctivement, nous l'écririons ainsi :
Node root = GetTreeRoot();
new MyVisitor().visit(root);
Voici le problème. Si notre classe MyVisitor
était définie comme suit :
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
S'il s'avère, lors de l'exécution, que quel que soit le type réel de root
, notre appel irait vers la surcharge visit(Node node)
. Cela serait vrai pour toutes les variables déclarées de type Node
. Pourquoi ? Parce que Java et d'autres langages de type C se contentent de considérer le type statique, c'est-à-dire le type déclaré de la variable, du paramètre lors de la décision de l'appel à la surcharge. Java ne va pas vérifier, pour chaque appel de méthode, lors de l'exécution, "D'accord, quel est le type dynamique de root
? Ah, je vois. C'est un TrainNode
. Voyons s'il y a une méthode dans MyVisitor
qui accepte un paramètre de type TrainNode
...". Le compilateur, lors de la compilation, détermine quelle méthode sera appelée. (Si Java inspectait effectivement les types dynamiques des arguments, les performances seraient assez mauvaises.)
Java nous donne un outil pour prendre en compte le type d'exécution (c'est-à-dire dynamique) d'un objet lors de l'appel d'une méthode -- la dispatche de méthode virtuelle. Lorsque nous appelons une méthode virtuelle, l'appel se fait en réalité vers une table en mémoire constituée de pointeurs de fonction. Chaque type possède une table. Si une méthode particulière est redéfinie par une classe, l'entrée de la table de fonction de cette classe contiendra l'adresse de la fonction redéfinie. Si la classe ne redéfinit pas une méthode, elle contiendra un pointeur vers l'implémentation de la classe de base. Cela engendre quand même un surcoût de performance (chaque appel de méthode sera essentiellement la déréférencement de deux pointeurs : l'un pointant vers la table de fonction du type et un autre pointant vers la fonction elle-même), mais c'est toujours plus rapide que d'inspecter les types de paramètres.
L'objectif du pattern visiteur est d'accomplir double dispatch -- non seulement le type de la cible d'appel est considéré (MyVisitor
, via les méthodes virtuelles), mais aussi le type du paramètre (de quel type de Node
s'agit-il) ? Le pattern Visiteur nous permet de faire cela grâce à la combinaison visit
/accept
.
En changeant notre ligne comme ceci :
root.accept(new MyVisitor());
Nous pouvons obtenir ce que nous voulons : via la dispatche de méthode virtuelle, nous entrons dans l'appel accept() correct tel qu'implémenté par la sous-classe -- dans notre exemple avec TrainElement
, nous entrerons dans l'implémentation de accept()
de TrainElement
:
class TrainNode extends Node implements IVisitable {
void accept(IVisitor v) {
v.visit(this);
}
}
Que sait le compilateur à ce stade, à l'intérieur de la portée de accept
de TrainNode
? Il sait que le type statique de this
est un TrainNode
. Il s'agit là d'un morceau d'information supplémentaire important auquel le compilateur n'était pas conscient dans la portée de l'appelant : là, tout ce qu'il savait de root
était qu'il s'agissait d'un Node
. À présent, le compilateur sait que this
(root
) n'est pas juste un Node
, mais qu'il s'agit en fait d'un TrainNode
. Par conséquent, la ligne trouvée à l'intérieur de accept()
: v.visit(this)
, prend un sens totalement différent. Le compilateur recherchera maintenant une surcharge de visit()
prenant un TrainNode
. Si elle n'existe pas, il compilera l'appel vers une surcharge prenant un Node
. Si aucune des deux n'existe, vous obtiendrez une erreur de compilation (sauf si vous avez une surcharge qui prend object
). L'exécution entrera donc finalement dans ce que nous avions initialement prévu : l'implémentation de visit(TrainElement e)
de MyVisitor
. Aucun cast n'était nécessaire, et, surtout, pas de réflexion. Ainsi, le surcoût de ce mécanisme est plutôt faible : il se compose uniquement de références de pointeurs et rien d'autre.
Vous avez raison dans votre question -- nous pouvons utiliser un cast et obtenir le comportement correct. Cependant, souvent, nous ne connaissons même pas le type de Node. Prenons le cas de la hiérarchie suivante :
classe abstraite Node { ... }
classe abstraite BinaryNode extends Node { Node left, right; }
classe abstraite AdditionNode extends BinaryNode { }
classe abstraite MultiplicationNode extends BinaryNode { }
classe LitteralNode { int value; }
Et nous étions en train d'écrire un compilateur simple qui analyse un fichier source et produit une hiérarchie d'objets conforme à la spécification ci-dessus. Si nous écrivions un interpréteur pour la hiérarchie implémentée en tant que Visiteur :
class Interpreter implements IVisitor {
int visit(AdditionNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left + right;
}
int visit(MultiplicationNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left * right;
}
int visit(LitteralNode n) {
return n.value;
}
}
Le cast ne nous mènerait pas très loin, car nous ne connaissons pas les types de left
ou right
dans les méthodes visit()
. Notre analyseur renverrait très probablement également un objet de type Node
qui pointerait à la racine de la hiérarchie, donc nous ne pourrions pas le caster en toute sécurité non plus. Ainsi, notre interpréteur simple pourrait ressembler à ceci :
Node programme = analyse(args[0]);
int résultat = programme.accept(new Interpreter());
System.out.println("Résultat : " + résultat);
Le pattern visiteur nous permet de faire quelque chose de très puissant : étant donné une hiérarchie d'objets, il nous permet de créer des opérations modulaires qui fonctionnent sur la hiérarchie sans avoir besoin de mettre le code dans la classe de la hiérarchie elle-même. Le pattern visiteur est largement utilisé, par exemple, dans la construction de compilateurs. Étant donné l'arbre syntaxique d'un programme particulier, de nombreux visiteurs sont écrits pour opérer sur cet arbre : vérification des types, optimisations, génération de code machine sont généralement implémentés en tant que visiteurs différents. Dans le cas du visiteur d'optimisation, il peut même produire un nouvel arbre syntaxique en fonction de l'arbre d'entrée.
Il a bien sûr ses inconvénients : si nous ajoutons un nouveau type dans la hiérarchie, nous devons également ajouter une méthode visit()
pour ce nouveau type dans l'interface IVisitor
, et créer des implémentations de stub (ou complètes) dans tous nos visiteurs. Nous devons également ajouter la méthode accept()
aussi, pour les raisons décrites ci-dessus. Si les performances ne sont pas d'une grande importance pour vous, il existe des solutions pour écrire des visiteurs sans avoir besoin de l'accept()
, mais elles impliquent généralement la réflexion et peuvent donc entraîner un surcoût assez important.