Design Patterns Comportementaux en Java, communication (partie 3/3) : Iterator et Visitor

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
2 min de lecture
5 vues

Écouter l'article

Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.


Cette dernière partie traite deux patterns de parcours et d'opérations. Iterator est intégré au langage Java (la boucle for-each). Visitor reste un outil à choisir en connaissance de cause, avec une alternative moderne à connaître : une hiérarchie sealed combinée à un switch avec pattern matching. Elle se termine par un scénario de commande qui réunit plusieurs patterns de la série, puis par un bilan des 17 patterns présentés.

Le code est illustratif et simplifié : les classes non définies (Category, Product, Order, OrderRepository, OrderRequest…) sont supposées exister. Les noms suivent les conventions Java et le rôle du pattern en suffixe, comme dans le reste de la série ; les diagrammes UML reprennent les noms du code.. Les exemples ont été compilés et exécutés avec le JDK 21 et Spring Boot 3.5 ; les versions minimales de Java sont indiquées quand une fonctionnalité en dépend.


Iterator et Visitor : parcourir et ajouter des opérations

Iterator : le pattern devenu invisible

Iterator est intégré au langage Java. Chaque boucle for-each sur un Iterable utilise un Iterator :

// Quand on écrit :
for (Product product : products) {
    System.out.println(product.getName());
}

// Le compilateur génère l'équivalent de :
Iterator<Product> it = products.iterator();
while (it.hasNext()) {
    Product product = it.next();
    System.out.println(product.getName());
}

L'interface est dans le JDK depuis Java 1.2 (extrait simplifié) :

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove() { throw new UnsupportedOperationException(); }
    default void forEachRemaining(Consumer<? super E> action) { /* ... */ }
}

Un Iterator personnalisé pour un arbre de catégories :

public class CategoryTreeIterator implements Iterator<Category> {

    private final Deque<Category> stack = new ArrayDeque<>();

    public CategoryTreeIterator(Category root) {
        if (root != null) {
            stack.push(root);
        }
    }

    @Override
    public boolean hasNext() {
        return !stack.isEmpty();
    }

    @Override
    public Category next() {
        if (!hasNext()) throw new NoSuchElementException();

        Category current = stack.pop();

        // Ajoute les enfants en ordre inverse pour un parcours en profondeur
        List<Category> children = current.getChildren();
        for (int i = children.size() - 1; i >= 0; i--) {
            stack.push(children.get(i));
        }

        return current;
    }
}

// La collection qui implémente Iterable
public class CategoryTree implements Iterable<Category> {

    private final Category root;

    public CategoryTree(Category root) {
        this.root = root;
    }

    @Override
    public Iterator<Category> iterator() {
        return new CategoryTreeIterator(root);
    }
}

// Utilisation naturelle
CategoryTree tree = new CategoryTree(rootCategory);
for (Category category : tree) {
    System.out.println(category.getName());
}

// Ou avec l'API Stream
StreamSupport.stream(tree.spliterator(), false)
    .filter(c -> c.getLevel() > 2)
    .map(Category::getName)
    .forEach(System.out::println);

Visitor : double dispatch et opérations sans modification

Le Visitor permet d'ajouter des opérations à une hiérarchie de classes sans modifier ces classes. Il est souvent jugé peu intuitif, mais il sert pour l'analyse et la transformation d'arbres.

Contrairement à Iterator, Java ne l'a pas intégré au langage. Il faut l'écrire soi-même, et il impose un compromis qu'il vaut mieux connaître avant de l'adopter.

// Hiérarchie de nœuds de document
public interface DocumentNode {
    void accept(DocumentVisitor visitor);
}

public class ParagraphNode implements DocumentNode {
    private final String text;

    public ParagraphNode(String text) { this.text = text; }
    public String getText() { return text; }

    @Override
    public void accept(DocumentVisitor visitor) {
        visitor.visit(this); // Double dispatch
    }
}

public class ImageNode implements DocumentNode {
    private final String url;
    private final String alt;

    public ImageNode(String url, String alt) { this.url = url; this.alt = alt; }
    public String getUrl() { return url; }
    public String getAlt() { return alt; }

    @Override
    public void accept(DocumentVisitor visitor) {
        visitor.visit(this);
    }
}

public class TableNode implements DocumentNode {
    private final List<List<String>> rows;

    public TableNode(List<List<String>> rows) { this.rows = rows; }
    public List<List<String>> getRows() { return rows; }

    @Override
    public void accept(DocumentVisitor visitor) {
        visitor.visit(this);
    }
}

// L'interface Visitor : une nouvelle opération = un nouveau Visitor
public interface DocumentVisitor {
    void visit(ParagraphNode node);
    void visit(ImageNode node);
    void visit(TableNode node);
}

Le « double dispatch » se joue en deux temps. node.accept(visitor) est résolu à l'exécution selon le type réel du nœud (polymorphisme classique), puis l'appel visitor.visit(this) est résolu à la compilation selon le type statique de this, ce qui sélectionne la bonne surcharge.

Ajout d'opérations sans modifier les nœuds :

// Export HTML
public class HtmlExportVisitor implements DocumentVisitor {

    private final StringBuilder html = new StringBuilder();

    @Override
    public void visit(ParagraphNode node) {
        html.append("<p>").append(escapeHtml(node.getText())).append("</p>\n");
    }

    @Override
    public void visit(ImageNode node) {
        html.append(String.format("<img src=\"%s\" alt=\"%s\" />\n",
            safeUrl(node.getUrl()), escapeHtml(node.getAlt())));
    }

    @Override
    public void visit(TableNode node) {
        html.append("<table>\n");
        for (List<String> row : node.getRows()) {
            html.append("  <tr>");
            for (String cell : row) {
                html.append("<td>").append(escapeHtml(cell)).append("</td>");
            }
            html.append("</tr>\n");
        }
        html.append("</table>\n");
    }

    public String getHtml() { return html.toString(); }

    // Toute donnée insérée dans du HTML doit être échappée, sinon injection XSS
    private static String escapeHtml(String text) {
        if (text == null) return "";
        return text.replace("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;")
                   .replace("\"", "&quot;")
                   .replace("'", "&#39;");
    }

    // Liste blanche de schémas : refuse javascript:, data:, etc.
    private static String safeUrl(String url) {
        if (url != null
                && (url.startsWith("https://") || url.startsWith("http://")
                    || (url.startsWith("/") && !url.startsWith("//")))) {
            return escapeHtml(url);
        }
        return "";
    }
}
// Compteur de mots : nouvelle opération, aucune modification des nœuds
public class WordCountVisitor implements DocumentVisitor {

    private int totalWords = 0;

    @Override
    public void visit(ParagraphNode node) {
        totalWords += countWords(node.getText());
    }

    @Override
    public void visit(ImageNode node) {
        totalWords += countWords(node.getAlt());
    }

    @Override
    public void visit(TableNode node) {
        for (List<String> row : node.getRows()) {
            for (String cell : row) {
                totalWords += countWords(cell);
            }
        }
    }

    // "".split("\\s+") retourne un tableau d'un élément : le vide se traite à part
    private static int countWords(String text) {
        if (text == null || text.isBlank()) return 0;
        return text.trim().split("\\s+").length;
    }

    public int getTotalWords() { return totalWords; }
}

Utilisation :

List<DocumentNode> document = List.of(
    new ParagraphNode("Les design patterns donnent un vocabulaire commun."),
    new ImageNode("/img/uml.png", "Diagramme UML"),
    new TableNode(List.of(
        List.of("Pattern", "Type"),
        List.of("Command", "Comportemental")
    ))
);

// Export HTML
HtmlExportVisitor htmlVisitor = new HtmlExportVisitor();
document.forEach(node -> node.accept(htmlVisitor));
System.out.println(htmlVisitor.getHtml());

// Comptage de mots
WordCountVisitor wordCounter = new WordCountVisitor();
document.forEach(node -> node.accept(wordCounter));
System.out.println("Mots : " + wordCounter.getTotalWords());  // 11

Visitor est à la base de nombreux outils : compilateurs (visiteurs d'AST), analyseurs statiques, et java.nio.file.FileVisitor pour parcourir les arborescences de fichiers.

Le compromis du Visitor. Ajouter une opération est facile : une nouvelle classe, sans toucher aux nœuds. Ajouter un type de nœud est coûteux : il faut ajouter une méthode visit à l'interface, donc modifier chaque visiteur existant. Le pattern convient donc aux hiérarchies stables dont on multiplie les opérations.

L'alternative moderne : sealed et le switch avec pattern matching. Les record (Java 16) et les types sealed (Java 17) existent avant Java 21, mais le switch avec pattern matching sur type n'est définitif qu'en Java 21. Depuis cette version, une hiérarchie scellée de record combinée à un switch exhaustif exprime la même chose avec bien moins de cérémonie (ces types remplacent la hiérarchie précédente, ils ne s'y ajoutent pas) :

public sealed interface DocumentNode permits ParagraphNode, ImageNode, TableNode {}

public record ParagraphNode(String text) implements DocumentNode {}
public record ImageNode(String url, String alt) implements DocumentNode {}
public record TableNode(List<List<String>> rows) implements DocumentNode {}

public final class DocumentOps {

    private DocumentOps() {}

    public static int wordCount(DocumentNode node) {
        return switch (node) {
            case ParagraphNode p -> countWords(p.text());
            case ImageNode i -> countWords(i.alt());
            case TableNode t -> t.rows().stream()
                .flatMap(List::stream)
                .mapToInt(DocumentOps::countWords)
                .sum();
        };
    }

    private static int countWords(String text) {
        return (text == null || text.isBlank()) ? 0 : text.trim().split("\\s+").length;
    }
}

Le compilateur vérifie que le switch couvre tous les sous-types : ajouter un nouveau nœud fait échouer la compilation à chaque endroit à compléter. On retrouve la même contrainte que le Visitor, sans accept() ni double dispatch. Ajouter un type de nœud casse donc autant les visiteurs que les switch. Le Visitor garde son intérêt quand on cible une version de Java antérieure à 21, ou quand l'API est consommée par du code tiers qui fournit ses propres opérations (comme FileVisitor).


Les patterns comportementaux en synergie finale

Les patterns d'orchestration (Strategy et Observer, Template Method et State) et ceux de communication se combinent dans un scénario de commande e-commerce :

// Système de gestion de commandes e-commerce
//
// Strategy      -> Calcul du prix selon le type de client (partie orchestration)
// Observer      -> Notification des changements de commande (partie orchestration)
// Template      -> Processus standard d'import de produits (partie orchestration)
// State         -> Cycle de vie de la commande (partie orchestration)
// Command       -> Historique des actions admin avec undo/redo
// Chain of Resp -> Pipeline de contrôle des requêtes API (en amont, au niveau de la passerelle)
// Mediator      -> Coordination du traitement de commande

@Service
public class OrderPlacementService {

    private final PricingService pricingService;           // Strategy
    private final CommandHistoryRegistry histories;         // Command
    private final OrderProcessingCoordinator orchestrator;           // Mediator
    private final OrderRepository orderRepository;

    public OrderPlacementService(PricingService pricingService,
                                 CommandHistoryRegistry histories,
                                 OrderProcessingCoordinator orchestrator,
                                 OrderRepository orderRepository) {
        this.pricingService = pricingService;
        this.histories = histories;
        this.orchestrator = orchestrator;
        this.orderRepository = orderRepository;
    }

    @Transactional
    public OrderResult place(String username, OrderRequest request, Customer customer) {
        // 1. Strategy : calcul du meilleur prix applicable
        PriceResult price = pricingService.calculatePrice(request.product(), customer);

        // 2. Command : création annulable, dans l'historique de l'utilisateur
        // (CreateOrderCommand suit le modèle de CreateArticleCommand)
        CreateOrderCommand command = new CreateOrderCommand(orderRepository, request, price);
        CommandResult created = histories.forUser(username).executeCommand(command);
        if (!created.success()) {
            return OrderResult.failed(created.message());
        }

        // 3. Mediator : coordination stock, paiement, expédition, notifications.
        // La commande est créée à l'état initial ; les transitions suivantes
        // (confirmation, expédition, livraison, annulation) relèvent du State.
        Order order = orderRepository.findById(command.getCreatedOrderId()).orElseThrow();
        return orchestrator.processOrder(order);
    }
}

Les contrôles HTTP (rate limiting, authentification, autorisation, validation) interviennent en amont, via la chaîne de filtres vue plus haut. Les événements applicatifs de l'article précédent (Observer) permettent ensuite de brancher le stock, les e-mails et la facturation sans toucher à ce service.


Ce qu'il faut retenir

Pattern Quand l'utiliser Exemples JDK / Spring
Iterator Parcours uniforme de collections Iterable, Iterator, Spliterator
Visitor Opérations sur une hiérarchie stable, sans la modifier FileVisitor, visiteurs d'AST ; alternative : sealed + switch

Bilan de la série

Cette série de neuf articles couvre 17 design patterns du Gang of Four.

Créationnels (4) : Singleton, Factory Method, Abstract Factory, Builder. Ils décrivent comment créer des objets.

Structurels (4) : Adapter, Decorator, Facade, Proxy. Ils décrivent comment assembler des objets pour obtenir de nouvelles fonctionnalités.

Comportementaux, orchestration (4) : Strategy, Observer, Template Method, State. Ils décrivent comment les objets se comportent et changent de comportement.

Comportementaux, communication (5) : Command, Chain of Responsibility, Mediator, Iterator, Visitor. Ils décrivent comment les objets communiquent et collaborent.

Le catalogue du GoF compte 23 patterns. L'objectif n'est pas de tous connaître par cœur, mais de reconnaître les problèmes qu'ils résolvent dans le code courant : un switch sur un type qui grandit sans cesse renvoie à Strategy ou à State, de la duplication dans un processus à Template Method, un couplage fort entre services à Observer ou à Mediator.

Les patterns ne s'appliquent pas mécaniquement : ce sont des noms donnés à des solutions que les développeurs rencontrent régulièrement, ce qui facilite la discussion dans une équipe.


Partie précédente : Chain of Responsibility et Mediator.

Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.