13 août 2006

V comme Visiteur

Non, ce n'est pas le titre d'une série télévisée, la discussion qui s'en suivrait si tel était le cas n'aurait de toute façon aucune espèce d'intérêt dans le cadre d'un billet parlant d'architecture orientée objet. Le visiteur auquel je fais référence est bien évidemment le patron de conception du même nom.

Avant de discuter plus avant sur ce patron, et parce que les patrons de conception sont encore des entité méconnues, une brève description s'impose.

La définition usuelle de ce parton de conception est la suivante : il représente une opération à effectuer sur les éléments d'une structure d'objet. Il permet en outre de définir de nouvelles opérations sans changer les classes des éléments sur lesquels il opère.

La représentation UML du patron fait intervenir plusieurs classes : AbstractVisitor définit l'interface du visiteur comme un ensemble de méthodes visit() prenant des paramètres de type différent. Les classes ConcreteVisitorA et ConcreteVisitor2 implémentent ces méthodes. AbstractElement définit la méthode abstraite accept(), qui est implémentée dans ConcreteElement1 et ConcreteElement2.

diagramme de classe du Visiteur

Grâce au mécanisme de surcharge, ConcreteElement1::accept() va appeler la méthode AbstractVisitor::visit() qui gère les objets du type ConcreteElement1, tandis que ConcreteElement2::accept() va appeler celle qui gère les objets du type ConcreteElement2.

class ConcreteElement1
{
public:
   virtual void accept(AbstractVisitor& visitor)
   {
      // ce code va appeler 
      // AbstractVisitor::visit(ConcreteElement1& element)
      visitor.visit(*this);
   }
};
class ConcreteElement2 { public: virtual void accept(AbstractVisitor& visitor) { // ce code va appeler // AbstractVisitor::visit(ConcreteElement2& element) visitor.visit(*this); } };

Le mécanisme mis en place s'appelle double dispatch et permet d'éviter l'utilisation du transtypage dynamique, méthode plus lourde et difficilement extensible.

L'utilisation typique du patron Visiteur consiste à parcourir une liste d'objets du même type de base pour y appliquer un algorithme dépendant de ce type de base. Par exemple, je peux imaginer une classe CarElement dont vont hériter les classes Wheel, SteeringDevice et Engine. L'objet Car contient une liste de CarElement, et je souhaite par exemple compter le nombre d'éléments de type Wheel. Pour ce faire, je crée un visiteur qui implémente visit(Wheel&) de manière à incrémenter un compteur; les autres méthodes visit() ne font rien. En itérant sur la liste de CarElement, je suis maintenant à même de compter le nombre de roues de mon véhicule, sans pour celà avoir été obligé de modifier quoi que ce soit dans la hiérarchie des objets utilisés.

On le voit, ce patron a de grande qualités. Ce qu'on discerne mal, ce sont ses défauts, qui sont pourtant particulièrement gênants.

En fait de défauts, le plus important est aussi le plus visible : le patron de conception Visiteur introduit une dépendance circulaire dans la conception. ConcreteElement1 se doit de connaitre l'existence de AbstractVisitor qui doit connaitre ConcreteElement1. Bien souvent, les dépendances circulaires sont le signe d'un problème de cohérence face aux principes fondamentaux de l'architecture objet[1], et c'est bien évidemment le cas ici : le principe ouvert-fermé (Open/Closed Principle, ou OCP) n'est pas respecté. Ce principe stipule que mes classes doivent être ouvertes à l'extension mais fermées aux modifications, c'est à dire qu'une évolution logicielle doit pouvoir ajouter de nouvelles fonctionnalités sans modifier le code existant. C'est bien évidemment impossible avec le visiteur : si j'ajoute un nouveau type d'objet à visiter, je dois modifier mon visiteur abstrait et toutes les classes dérivées de AbstractVisitor vont s'en trouver impactée.

Robert C. Martin propose une solution à ce problème, en utilisant ce qu'il nomme des visiteurs acycliques (pdf). Dans un visiteur acyclique, AbstractVisitor est une classe dégénérée - c'est à dire qu'elle ne contient aucune méthode. De cette classe dérive AbstractElement1Visitor ou AbstractElement2Visitor qui définissent les méthodes visit() correspondante (une seule par classe). ConcreteElement1, dans sa méthode accept() vérifie si l'objet visitor qu'il reçoit en paramètre est bien du type AbstractElement1Visitor.

class AbstractVisitor
{
};
class AbstractElement1Visitor : public AbstractVisitor { public: virtual void visit(ConcreteElement1& element) = 0; };
class ConcreteElement1 { public: virtual void accept(AbstractVisitor* visitor) { AbstractElement1Visitor *el1visitor; el1visitor = dynamic_cast<AbstractElement1Visitor*>(visitor); if (el1visitor) { el1visitor->visit(*this); } } };

Cette solution est relativement satisfaisante - à partir du moment où le langage objet utilisé permet de connaitre de manière dynamique le type d'un objet (en C++, dynamic_cast<> remplit cet office).

On notera aussi que les langages ayant des possibilités de réflexion et d'introspection peuvent permettre l'implémentation de solutions similaires, quoique souvent plus élégantes. D'autres langages (comme Nice) permettent de définir des multiméthodes, qui offrent une alternative satisfaisante à la technique du double dispatch.

Utiliser le patron Visiteur tel quel est certes très utile, mais cette utilisation doit être balancée par le fait qu'il ne respecte pas OCP. Si le nombre de classes concernées est faible, le problème n'est pas très important. Dans le cas contraire, il peut être intéressant d'évaluer la possibilité d'utiliser des solutions alternatives bien que le code résultant soit souvent moins idiomatique (et donc plus difficile à déchiffrer).

Notes

[1] je me permet d'introduire les principes fondamentaux de l'architecture objet ici, mais je prendrais soin de les discuter plus tard, dans une série de billets dédiés

Commentaires

1. Le samedi, octobre 14 2006, 05:47 par Karim Refeyton

Cette solution présente le désavantage d'obliger l'élément de connaitre toutes les classes de visiteurs qui le traverse, or le but du visiteur est justement le contraire !

Une autre possiblité pour rester dans l'esprit du visiteur est donc de laisser le visiteur choisir la méthode spécifique au type de l'élément mais non pas en exposant d'embler toutes les signatures possibles, mais en exposant seulement la signature la plus générique. C'est en son sein que le visiteur va opérer les cast nécessaire pour déclencher le traitement adéquat.

Dans ce cas de figure, AbstractVisitor contient une signature ayant en paramètre un AbstractElement et à l'intérieur de l'implémentation de cette méthode propre à chaque visiteur, le visiteur vérifie si le paramètre est de tel ou tel type afin de déclencher l'appel à la méthode dédiée à ce type d'élément.

(en pseudo-java in ze text)

class AbstractVisitor{
abstract void visit(AbstractElement element);
}

class Visitor1 extends AbstractVisitor{
void visit(AbstractElement element) {
// Tente le cast du paramètre pour appeler la méthode adéquate
if (element instanceof ConcreteElement1) {
visit((ConcreteElement1)element);
} else if (element instanceof ConcreteElement2) {
visit((ConcreteElement2)element);
} else {
// Fonctionnement par défaut
}
}
void visit(ConcreteElement1 element) {
// Traitement spécifique à ConcreteElement1
}
void visit(ConcreteElement2 element) {
// Traitement spécifique à ConcreteElement1
}
}

Ainsi, les éléments ne se soucient plus des types de visiteurs qui les traversent et la méthode peut être implémentée une fois pour toute au niveau de la classe abstraite :

class AbstractElement {
public void accept(AbstractVisitor visitor) {
visitor.visit(this);
}
}

2. Le samedi, octobre 14 2006, 05:50 par Karim Refeyton

Désolé pour les fautes de frappe, j'en corrige au moins une

class Visitor1 extends AbstractVisitor{
void visit(AbstractElement element) {
// Tente le cast du paramètre pour appeler la méthode adéquate
if (element instanceof ConcreteElement1) {
visit((ConcreteElement1)element);
} else if (element instanceof ConcreteElement2) {
visit((ConcreteElement2)element);
} else {
// Fonctionnement par défaut
}
}
void visit(ConcreteElement1 element) {
// Traitement spécifique à ConcreteElement1
}
void visit(ConcreteElement2 element) {
// Traitement spécifique à ConcreteElement2 <- corrigé
}
}

3. Le lundi, octobre 16 2006, 22:34 par Emmanuel Deloget

> Cette solution présente le désavantage d'obliger
> l'élément de connaitre toutes les classes de
> visiteurs qui le traverse, or le but du visiteur
> est justement le contraire !

Non, pas tout à fait. En fait, la classe n'a besoin de connaître que le visiteur abstrait qui sert de base à tous les visiteurs qui le traverse. Dans mon exemple, ConcreteElement1 va connaître AbstractElement1Visitor, et n'a pas besoin de connaître les visiteurs concrets qui dérivent de cette classe.

Quoi qu'il en soit, cette limitation est liée au langage plutôt qu'au code - le code essayant tant bien que mal de corriger un problème qui n'existerait pas si le langage supportait le multiple dispatch (dont le double dispatch n'est qu'un cas particulier). Trouver une solution élégante au problème me semble difficile dans ce cas.

La solution que tu propose peut être adaptée au C++, avec une petite limitation : comme la solution de R.C. Martin, elle impose de compiler le projet en utilisant le système RTTI (pour avoir accès à dynamic_cast), ce qui aura une influence non négligeable sur les performances du code. Bien èvidemment, ce n'est pas là un gros problème :) Elle ne résout pas le problème du non respect du principe ouvert/fermé, mais comme la solution originelle ne le fait pas non plus...

PS: désolé pour les erreurs d'accents, etc. Les claviers de nos amis germains sont traitres :)

4. Le dimanche, octobre 22 2006, 18:35 par Karim Refeyton

Dans l'exemple que je donne, le visité ne fait aucune hypothèse sur les caractéristiques du visiteur. Quel aspect de cette solution ne permet pas le respect du principe ouvert/fermé ?

5. Le mardi, octobre 24 2006, 16:10 par Emmanuel Deloget

Ta solution suppose que Visitor1 connait tous les objets qu'il va visiter. C'est une amélioration indéniable sur le pattern originel (qui supposait que la classe abstraite connaisse les différentes classes visitées), mais il n'en reste pas moins qu'elle ne respecte pas OCP : si je rajoute une classe à visiter dans la hierarchie, il me faut modifier le visiteur.

De toute façon, sans une implémentation native du multiple dispatch, on est revient toujours au même type de solutions - il convient donc de passer outre leurs problèmes dans la pratique, puisqu'aucune de ces solutions n'est pleinement satisfaisante.

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet