Aller au contenu

Design Pattern Visitor

Ajouter des traitements à un ensemble d’objets sans les modifier

Introduction

Le design pattern Visitor est un pattern de conception comportemental qui permet d'ajouter de nouveaux traitements à un ensemble d'objets, sans avoir à modifier les classes ayant permis de créer ces objets. L'idée consiste à déplacer les traitements dans des objets dédiés appelés visiteurs.

Le design pattern Visitor est intéressant lorsqu'une structure d'objets est relativement stable, mais que les traitements à exécuter sur ces objets sont susceptibles d'évoluer régulièrement.

Problématique

Nous devons développer une application permettant de manipuler un document composé de différents types d'éléments tels que des paragraphes, des images et des tableaux. Nous implémentons alors ces trois classes :

public class Paragraph
{
    public string Text { get; set; }

    public Paragraph(string text)
    {
        Text = text;
    }
}
public class Image
{
    public string Url { get; set; }

    public Image(string url)
    {
        Url = url;
    }
}
public class Table
{
    public int RowCount { get; set; }
    public int ColumnCount { get; set; }

    public Table(int rowCount, int columnCount)
    {
        RowCount = rowCount;
        ColumnCount = columnCount;
    }
}

Nous souhaitons maintenant effectuer plusieurs traitements sur ces objets. Par exemple, exporter les éléments du document au format HTML.

Une première solution consisterait à ajouter une méthode nommée ToHtml() dans chacune des classes. Voici un exemple :

public class Paragraph
{
    public string Text { get; set; }

    public Paragraph(string text)
    {
        Text = text;
    }

    public string ToHtml()
    {
        return $"<p>{Text}</p>";
    }
}

Nous l'implémentons de manière analogue avec les classes Image et Table. Mais s'il nous est demandé que l'application permette aussi d'exporter les documents au format Markdown, alors nous ajoutons une nouvelle méthode ToMarkdown(). Puis, s'il nous est demandé de générer du texte brut et d'autres traitements, alors les classes contiendront de nombreuses méthodes qui ne sont pas directement liées à leur responsabilité principale consistant à représenter le contenu d’un document.

Toute nouvelle exportation nécessite de modifier plusieurs classes existantes. Le design pattern Visitor propose d'inverser cette organisation : les objets conservent leurs données et les traitements sont déplacés dans d'autres classes.

Conception

Dans notre exemple, les rôles du pattern peuvent être représentés ainsi :

Design Pattern Visitor - Conception

Le pattern Visitor repose principalement sur deux familles d'objets.

  • Les éléments visitables, qui représentent les objets constituant le contenu d'un document. Dans notre exemple, il s'agit des classes Paragraph, Image et Table.
  • Les visiteurs, qui représentent les traitements que nous souhaitons effectuer sur ces éléments visitables. Il s'agit des classes HtmlVisitor et MarkdownVisitor.

L'interface IDocumentElement représente les éléments visitables et l'interface IDocumentVisitor représente le contrat que doivent respecter les visiteurs.

Implémentation

Implémenter le visiteur

Un visiteur possède une méthode spécifique pour chaque type d'élément pouvant être visité. L'ajout d'un nouveau traitement consiste alors principalement à créer un nouveau visiteur, sans modifier les classes existantes.

Voici l'interface représentant un visiteur :

public interface IDocumentVisitor
{
    void Visit(Paragraph paragraph);

    void Visit(Image image);

    void Visit(Table table);
}

L'interface contient des surcharges de la méthode Visit pour chaque type d'élément pouvant être rencontré dans un document. Chaque implémentation de cette interface pourra ainsi appliquer un traitement différent selon le type d'objet visité.

Implémenter les éléments visitables

Voici une interface commune aux objets pouvant recevoir un visiteur.

public interface IDocumentElement
{
    void Accept(IDocumentVisitor visitor);
}

Chaque classe représentant un élément du document implémente cette interface :

public class Paragraph : IDocumentElement
{
    public string Text { get; }

    public Paragraph(string text)
    {
        Text = text;
    }

    public void Accept(IDocumentVisitor visitor)
    {
        visitor.Visit(this);
    }
}
public class Image : IDocumentElement
{
    public string Url { get; }

    public Image(string url)
    {
        Url = url;
    }

    public void Accept(IDocumentVisitor visitor)
    {
        visitor.Visit(this);
    }
}
public class Table : IDocumentElement
{
    public int RowCount { get; }
    public int ColumnCount { get; }

    public Table(int rowCount, int columnCount)
    {
        RowCount = rowCount;
        ColumnCount = columnCount;
    }

    public void Accept(IDocumentVisitor visitor)
    {
        visitor.Visit(this);
    }
}

Ces classes ne contiennent désormais aucun traitement lié à l'export du document. Elles prennent en paramètre un visiteur dans leur méthode Accept.

Implémenter un visiteur HTML

Nous pouvons maintenant déplacer la logique d'export HTML dans une classe dédiée :

public class HtmlVisitor : IDocumentVisitor
{
    public void Visit(Paragraph paragraph)
    {
        Console.WriteLine($"<p>{paragraph.Text}</p>");
    }

    public void Visit(Image image)
    {
        Console.WriteLine($"<img src=\"{image.Url}\" />");
    }

    public void Visit(Table table)
    {
        Console.WriteLine(
            $"<table data-rows=\"{table.RowCount}\" " +
            $"data-columns=\"{table.ColumnCount}\"></table>");
    }
}

Le visiteur connaît les différents types d'éléments et sait comment chacun doit être transformé en HTML. Les classes Paragraph, Image et Table ne contiennent aucune logique spécifique au HTML.

Utiliser le visiteur

Créons maintenant un document contenant plusieurs éléments :

IDocumentElement[] elements =
{
    new Paragraph("Introduction au design pattern Visitor"),
    new Image("/images/visitor.png"),
    new Table(5, 3)
};

Créons ensuite notre visiteur :

var visitor = new HtmlVisitor();

Il suffit alors de transmettre ce visiteur aux différents éléments.

foreach (var element in elements)
    element.Accept(visitor);

La méthode Accept de chaque élément appelle la surcharge de la méthode Visit correspondante. Voici le contenu du document obtenu :

<p>Introduction au design pattern Visitor</p>
<img src="/images/visitor.png" />
<table data-rows="5" data-columns="3"></table>

Ajouter un nouveau traitement

L'un des intérêts du design pattern Visitor apparaît lorsque nous souhaitons ajouter un nouveau traitement. Supposons qu'il nous soit demandé d'exporter notre document au format Markdown. Il n'est pas nécessaire de modifier les classes Paragraph, Image ou Table. Nous implémentons un nouveau visiteur :

public class MarkdownVisitor : IDocumentVisitor
{
    public void Visit(Paragraph paragraph)
    {
        Console.WriteLine(paragraph.Text);
    }

    public void Visit(Image image)
    {
        Console.WriteLine($"![]({image.Url})");
    }

    public void Visit(Table table)
    {
        Console.WriteLine($"Tableau de {table.RowCount} lignes et {table.ColumnCount} colonnes");
    }
}

Pour simplifier l’exemple, le tableau est représenté par une description sous forme de texte. Une implémentation complète générerait les lignes et les séparateurs nécessaires à un tableau Markdown.

Nous pouvons ensuite utiliser ce visiteur sur les mêmes objets :

var visitor = new MarkdownVisitor();

foreach (var element in elements)
    element.Accept(visitor);

Nous venons donc d'ajouter un nouvel export sans modifier les classes représentant les éléments de notre document. C'est précisément l'un des objectifs principaux du pattern Visitor.

Le double dispatch

Le fonctionnement du pattern Visitor repose sur un mécanisme généralement appelé double dispatch. Si nous écrivons :

IDocumentElement element = new Paragraph("Design Pattern Visitor");

element.Accept(visitor);

L'objet element est de type IDocumentElement. Grâce au polymorphisme, la méthode Accept de la classe Paragraph est exécutée.

public void Accept(IDocumentVisitor visitor)
{
    visitor.Visit(this);
}

Dans cette méthode, this étant de type Paragraph, cette surcharge est exécutée :

Visit(Paragraph paragraph)

Deux résolutions interviennent donc successivement :

  1. Le type réel de l’élément détermine l’implémentation de la méthode Accept qui sera exécutée.
  2. Dans cette implémentation, le type concret de l’élément détermine la surcharge de la méthode Visit qui sera exécutée.

Cette collaboration entre l'élément et le visiteur est fondamentale dans le design pattern Visitor.

Pourquoi ne pas simplement utiliser un switch ?

Une autre solution consisterait à utiliser du pattern matching :

foreach (var element in elements)
{
    switch (element)
    {
        case Paragraph paragraph:
            Console.WriteLine($"<p>{paragraph.Text}</p>");
            break;

        case Image image:
            Console.WriteLine($"<img src=\"{image.Url}\" />");
            break;

        case Table table:
            Console.WriteLine(
                $"Tableau {table.RowCount} x {table.ColumnCount}");
            break;
    }
}

Pour quelques classes avec des traitements simples, cette approche peut convenir. Le design pattern Visitor devient intéressant lorsque les traitements sont nombreux, suffisamment complexes pour être implémentés dans leurs propres classes. Le code relatif à l'export HTML se trouve dans la classe HtmlVisitor, celui relatif au Markdown dans la classe MarkdownVisitor.

Design Pattern Visitor et principe Open/Closed

Le design pattern Visitor entretient une relation particulière avec le principe SOLID Open/Closed. Lorsque nous souhaitons ajouter un nouvel export, notre implémentation est facilement extensible.

public class PdfVisitor : IDocumentVisitor
{
    // Implémentation des surcharge de la méthode Visit ...
}

sans modifier les éléments existants. En revanche, ajouter un nouveau type d'élément est plus contraignant. Voici une classe représentant une vidéo :

public class Video : IDocumentElement
{
    public string Url { get; }

    public Video(string url)
    {
        Url = url;
    }

    public void Accept(IDocumentVisitor visitor)
    {
        visitor.Visit(this);
    }
}

Il faut également ajouter cette méthode dans l'interface IDocumentVisitor :

void Visit(Video video);

Et toutes les classes implémentant l'interface IDocumentVisitor doivent alors être adaptées : HtmlVisitor et MarkdownVisitor. C'est une caractéristique essentielle du pattern Visitor. Ainsi, ajouter de nouvelles opérations est facile, mais ajouter de nouveaux types d'éléments est plus coûteux. Il convient donc particulièrement bien aux structures dont les types d'objets évoluent peu, mais sur lesquelles de nouveaux traitements sont régulièrement ajoutés ou modifiés.

Design patterns Visitor et Strategy

Les patterns Visitor et Strategy permettent tous les deux d'externaliser des comportements, mais ils répondent à des problématiques différentes.

Avec le design pattern Strategy, un objet délègue généralement une partie précise de son comportement à une stratégie interchangeable.

Avec le design pattern Visitor, un même traitement doit pouvoir s'appliquer à plusieurs types d'objets appartenant à une structure.

Une stratégie répond donc davantage à la question « Quelle implémentation d'un comportement devons-nous utiliser ? », alors qu'un visiteur répond plutôt à « Quel traitement devons-nous appliquer à chacun des différents types d'objets de cette structure ? »

Design patterns Visitor et Iterator

Le pattern Iterator permet de parcourir une collection ou une structure sans exposer sa représentation interne.

Le pattern Visitor permet d'exécuter un traitement sur les différents types d'objets rencontrés.

Les deux patterns peuvent être utilisés ensemble. Un Iterator peut parcourir la structure tandis qu'un Visitor définit l'opération appliquée à chaque élément rencontré.