Il y a beaucoup de discussions sur le découplage des algorithmes des classes. Mais, une chose reste de côté non expliquée.
Ils utilisent des visiteurs comme celui-ci
abstract class Expr {
public <T> T accept(Visitor<T> visitor) {visitor.visit(this);}
}
class ExprVisitor extends Visitor{
public Integer visit(Num num) {
return num.value;
}
public Integer visit(Sum sum) {
return sum.getLeft().accept(this) + sum.getRight().accept(this);
}
public Integer visit(Prod prod) {
return prod.getLeft().accept(this) * prod.getRight().accept(this);
}
Au lieu d'appeler directement visit (element), Visitor demande à l'élément d'appeler sa méthode visit. Cela contredit l'idée déclarée de méconnaissance de la classe au sujet des visiteurs.
PS1 Veuillez expliquer avec vos propres mots ou indiquer une explication exacte. Parce que deux réponses que j'ai reçues se réfèrent à quelque chose de général et d'incertain.
PS2 Ma supposition: puisque getLeft()
renvoie la base Expression
, appeler visit(getLeft())
entraînerait visit(Expression)
, tandis que getLeft()
appeler visit(this)
entraînera une autre invocation de visite, plus appropriée. Ainsi, accept()
effectue la conversion de type (aka casting).
PS3 Scala's Pattern Matching = Visitor Pattern on Steroid montre à quel point le modèle de visiteur est plus simple sans la méthode d'acceptation. Wikipedia ajoute à cette déclaration : en reliant un article montrant "que les méthodes accept()
ne sont pas nécessaires lorsque la réflexion est disponible; introduit le terme 'Walkabout' pour la technique."
Les constructions visit
/accept
du modèle visiteur sont un mal nécessaire en raison de la sémantique des langages de type C (C #, Java, etc.). L'objectif du modèle de visiteur est d'utiliser la double répartition pour acheminer votre appel comme vous vous attendez de la lecture du code.
Normalement, lorsque le modèle de 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
, appelé désormais Node
. Instinctivement, nous l'écririons comme ceci:
Node root = GetTreeRoot();
new MyVisitor().visit(root);
C'est ici que se trouve le problème. Si notre classe MyVisitor
a été définie comme suit:
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
Si, au moment de l'exécution, quel que soit le type réel qu'est root
, notre appel irait dans la surcharge visit(Node node)
. Cela serait vrai pour toutes les variables déclarées de type Node
. Pourquoi est-ce? Parce que Java et d'autres langages de type C ne prennent en compte que le type statique, ou le type de déclaration de la variable, du paramètre lors du choix de la surcharge à appel. Java ne prend pas l'étape supplémentaire pour demander, pour chaque appel de méthode, lors de l'exécution, "D'accord, quel est le type dynamique de root
? Oh, je vois. C'est a TrainNode
. Voyons s'il y a une méthode dans MyVisitor
qui accepte un paramètre de type TrainNode
... ". Le compilateur, au moment de la compilation, détermine quelle est la (Si Java a effectivement inspecté les types dynamiques des arguments, les performances seraient assez terribles.)
Java nous donne un outil pour prendre en compte le type d'exécution (c'est-à-dire dynamique) d'un objet lorsqu'une méthode est appelée - envoi de méthode virtuelle . Lorsque nous appelons une méthode virtuelle, l'appel va en fait à un table en mémoire qui se compose de pointeurs de fonction. Chaque type a une table. Si une méthode particulière est remplacée par une classe, l'entrée de table de fonction de cette classe contiendra l'adresse de la fonction remplacée. Si la classe ne remplace pas une méthode, elle contiendra un pointeur vers l'implémentation de la classe de base. Cela entraîne toujours une surcharge de performances (chaque appel de méthode déréférencera essentiellement deux pointeurs: l'un pointant vers la table de fonctions du type, et l'autre de la fonction elle-même), mais c'est toujours plus rapide que d'avoir à inspecter les types de paramètres.
L'objectif du modèle de visiteur est d'accomplir double-dispatch - non seulement le type de la cible d'appel considéré (MyVisitor
, via des méthodes virtuelles), mais aussi le type de la paramètre (quel type de Node
examinons-nous)? Le modèle Visitor nous permet de le faire par la combinaison visit
/accept
.
En changeant notre gamme en ceci:
root.accept(new MyVisitor());
Nous pouvons obtenir ce que nous voulons: via l'envoi d'une méthode virtuelle, nous entrons l'appel correct accept () tel qu'implémenté par la sous-classe - dans notre exemple avec TrainElement
, nous entrerons l'implémentation de TrainElement
dans accept()
:
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 TrainNode
accept
? Il sait que le type statique de this
est un TrainNode
. Il s'agit d'un élément d'information supplémentaire important que le compilateur n'était pas au courant de la portée de notre appelant: là, tout ce qu'il savait sur root
était qu'il s'agissait d'un Node
. Maintenant, le compilateur sait que this
(root
) n'est pas seulement un Node
, mais c'est en fait un TrainNode
. Par conséquent, la seule ligne trouvée à l'intérieur de accept()
: v.visit(this)
, signifie complètement autre chose. Le compilateur va maintenant rechercher une surcharge de visit()
qui prend un TrainNode
. S'il n'en trouve pas, il compilera alors l'appel à une surcharge qui prend un Node
. Si aucun n'existe, vous obtiendrez une erreur de compilation (sauf si vous avez une surcharge qui prend object
). L'exécution entrera donc ce que nous avions prévu tout au long: l'implémentation de visit(TrainNode e)
par MyVisitor
. Aucun plâtre n'a été nécessaire et, surtout, aucune réflexion n'a été nécessaire. Ainsi, la surcharge de ce mécanisme est plutôt faible: il ne se compose que de références de pointeurs et rien d'autre.
Vous avez raison dans votre question - nous pouvons utiliser un casting et obtenir le bon comportement. Cependant, souvent, nous ne savons même pas quel type Node est. Prenons le cas de la hiérarchie suivante:
abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }
Et nous écrivions un simple compilateur qui analyse un fichier source et produit une hiérarchie d'objets conforme à la spécification ci-dessus. Si nous écrivions un interprète pour la hiérarchie implémentée en tant que visiteur:
class Interpreter implements IVisitor<int> {
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(LiteralNode n) {
return n.value;
}
}
Le casting 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 retournerait très probablement aussi un objet de type Node
qui pointait également vers la racine de la hiérarchie, donc nous ne pouvons pas le transposer non plus en toute sécurité. Ainsi, notre simple interprète peut ressembler à:
Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);
Le modèle de 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 opèrent sur la hiérarchie sans avoir besoin de mettre le code dans la classe de la hiérarchie elle-même. Le modèle de visiteur est largement utilisé, par exemple, dans la construction de compilateurs. Étant donné l'arbre de syntaxe d'un programme particulier, de nombreux visiteurs sont écrits qui opèrent sur cet arbre: la vérification de type, les optimisations, l'émission de code machine sont généralement implémentées en tant que visiteurs différents. Dans le cas du visiteur d'optimisation, il peut même produire un nouvel arbre de syntaxe en fonction de l'arbre d'entrée.
Il a ses inconvénients, bien sûr: 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 stub ( ou complète) implémentations dans tous nos visiteurs. Nous devons également ajouter la méthode accept()
également, pour les raisons décrites ci-dessus. Si les performances ne signifient pas grand-chose pour vous, il existe des solutions pour écrire des visiteurs sans avoir besoin de la accept()
, mais elles impliquent normalement la réflexion et peuvent donc entraîner des frais généraux assez importants.
Bien sûr, ce serait idiot si c'était la manière seulement qu'Accept est implémentée.
Mais ce n'est pas.
Par exemple, les visiteurs sont vraiment très utiles lorsqu'ils traitent avec des hiérarchies, auquel cas l'implémentation d'un nœud non terminal pourrait ressembler à ceci
interface IAcceptVisitor<T> {
void Accept(IVisit<T> visitor);
}
class HierarchyNode : IAcceptVisitor<HierarchyNode> {
public void Accept(IVisit<T> visitor) {
visitor.visit(this);
foreach(var n in this.children)
n.Accept(visitor);
}
private IEnumerable<HierarchyNode> children;
....
}
Tu vois? Ce que vous décrivez comme stupide est la solution pour traverser les hiérarchies.
Voici un article beaucoup plus long et approfondi qui m'a fait comprendre visiteur .
Edit: Pour clarifier: La méthode Visit
du visiteur contient une logique à appliquer à un nœud. La méthode Accept
du nœud contient une logique sur la façon de naviguer vers les nœuds adjacents. Le cas où vous seulement double répartition est un cas spécial où il n'y a tout simplement pas de nœuds adjacents vers lesquels naviguer.
Le motif du visiteur a pour but de s'assurer que les objets savent quand le visiteur en a fini avec eux et qu'ils sont partis, afin que les classes puissent effectuer tout nettoyage nécessaire par la suite. Il permet également aux classes d'exposer leurs éléments internes "temporairement" en tant que paramètres "ref", et de savoir que les éléments internes ne seront plus exposés une fois que le visiteur sera parti. Dans les cas où aucun nettoyage n'est nécessaire, le modèle de visiteur n'est pas très utile. Les classes qui ne font rien de tout cela peuvent ne pas bénéficier du modèle de visiteur, mais le code qui est écrit pour utiliser le modèle de visiteur sera utilisable avec les classes futures qui pourraient nécessiter un nettoyage après l'accès.
Par exemple, supposons que l'on ait une structure de données contenant de nombreuses chaînes qui devraient être mises à jour atomiquement, mais que la classe contenant la structure de données ne sait pas précisément quels types de mises à jour atomiques doivent être effectuées (par exemple, si un thread veut remplacer toutes les occurrences de " X ", alors qu'un autre thread souhaite remplacer toute séquence de chiffres par une séquence numériquement supérieure, les opérations des deux threads doivent réussir. Si chaque thread lit simplement une chaîne, effectue ses mises à jour et la réécrit, le deuxième thread réécrire sa chaîne écraserait la première). Une façon d'y parvenir consisterait à ce que chaque thread acquière un verrou, effectue son opération et relâche le verrou. Malheureusement, si des verrous sont exposés de cette manière, la structure de données n'aurait aucun moyen d'empêcher quelqu'un d'acquérir un verrou et de ne jamais le libérer.
Le modèle de visiteur propose (au moins) trois approches pour éviter ce problème:
Sans le modèle de visiteur, effectuer des mises à jour atomiques nécessiterait d'exposer des verrous et de risquer l'échec si le logiciel appelant ne suivait pas un protocole de verrouillage/déverrouillage strict. Avec le modèle Visitor, les mises à jour atomiques peuvent être effectuées de manière relativement sûre.
Les classes qui nécessitent une modification doivent toutes implémenter la méthode "accept". Les clients appellent cette méthode d'acceptation pour effectuer une nouvelle action sur cette famille de classes, étendant ainsi leurs fonctionnalités. Les clients peuvent utiliser cette méthode d'acceptation unique pour effectuer un large éventail de nouvelles actions en transmettant une classe de visiteurs différente pour chaque action spécifique. Une classe de visiteurs contient plusieurs méthodes de visite redéfinies définissant comment réaliser la même action spécifique pour chaque classe de la famille. Ces méthodes de visite reçoivent une instance sur laquelle travailler.
Les visiteurs sont utiles si vous ajoutez, modifiez ou supprimez fréquemment des fonctionnalités à une famille stable de classes car chaque élément de fonctionnalité est défini séparément dans chaque classe de visiteurs et les classes elles-mêmes n'ont pas besoin d'être modifiées. Si la famille de classes n'est pas stable, le modèle de visiteurs peut être moins utile, car de nombreux visiteurs doivent être modifiés chaque fois qu'une classe est ajoutée ou supprimée.