Quel est le point de la méthode accept () dans le modèle de visiteur?

87

On parle beaucoup de découpler les algorithmes des classes. Mais, une chose reste de côté non expliquée.

Ils utilisent le visiteur comme ça

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 d'inconscience de classe sur les visiteurs.

PS1 Veuillez expliquer avec vos propres mots ou indiquer une explication exacte. Parce que deux réponses que j'ai reçues font référence à quelque chose de général et d'incertain.

PS2 Ma conjecture: puisque getLeft()retourne la base Expression, l'appel visit(getLeft())entraînerait visit(Expression), tandis que l' getLeft()appel visit(this)entraînerait un autre appel de visite, plus approprié. Donc, accept()effectue la conversion de type (aka casting).

La correspondance de modèle de PS3 Scala = modèle de visiteur sur stéroïde 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 accept()méthodes ne sont pas nécessaires lorsque la réflexion est disponible; introduit le terme" Walkabout "pour la technique."

Val
la source
Il dit "lorsque le visiteur appelle accept, l'appel est distribué en fonction du type de l'appelé. Ensuite, l'appelé rappelle la méthode de visite spécifique au type de visiteur, et cet appel est distribué en fonction du type réel du visiteur." En d'autres termes, il énonce ce qui me trouble. Pour cette raison, pouvez-vous être plus précis?
Val

Réponses:

154

Les structures visit/ du modèle de visiteur acceptsont 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 à 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 Nodetype de base , désormais appelé 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 MyVisitorclasse é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);
}

Si, au moment de l'exécution, quel que soit le type réelroot , 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 sous lequel la variable est déclarée, du paramètre lors du choix de la surcharge à appeler. Java ne fait pas l'étape supplémentaire pour demander, pour chaque appel de méthode, à l'exécution, "D'accord, quel est le type dynamique de root? Oh, je vois. C'est un TrainNode. Voyons s'il y a une méthode dans MyVisitorlaquelle accepte un paramètre de typeTrainNode... ". Le compilateur, au moment de la compilation, détermine quelle est la méthode qui sera appelée. (Si Java inspectait effectivement 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 - répartition de méthode virtuelle . Lorsque nous appelons une méthode virtuelle, l'appel va en fait à une 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 la table des fonctions 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: un pointant vers la table de fonctions du type et un autre sur 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 une double répartition - non seulement le type de cible d'appel est-il pris en compte ( MyVisitor, via des méthodes virtuelles), mais également le type de paramètre (quel type de cible Noderegardons-nous)? Le modèle Visiteur nous permet de le faire par la combinaison visit/ accept.

En changeant notre ligne en ceci:

root.accept(new MyVisitor());

Nous pouvons obtenir ce que nous voulons: via la répartition de la méthode virtuelle, nous entrons le bon appel accept () tel qu'implémenté par la sous-classe - dans notre exemple avec TrainElement, nous entrerons TrainElementl'implémentation de accept():

class TrainNode extends Node implements IVisitable {
  void accept(IVisitor v) {
    v.visit(this);
  }
}

Que sait le compilateur à ce stade, dans le cadre de TrainNode's accept? Il sait que le type statique de thisest aTrainNode . Il s'agit d'une information supplémentaire importante dont le compilateur n'était pas au courant dans la portée de notre appelant: là, tout ce qu'il savait, rootc'était qu'il s'agissait d'un fichier Node. Maintenant, le compilateur sait que this( root) n'est pas seulement un Node, mais c'est en fait un TrainNode. En conséquence, une ligne trouvé à l' intérieur accept(): v.visit(this), signifie quelque chose d' autre. Le compilateur recherchera maintenant une surcharge de visit()qui prend un TrainNode. S'il n'en trouve pas, il compilera alors l'appel à une surcharge qui prend unNode. Si aucun n'existe, vous obtiendrez une erreur de compilation (sauf si vous avez une surcharge qui prend object). L'exécution entrera donc dans ce que nous avions toujours voulu: MyVisitorla mise en œuvre de visit(TrainNode e). Aucun moulage n'était nécessaire et, surtout, aucune réflexion n'était nécessaire. Ainsi, la surcharge de ce mécanisme est plutôt faible: il ne se compose que de références de pointeur 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 est le type de Node. 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 ni les types leftni rightles visit()méthodes. Notre parseur renverrait probablement également un objet de type Nodequi pointait également vers la racine de la hiérarchie, nous ne pouvons donc pas non plus convertir cela en toute sécurité. Ainsi, notre interpréteur simple 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 du compilateur. Compte tenu de l'arborescence syntaxique d'un programme particulier, de nombreux visiteurs sont écrits qui opèrent sur cet arbre: vérification de type, optimisations, é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 générer un nouvel arbre de syntaxe étant donné 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 visit()méthode pour ce nouveau type dans l' IVisitorinterface, et créer des implémentations stub (ou complètes) dans tous nos visiteurs. Nous devons également ajouter la accept()méthode, pour les raisons décrites ci-dessus. Si la performance ne signifie pas grand-chose pour vous, il existe des solutions pour écrire des visiteurs sans avoir besoin du accept(), mais elles impliquent normalement une réflexion et peuvent donc entraîner une surcharge assez importante.

atanamir
la source
5
L'élément Java n ° 41 effectif inclut cet avertissement: « Évitez les situations où le même ensemble de paramètres peut être transmis à différentes surcharges par l'ajout de transtypages. » La accept()méthode devient nécessaire lorsque cet avertissement est violé dans le visiteur.
jaco0646
« 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 nœud de base », ce n'est absolument pas nécessaire en C ++. Voir Boost.Variant, Eggs.Variant
Jean-Michaël Celerier
Il me semble qu'en java nous n'avons pas vraiment besoin de la méthode accept car en java, nous appelons toujours la méthode de type la plus spécifique
Gilad Baruchian
1
Wow, c'était une explication géniale. Éclairant de voir que toutes les ombres du modèle sont dues aux limitations du compilateur, et se révèle maintenant clair grâce à vous.
Alfonso Nishikawa
@GiladBaruchian, le compilateur génère un appel à la méthode de type la plus spécifique que le compilateur peut déterminer.
mmw
15

Bien sûr, ce serait idiot si c'était la seule façon dont Accept est implémenté.

Mais ce n'est pas.

Par exemple, les visiteurs sont vraiment très utiles lorsqu'ils traitent des hiérarchies, auquel cas l'implémentation d'un nœud non terminal pourrait être quelque chose comme ça

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;
  ....
}

Vous voyez? 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 le visiteur .

Edit: Pour clarifier: La Visitméthode du visiteur contient une logique à appliquer à un nœud. La Acceptméthode du nœud contient une logique sur la façon de naviguer vers les nœuds adjacents. Le cas où vous ne faites que doubler la répartition est un cas particulier où il n'y a tout simplement aucun nœud adjacent vers lequel naviguer.

George Mauer
la source
7
Votre explication n'explique pas pourquoi il devrait être de la responsabilité du nœud plutôt que de la méthode visit () appropriée du visiteur d'itérer les enfants? Voulez-vous dire que l'idée principale est de partager le code de traversée de la hiérarchie lorsque nous avons besoin des mêmes modèles de visite pour différents visiteurs? Je ne vois aucun indice sur le papier recommandé.
Val
1
Dire qu'accepter est bon pour la traversée de routine est raisonnable et vaut la peine pour la population générale. Mais, j'ai pris mon exemple de quelqu'un d'autre "Je ne pourrais pas comprendre le modèle de Visiteur jusqu'à ce que je lise andymaleh.blogspot.com/2008/04/… ". Ni cet exemple, ni Wikipedia, ni d'autres réponses ne mentionnent l'avantage de la navigation. Néanmoins, ils exigent tous ce stupide accept (). C'est pourquoi posez ma question: pourquoi?
Val
1
@Val - que voulez-vous dire? Je ne sais pas ce que vous demandez. Je ne peux pas parler pour d'autres articles car ces personnes ont des points de vue différents sur ce sujet, mais je doute que nous soyons en désaccord. En général, dans le calcul, un grand nombre de problèmes peuvent être mappés sur des réseaux, donc une utilisation peut n'avoir rien à voir avec des graphiques en surface mais est en fait un problème très similaire.
George Mauer
1
Fournir un exemple où une méthode pourrait être utile ne répond pas à la question de savoir pourquoi la méthode est obligatoire. Comme la navigation n'est pas toujours nécessaire, la méthode accept () n'est pas toujours bonne pour la visite. Nous devrions donc pouvoir atteindre nos objectifs sans cela. Néanmoins, c'est obligatoire. Cela signifie qu'il y a une raison plus forte pour introduire accept () dans chaque motif de visiteur que "c'est parfois utile". Qu'est-ce qui n'est pas clair dans ma question? Si vous n'essayez pas de comprendre pourquoi Wikipédia cherche des moyens de se débarrasser d'accepter, vous n'êtes pas intéressé de comprendre ma question.
Val
1
@Val Le papier qu'ils lient à "L'essence du modèle de visiteur" note la même séparation de la navigation et du fonctionnement dans son résumé que j'ai donné. Ils disent simplement que l'implémentation GOF (qui est ce que vous demandez) a des limitations et des inconvénients qui peuvent être supprimés avec l'utilisation de la réflexion - ils introduisent donc le modèle Walkabout. Ceci est certainement utile et peut faire la même chose que le visiteur, mais c'est beaucoup de code assez sophistiqué et (sur une lecture superficielle) perd certains avantages de la sécurité de type. C'est un outil pour la boîte à outils mais plus lourd que le visiteur
George Mauer
0

L'objectif du modèle Visiteur est de s'assurer que les objets savent quand le visiteur en a fini avec eux et sont partis, afin que les classes puissent effectuer tout nettoyage nécessaire par la suite. Cela permet également aux classes d'exposer leurs internes «temporairement» en tant que paramètres 'ref', et de savoir que les internes ne seront plus exposés une fois le visiteur 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 aucune de ces choses 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 peuvent 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 de manière atomique, 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 d'un, les opérations des deux threads devraient 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'accomplir cela serait de demander à chaque thread d'acquérir un verrou, d'effectuer son opération et de libérer le verrou. Malheureusement, si les verrous sont exposés de cette manière,

Le modèle Visiteur propose (au moins) trois approches pour éviter ce problème:

  1. Il peut verrouiller un enregistrement, appeler la fonction fournie, puis déverrouiller l'enregistrement; l'enregistrement peut être verrouillé pour toujours si la fonction fournie tombe dans une boucle sans fin, mais si la fonction fournie renvoie ou lève une exception, l'enregistrement sera déverrouillé (il peut être raisonnable de marquer l'enregistrement comme invalide si la fonction lève une exception; laisser il est verrouillé n'est probablement pas une bonne idée). Notez qu'il est important que si la fonction appelée tente d'acquérir d'autres verrous, un blocage peut en résulter.
  2. Sur certaines plates-formes, il peut passer un emplacement de stockage contenant la chaîne en tant que paramètre «ref». Cette fonction pourrait alors copier la chaîne, calculer une nouvelle chaîne basée sur la chaîne copiée, tenter de CompareExchange l'ancienne chaîne avec la nouvelle et répéter l'ensemble du processus si CompareExchange échoue.
  3. Il peut faire une copie de la chaîne, appeler la fonction fournie sur la chaîne, puis utiliser CompareExchange lui-même pour tenter de mettre à jour l'original et répéter l'ensemble du processus si CompareExchange échoue.

Sans le modèle de visiteur, effectuer des mises à jour atomiques nécessiterait d'exposer les verrous et de risquer une défaillance si le logiciel appelant ne parvient pas à suivre un protocole de verrouillage / déverrouillage strict. Avec le modèle Visiteur, les mises à jour atomiques peuvent être effectuées de manière relativement sûre.

supercat
la source
2
1. La visite implique que vous n'avez accès qu'aux méthodes publiques de visite, de sorte que vous devez rendre les verrous internes accessibles au public pour être utiles avec Visiteur. 2 / Aucun des exemples que j'ai vus auparavant n'implique que Visiteur est censé être utilisé pour changer le statut de visité. 3. "Avec le VisitorPattern traditionnel, on ne peut que déterminer quand nous entrons dans un nœud. Nous ne savons pas si nous avons quitté le nœud précédent avant d'entrer dans le nœud actuel." Comment débloquer avec une seule visite au lieu de visiterEnter et visitLeave? Enfin, j'ai posé des questions sur les applications de accpet () plutôt que de Visitor.
Val
Peut-être ne suis-je pas tout à fait au courant de la terminologie des modèles, mais le "modèle de visiteur" semble ressembler à une approche que j'ai utilisée où X passe un délégué à Y, auquel Y peut ensuite transmettre des informations qui doivent seulement être valides en tant que tant que le délégué est en cours d'exécution. Peut-être que ce modèle a un autre nom?
supercat
2
Il s'agit d'une application intéressante du modèle de visiteur à un problème spécifique, mais ne décrit pas le modèle lui-même ni ne répond à la question initiale. "Dans les cas où aucun nettoyage n'est nécessaire, le modèle de visiteur n'est pas très utile." Cette affirmation est définitivement fausse et ne concerne que votre problème spécifique et non le modèle en général.
Tony O'Hagan
0

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 visiteur différente pour chaque action spécifique. Une classe de visiteur contient plusieurs méthodes de visite remplacées définissant comment réaliser cette 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 visiteur 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 visiteur peut être moins utile, car de nombreux visiteurs doivent changer chaque fois qu'une classe est ajoutée ou supprimée.

andrew pate
la source
-1

Un bon exemple est dans la compilation de code source:

interface CompilingVisitor {
   build(SourceFile source);
}

Les clients peuvent mettre en œuvre un JavaBuilder, RubyBuilder, XMLValidator, etc. , et la mise en œuvre de la collecte et de visiter tous les fichiers source dans un projet n'a pas besoin de changer.

Ce serait un mauvais modèle si vous avez des classes distinctes pour chaque type de fichier source:

interface CompilingVisitor {
   build(JavaSourceFile source);
   build(RubySourceFile source);
   build(XMLSourceFile source);
}

Cela dépend du contexte et des parties du système que vous souhaitez extensibles.

Salle Garrett
la source
L'ironie est que VisitorPattern nous propose d'utiliser le mauvais modèle. Il dit que nous devons définir une méthode de visite pour chaque type de nœud qu'il va visiter. Deuxièmement, il n'est pas clair quels sont vos exemples, bons ou mauvais? Comment sont-ils liés à ma question?
Val