Visiteur et double répartition.
Examinons la hiérarchie de classes de formes géométriques suivante (attention, il s’agit de pseudocode) :
interface Graphic is
method draw()
class Shape implements Graphic is
field id
method draw()
// ...
class Dot extends Shape is
field x, y
method draw()
// ...
class Circle extends Dot is
field radius
method draw()
// ...
class Rectangle extends Shape is
field width, height
method draw()
// ...
class CompoundGraphic implements Graphic is
field children: array of Graphic
method draw()
// ...
Le code fonctionne bien et l’application est en production. Mais un jour, vous décidez d’ajouter une fonctionnalité d’export. Le code d’export ferait tache s’il était placé dans ces classes. Ainsi, plutôt que d’ajouter l’export à toutes les classes de cette hiérarchie, vous décidez de créer une nouvelle classe, externe à la hiérarchie, et d’y placer toute la logique d’export. Cette classe disposerait de méthodes pour exporter l’état public de chaque objet dans des chaînes XML :
class Exporter is
method export(s: Shape) is
print("Exporting shape")
method export(d: Dot)
print("Exporting dot")
method export(c: Circle)
print("Exporting circle")
method export(r: Rectangle)
print("Exporting rectangle")
method export(cs: CompoundGraphic)
print("Exporting compound")
Le code semble correct, mais essayons-le :
class App() is
method export(shape: Shape) is
Exporter exporter = new Exporter()
exporter.export(shape);
app.export(new Circle());
// Malheureusement, cela affichera "Exporting shape".
Attendez ! Pourquoi ?!
Penser comme un compilateur
Remarque : les informations qui suivent sont valables pour la plupart des langages de programmation orientés objet modernes (Java, C#, PHP et autres).
Liaison tardive/dynamique
Imaginez que vous êtes un compilateur. Vous devez décider comment compiler le code suivant :
method drawShape(shape: Shape) is
shape.draw();
Voyons voir... la méthode draw est définie dans la classe Shape. Attendez une seconde, il y a aussi quatre sous-classes qui redéfinissent cette méthode. Pouvons-nous décider en toute sûreté laquelle des implémentations appeler ici ? Cela ne semble pas être le cas. La seule façon de le savoir avec certitude est de lancer le programme et de vérifier la classe d’un objet passé à la méthode. La seule chose dont nous sommes sûrs est que l’objet disposera d’une implémentation de la méthode draw.
Ainsi, le code machine résultant vérifiera la classe de l’objet passé au paramètre shape et choisira l’implémentation de draw dans la classe appropriée.
Une telle vérification de type dynamique est appelée liaison tardive (ou dynamique) :
- Tardive, parce que nous lions l’objet et son implémentation après la compilation, à l’exécution.
- Dynamique, parce que chaque nouvel objet peut nécessiter d’être lié à une implémentation différente.
Liaison précoce/statique
Maintenant, « compilons » le code suivant :
method exportShape(shape: Shape) is
Exporter exporter = new Exporter()
exporter.export(shape);
Tout est clair pour la deuxième ligne : la classe Exporter n’a pas de constructeur personnalisé, nous instancions donc simplement un objet. Qu’en est-il de l’appel à export ? La classe Exporter possède cinq méthodes portant le même nom qui diffèrent par les types de paramètres. Laquelle appeler ? Il semble que nous allons avoir besoin d’une liaison dynamique ici aussi.
Mais il y a un autre problème. Et s’il existait une classe de forme qui n’a pas de méthode export appropriée dans la classe Exporter ? Par exemple, un objet Ellipse. Le compilateur ne peut pas garantir que la méthode surchargée appropriée existe, contrairement aux méthodes redéfinies. Une situation ambiguë apparaît, qu’un compilateur ne peut pas autoriser.
C’est pourquoi les développeurs de compilateurs empruntent une voie sûre et utilisent la liaison précoce (ou statique) pour les méthodes surchargées :
- Précoce parce qu’elle se produit à la compilation, avant le lancement du programme.
- Statique parce qu’elle ne peut pas être modifiée à l’exécution.
Revenons à notre exemple. Nous sommes certains que l’argument entrant appartiendra à la hiérarchie de Shape : soit la classe Shape, soit l’une de ses sous-classes. Nous savons aussi que la classe Exporter possède une implémentation de base de l’export qui prend en charge la classe Shape : export(s: Shape).
C’est la seule implémentation qui peut être liée en toute sûreté à un code donné sans rendre les choses ambiguës. C’est pourquoi, même si nous passons un objet Rectangle à exportShape, l’exportateur appellera tout de même une méthode export(s: Shape).
Double répartition
La double répartition est une astuce qui permet d’utiliser la liaison dynamique conjointement avec des méthodes surchargées. Voici comment procéder :
class Visitor is
method visit(s: Shape) is
print("Visited shape")
method visit(d: Dot)
print("Visited dot")
interface Graphic is
method accept(v: Visitor)
class Shape implements Graphic is
method accept(v: Visitor)
// Le compilateur sait avec certitude que `this` est un `Shape`.
// Ce qui signifie que `visit(s: Shape)` peut être appelée en toute sûreté.
v.visit(this)
class Dot extends Shape is
method accept(v: Visitor)
// Le compilateur sait que `this` est un `Dot`.
// Ce qui signifie que `visit(s: Dot)` peut être appelée en toute sûreté.
v.visit(this)
Visitor v = new Visitor();
Graphic g = new Dot();
// La méthode `accept` est redéfinie, et non surchargée. Le compilateur la lie
// dynamiquement. Par conséquent, `accept` sera exécutée sur une classe qui
// correspond à l’objet appelant la méthode (dans notre cas, la classe `Dot`).
g.accept(v);
// Sortie : "Visited dot"
Postface
Même si le patron Visiteur repose sur le principe de la double répartition, ce n’est pas son objectif premier. Le Visiteur vous permet d’ajouter des opérations « externes » à toute une hiérarchie de classes sans modifier le code existant de ces classes.