Visitor & State
Ajouter des opérations à une hiérarchie sans la modifier (famille Visitor), et modéliser proprement les machines à états (State).
Cette dernière partie du livre s'ouvre sur une étude de cas réelle : le système d'examen automatisé conçu par l'Educational Testing Service (ETS) pour la certification des architectes aux États-Unis et au Canada. Comme dans le reste de l'ouvrage, Martin ne déroule pas un catalogue : il laisse les patterns émerger du code, au fil des besoins du projet. Deux familles de patrons s'imposent d'emblée pour ce genre de système. La première, la famille Visitor, répond à un besoin récurrent : ajouter des comportements à une hiérarchie de classes sans la toucher. La seconde, State, donne une forme propre aux machines à états finis (finite state machines, FSM), qui pilotent aussi bien une interface graphique qu'un protocole réseau. Nous transposons ici les exemples Java du livre en TypeScript idiomatique.
Le problème que résout Visitor
Le point de départ est un problème courant : il faut ajouter une nouvelle méthode à une hiérarchie de classes, mais l'ajout serait douloureux ou nuisible à la conception. Imaginons une hiérarchie de modems (Modem). La classe de base déclare les opérations génériques communes à tous les modems ; les dérivées représentent les pilotes des différents fabricants : un modem Hayes, un Zoom, et la carte modem bricolée par Ernie, l'un des ingénieurs matériel. Survient une exigence : ajouter une méthode configureForUnix qui paramètre chaque modem pour le système UNIX. Le code en sera différent dans chaque dérivée, car chaque modem a ses idiosyncrasies.
Le piège, c'est que cet ajout appelle aussitôt une cascade de questions. Et Windows ? Et MacOS ? Et Linux ? Faut-il vraiment ajouter une méthode à la hiérarchie Modem pour chaque nouveau système d'exploitation ? On ne refermera jamais l'interface Modem : à chaque nouvel OS, il faudra la modifier et redéployer tout le logiciel des modems. C'est exactement la violation du principe ouvert/fermé (Open-Closed Principle, OCP) que l'on cherche à éviter.
La famille Visitor permet précisément d'ajouter de nouvelles méthodes à des hiérarchies existantes sans les modifier. Elle réunit quatre patrons : Visitor, Acyclic Visitor, Decorator et Extension Object.
Visitor et la double répartition
Le cœur du pattern Visitor est une technique appelée double répartition (double dispatch, ou dual dispatch). On crée une seconde hiérarchie, parallèle à la première : la hiérarchie du visiteur. La hiérarchie visitée (Modem) reçoit une unique méthode accept, et le visiteur (ModemVisitor) déclare une méthode visit par dérivée de la hiérarchie visitée. C'est une sorte de rotation à 90° : ce qui était des dérivées devient des méthodes.
// La hiérarchie visitée : une seule nouvelle méthode, accept.
interface Modem {
dial(pno: string): void;
hangup(): void;
send(c: string): void;
recv(): string;
accept(v: ModemVisitor): void;
}
// Le visiteur : une méthode visit par dérivée de Modem.
interface ModemVisitor {
visitHayes(modem: HayesModem): void;
visitZoom(modem: ZoomModem): void;
visitErnie(modem: ErnieModem): void;
}
class HayesModem implements Modem {
configurationString = "";
dial(pno: string): void {}
hangup(): void {}
send(c: string): void {}
recv(): string { return ""; }
// Chaque dérivée appelle la bonne surcharge de visit.
accept(v: ModemVisitor): void { v.visitHayes(this); }
}
class ZoomModem implements Modem {
configurationValue = 0;
dial(pno: string): void {}
hangup(): void {}
send(c: string): void {}
recv(): string { return ""; }
accept(v: ModemVisitor): void { v.visitZoom(this); }
}
class ErnieModem implements Modem {
internalPattern = "";
dial(pno: string): void {}
hangup(): void {}
send(c: string): void {}
recv(): string { return ""; }
accept(v: ModemVisitor): void { v.visitErnie(this); }
} La nouvelle opération — configurer pour UNIX — vit désormais dans une dérivée du visiteur, et pas du tout dans la hiérarchie Modem :
// La nouvelle opération, hors de la hiérarchie Modem.
class UnixModemConfigurator implements ModemVisitor {
visitHayes(m: HayesModem): void {
m.configurationString = "&s1=4&D=3";
}
visitZoom(m: ZoomModem): void {
m.configurationValue = 42;
}
visitErnie(m: ErnieModem): void {
m.internalPattern = "C is too slow";
}
}
// Côté appelant : on crée le configurateur et on le passe au modem.
const m: Modem = new HayesModem();
m.accept(new UnixModemConfigurator()); À retenir
La double répartition met en jeu deux répartitions polymorphes successives. La première, l'appel à accept, résout le type concret de l'objet visité (est-ce un Hayes, un Zoom ou un Ernie ?). La seconde, l'appel à visit, résout la fonction précise à exécuter. Ces deux répartitions, combinées, donnent au Visitor une exécution très rapide — et permettent au compilateur de garantir qu'aucune dérivée n'a été oubliée.
Une fois cette structure en place, on ajoute de nouvelles opérations (configurer pour Windows, pour MacOS…) simplement en créant de nouvelles dérivées de ModemVisitor, sans jamais toucher à la hiérarchie Modem. Le Visitor substitue donc des dérivées du visiteur à des méthodes de la hiérarchie visitée.
Visitor est une matrice
Les deux répartitions du Visitor forment une matrice de fonctions. Dans l'exemple des modems, un axe représente les types de modems, l'autre les systèmes d'exploitation. Chaque case de la matrice est remplie par une fonction décrivant comment initialiser tel modem pour tel OS. C'est cette image qui éclaire à la fois la puissance et la limite du pattern.
Acyclic Visitor : briser le cycle de dépendances
Le Visitor classique a un défaut structurel. La base de la hiérarchie visitée (Modem) dépend de la base du visiteur (ModemVisitor), qui elle-même possède une méthode pour chaque dérivée visitée. Il existe donc un cycle de dépendances qui lie toutes les dérivées de modems entre elles. Conséquence : il est très difficile de compiler la structure de façon incrémentale ou d'ajouter de nouvelles dérivées à la hiérarchie visitée.
Tant que la hiérarchie visitée est stable, ce n'est pas un problème : si Hayes, Zoom et Ernie sont les seuls modems prévisibles, le Visitor convient parfaitement. Mais si la hiérarchie est volatile, avec de nombreuses dérivées à venir, la base du visiteur devra être modifiée et recompilée — avec toutes ses dérivées — à chaque nouveau modem.
La variante Acyclic Visitor brise ce cycle en rendant la classe de base du visiteur dégénérée : une interface sans aucune méthode (un marqueur). N'ayant plus de méthode, elle ne dépend plus des dérivées de la hiérarchie visitée. On déclare en revanche une interface de visiteur par dérivée visitée — une rotation à 180° des dérivées vers les interfaces. Les méthodes accept tentent alors une conversion (cast) de la base du visiteur vers l'interface appropriée ; si elle réussit, elles invoquent le bon visit.
// La base du visiteur est dégénérée : aucune méthode.
interface ModemVisitor {}
// Une interface de visiteur par dérivée visitée.
interface HayesVisitor extends ModemVisitor {
visitHayes(m: HayesModem): void;
}
interface ZoomVisitor extends ModemVisitor {
visitZoom(m: ZoomModem): void;
}
class HayesModem implements Modem {
configurationString = "";
dial(pno: string): void {}
hangup(): void {}
send(c: string): void {}
recv(): string { return ""; }
// accept tente la conversion vers l'interface du Hayes.
accept(v: ModemVisitor): void {
const hv = v as Partial<HayesVisitor>;
if (typeof hv.visitHayes === "function") {
hv.visitHayes(this);
}
}
} Note
Là où le Visitor remplit une matrice dense, l'Acyclic Visitor crée une matrice creuse : un visiteur n'a pas à implémenter de méthode pour chaque dérivée visitée. Si les modems Ernie ne peuvent pas être configurés pour UNIX, le UnixModemConfigurator n'implémente tout simplement pas ErnieVisitor. Cette capacité à ignorer certaines combinaisons est parfois un avantage précieux.
Le revers : la solution devient nettement plus complexe, et le coût de la conversion dépend de la largeur et de la profondeur de la hiérarchie visitée, donc difficile à prévoir. Pour les systèmes temps réel durs, cette imprévisibilité peut disqualifier l'Acyclic Visitor. Mais pour les hiérarchies volatiles où la compilation incrémentale compte, c'est une bonne option.
Visitor pour les générateurs de rapports
L'usage le plus fréquent du Visitor consiste à parcourir une grande structure de données pour produire des rapports. Cela évite que les objets de la structure contiennent du code de génération de rapport. De nouveaux rapports s'ajoutent en créant de nouveaux visiteurs, jamais en modifiant la structure — et chaque rapport peut vivre dans un composant séparé, déployé uniquement chez les clients qui en ont besoin.
Prenons une nomenclature (bill of materials) : un assemblage (Assembly) contient des pièces, qui sont soit des pièces élémentaires (PiecePart), soit d'autres assemblages. On pourrait générer le coût total éclaté ou le décompte des pièces. La tentation serait d'ajouter getExplodedCost et getPieceCount directement dans Part. Mais le principe de responsabilité unique (Single-Responsibility Principle, SRP) nous l'interdit : la hiérarchie Part change quand de nouveaux types de pièces apparaissent, pas quand de nouveaux types de rapports sont demandés. Il faut séparer les rapports de la structure.
interface Part {
getPartNumber(): string;
getDescription(): string;
accept(v: PartVisitor): void;
}
interface PartVisitor {
visitPiecePart(pp: PiecePart): void;
visitAssembly(a: Assembly): void;
}
class PiecePart implements Part {
constructor(
private partNumber: string,
private description: string,
private cost: number,
) {}
getPartNumber(): string { return this.partNumber; }
getDescription(): string { return this.description; }
getCost(): number { return this.cost; }
accept(v: PartVisitor): void { v.visitPiecePart(this); }
}
class Assembly implements Part {
private parts: Part[] = [];
constructor(private partNumber: string, private description: string) {}
add(part: Part): void { this.parts.push(part); }
getParts(): readonly Part[] { return this.parts; }
getPartNumber(): string { return this.partNumber; }
getDescription(): string { return this.description; }
// accept visite ce nœud, puis propage la visite à l'arbre.
accept(v: PartVisitor): void {
v.visitAssembly(this);
for (const p of this.parts) p.accept(v);
}
} Chaque rapport devient un visiteur qui accumule ses statistiques au fil du parcours, puis qu'on interroge à la fin :
// Un rapport : le coût total éclaté.
class ExplodedCostVisitor implements PartVisitor {
private total = 0;
cost(): number { return this.total; }
visitPiecePart(p: PiecePart): void { this.total += p.getCost(); }
visitAssembly(a: Assembly): void {} // rien à cumuler ici
}
// Un autre rapport : le décompte des pièces, sans toucher à Part.
class PartCountVisitor implements PartVisitor {
private pieceCount = 0;
private counts = new Map<string, number>();
visitPiecePart(p: PiecePart): void {
this.pieceCount++;
const n = this.counts.get(p.getPartNumber()) ?? 0;
this.counts.set(p.getPartNumber(), n + 1);
}
visitAssembly(a: Assembly): void {}
getPieceCount(): number { return this.pieceCount; }
getCountForPart(pn: string): number { return this.counts.get(pn) ?? 0; }
}
const v = new ExplodedCostVisitor();
cellphone.accept(v);
console.log(v.cost()); On peut ainsi créer un nombre illimité de rapports sans jamais affecter la hiérarchie Part, chacun compilé et distribué indépendamment. Plus généralement, le Visitor s'applique partout où une structure de données doit être interprétée de multiples façons : les compilateurs construisent des arbres intermédiaires qu'un visiteur transforme en code, en référence croisée ou en diagramme UML ; les sous-systèmes d'une application peuvent s'initialiser en parcourant une structure de configuration avec leur propre visiteur.
Les apparentés : Decorator et Extension Object
Le Decorator atteint le même but — ajouter un comportement sans modifier la hiérarchie — par une autre voie. Reprenons les modems : certains utilisateurs veulent entendre la composition, d'autres non. On pourrait disséminer dans tout le code des if (user.wantsLoudDial()), ou bien placer un drapeau dans chaque modem — mais il faudrait alors le dupliquer dans chaque dérivée. Pourquoi Modem devrait-il connaître les caprices de l'utilisateur ? Le principe de fermeture commune (Common-Closure Principle, CCP) et le SRP nous poussent à séparer ce qui change pour des raisons différentes.
Le Decorator crée une classe LoudDialModem qui implémente Modem, délègue à un modem contenu, et n'intercepte que dial pour monter le volume avant de déléguer.
class LoudDialModem implements Modem {
constructor(private modem: Modem) {}
// Seule méthode décorée : on monte le volume avant de déléguer.
dial(pno: string): void {
this.modem.setSpeakerVolume(10);
this.modem.dial(pno);
}
// Tout le reste délègue tel quel.
hangup(): void { this.modem.hangup(); }
send(c: string): void { this.modem.send(c); }
recv(): string { return this.modem.recv(); }
setSpeakerVolume(v: number): void { this.modem.setSpeakerVolume(v); }
getSpeakerVolume(): number { return this.modem.getSpeakerVolume(); }
} La décision « composer fort » se prend désormais en un seul endroit : là où l'utilisateur règle ses préférences. Si plusieurs décorateurs coexistent pour la même hiérarchie (par exemple un LogoutExitModem), on factorise tout le code de délégation dans une classe ModemDecorator dont les décorateurs concrets héritent, ne redéfinissant que les méthodes qui les concernent.
L'Extension Object est plus complexe mais aussi plus puissant. Chaque objet de la hiérarchie maintient une liste d'objets d'extension consultables par nom. Supposons qu'on veuille produire une représentation XML et CSV de chaque objet d'une nomenclature. Un Visitor regrouperait tout le code XML dans une seule classe ; or on veut peut-être séparer la génération XML de chaque type d'objet dans sa propre classe. L'Extension Object le permet : getExtension("XML") rend l'extension XML, getExtension("CSV") la CSV.
// Une extension est un objet attaché, recherché par nom.
interface PartExtension {}
interface XMLPartExtension extends PartExtension {
getXMLElement(): XmlElement;
}
abstract class Part {
private extensions = new Map<string, PartExtension>();
abstract getPartNumber(): string;
addExtension(type: string, ext: PartExtension): void {
this.extensions.set(type, ext);
}
// Un nom inconnu rend une extension neutre, jamais null.
getExtension(type: string): PartExtension {
return this.extensions.get(type) ?? new BadPartExtension();
}
}
class BadPartExtension implements PartExtension {} Les extensions sont chargées dans le constructeur de chaque objet, ce qui maintient une dépendance ténue de la nomenclature vers les classes XML et CSV ; si même ce lien doit disparaître, une fabrique (Factory) peut créer les objets et y injecter leurs extensions. Martin insiste : il n'a pas écrit ce code d'un bloc, mais l'a fait émerger test après test, du plus simple cas au plus complexe, en gardant le pattern Extension Object comme boussole.
Piège courant
Les patrons de la famille Visitor sont séduisants — et c'est précisément le danger. Ils maintiennent l'OCP, séparent les responsabilités (SRP, CCP) et désencombrent les classes ; le LSP et le DIP s'appliquent eux aussi à la structure de cette famille, qui repose sur la substituabilité des dérivées et sur la dépendance envers des abstractions. Mais il est facile de s'emballer. Gardez un scepticisme sain : ce qu'un Visitor résout peut souvent l'être par quelque chose de plus simple. Le Visitor fige par ailleurs la hiérarchie visitée : il excelle quand celle-ci est stable, beaucoup moins quand elle change sans cesse.
State : les machines à états finis
Les automates à états finis comptent parmi les abstractions les plus utiles de l'arsenal logiciel. Ils offrent une manière simple et élégante d'explorer et de définir le comportement d'un système complexe, et une stratégie d'implémentation facile à comprendre comme à modifier. Martin les emploie à tous les niveaux d'un système, de l'interface graphique de haut niveau jusqu'aux protocoles de communication les plus bas.
L'exemple canonique est le tourniquet (turnstile) du métro. Sa machine à états se décrit par un diagramme de transition d'état (state transition diagram, STD) : des bulles (les états), reliées par des flèches (les transitions) étiquetées d'un événement suivi d'une action.
coin / unlock
┌──────────────────────────────►
┌─────────┐ ┌──────────┐
│ Locked │ │ Unlocked │
└─────────┘ └──────────┘
◄──────────────────────────────┘
pass / lock Cela se lit ainsi : dans l'état Locked, un événement coin fait transiter vers Unlocked en invoquant l'action unlock ; dans l'état Unlocked, un événement pass fait transiter vers Locked en invoquant lock. On peut réduire ces phrases à une table de transition d'état (state transition table, STT) : chaque ligne donne l'état de départ, l'événement, l'état d'arrivée et l'action.
| État courant | Événement | Nouvel état | Action |
|---|---|---|---|
| Locked | coin | Unlocked | unlock |
| Locked | pass | Locked | alarm |
| Unlocked | coin | Unlocked | thankyou |
| Unlocked | pass | Locked | lock |
Astuce
Un bénéfice majeur du STD et de la STT est qu'ils révèlent immédiatement les conditions non traitées. Dans la version naïve à deux transitions, rien ne dit quoi faire d'un coin en état Unlocked, ni d'un pass en état Locked. Ces omissions sont des failles logiques courantes : les programmeurs raisonnent davantage sur le cours normal des événements que sur les cas anormaux. On complète donc la FSM : un coin supplémentaire en Unlocked allume un petit « merci » (thankyou), et un pass forcé en Locked déclenche une alarme (alarm).
Trois façons d'implémenter une FSM
Il existe plusieurs stratégies pour coder une machine à états. Le livre en compare trois, dont voici la synthèse.
| Implémentation | Forces | Faiblesses |
|---|---|---|
| switch/case imbriqués | Élégant et efficace pour les petites FSM ; tout tient sur une ou deux pages. | Devient ingérable au-delà de quelques dizaines d'états ; pas de repères de lecture ; mauvaise séparation entre logique et actions. |
| Table de transition interprétée | La logique tient en un seul endroit, lisible comme une STT canonique ; ajout d'une transition trivial ; modifiable à l'exécution ; plusieurs tables possibles. | Lenteur (parcours de la table) ; volume de code de support important. |
| Pattern State | Combine l'efficacité du switch et la flexibilité de la table ; séparation forte entre actions et logique. | Écriture des dérivées d'état fastidieuse ; logique distribuée, sans vue d'ensemble. |
Switch/case imbriqués
L'implémentation la plus directe découpe le code en quatre zones mutuellement exclusives, une par transition.
interface TurnstileController {
lock(): void;
unlock(): void;
thankyou(): void;
alarm(): void;
}
const LOCKED = 0;
const UNLOCKED = 1;
const COIN = 0;
const PASS = 1;
class Turnstile {
state = LOCKED; // volontairement accessible aux tests
constructor(private controller: TurnstileController) {}
event(event: number): void {
switch (this.state) {
case LOCKED:
switch (event) {
case COIN: this.state = UNLOCKED; this.controller.unlock(); break;
case PASS: this.controller.alarm(); break;
}
break;
case UNLOCKED:
switch (event) {
case COIN: this.controller.thankyou(); break;
case PASS: this.state = LOCKED; this.controller.lock(); break;
}
break;
}
}
} Deux remarques de conception, qui n'ont rien à voir avec le switch lui-même, méritent attention. D'abord, l'interface TurnstileController n'existe que pour permettre au test de vérifier que les bonnes actions sont invoquées, dans le bon ordre. Sans elle, valider la machine serait bien plus difficile. C'est un exemple de l'impact des tests sur la conception : la nécessité de tester chaque unité en isolation force à découpler le code de façons qu'on n'aurait pas imaginées. La testabilité est une force qui pousse la conception vers moins de couplage.
Ensuite, la variable state doit rester accessible au test, qui force la machine dans un état donné avant de déclencher un événement. En Java, Martin la met en portée paquet avec un commentaire /*private*/ exprimant son intention — faute d'équivalent au friend du C++. En TypeScript, on documente la même intention (le commentaire ci-dessus), assumant la même entorse mesurée à l'encapsulation au service de la testabilité.
Table de transition interprétée
Ici, le constructeur construit une table qui se lit presque mot pour mot comme la STT, et un moteur générique l'interprète.
interface Action { execute(): void; }
interface Transition {
currentState: number;
event: number;
newState: number;
action: Action;
}
class Turnstile {
state = LOCKED;
private transitions: Transition[] = [];
constructor(private controller: TurnstileController) {
// Se lit comme la table de transition canonique.
this.addTransition(LOCKED, COIN, UNLOCKED, () => controller.unlock());
this.addTransition(LOCKED, PASS, LOCKED, () => controller.alarm());
this.addTransition(UNLOCKED, COIN, UNLOCKED, () => controller.thankyou());
this.addTransition(UNLOCKED, PASS, LOCKED, () => controller.lock());
}
private addTransition(
currentState: number, event: number,
newState: number, execute: () => void,
): void {
this.transitions.push({ currentState, event, newState, action: { execute } });
}
// Le moteur : il cherche la transition et l'applique.
event(event: number): void {
for (const t of this.transitions) {
if (this.state === t.currentState && event === t.event) {
this.state = t.newState;
t.action.execute();
}
}
}
} L'atout est considérable : la logique de la machine vit tout entière dans le constructeur, indépendante de l'implémentation des actions, et facile à maintenir — ajouter une transition se résume à une ligne. La table peut même changer à l'exécution (permettant des correctifs à chaud), et plusieurs tables peuvent coexister pour différentes logiques. Le coût est la vitesse (parcours linéaire de la table) et le volume de code de support.
Le pattern State
Le pattern State combine l'efficacité du switch imbriqué et la flexibilité de la table interprétée. La classe Turnstile (le contexte) expose des méthodes publiques pour les événements et des méthodes protégées pour les actions. Elle détient une référence vers une interface TurnstileState, dont chaque dérivée représente un état de la FSM.
// Une dérivée d'état par état de la machine.
interface TurnstileState {
coin(t: Turnstile): void;
pass(t: Turnstile): void;
}
class LockedTurnstileState implements TurnstileState {
coin(t: Turnstile): void { t.setUnlocked(); t.unlock(); }
pass(t: Turnstile): void { t.alarm(); }
}
class UnlockedTurnstileState implements TurnstileState {
coin(t: Turnstile): void { t.thankyou(); }
pass(t: Turnstile): void { t.setLocked(); t.lock(); }
}
class Turnstile {
// États sans variables : une seule instance partagée suffit.
private static lockedState = new LockedTurnstileState();
private static unlockedState = new UnlockedTurnstileState();
private state: TurnstileState = Turnstile.lockedState;
constructor(private controller: TurnstileController) {}
// Les événements délèguent à l'état courant.
coin(): void { this.state.coin(this); }
pass(): void { this.state.pass(this); }
setLocked(): void { this.state = Turnstile.lockedState; }
setUnlocked(): void { this.state = Turnstile.unlockedState; }
isLocked(): boolean { return this.state === Turnstile.lockedState; }
// Les actions, dans le contexte.
thankyou(): void { this.controller.thankyou(); }
alarm(): void { this.controller.alarm(); }
lock(): void { this.controller.lock(); }
unlock(): void { this.controller.unlock(); }
} Quand un événement arrive, le contexte le délègue à l'objet d'état courant. Changer d'état, c'est réaffecter la référence state à une autre dérivée. Comme ces dérivées n'ont aucune variable, une seule instance statique de chacune suffit, partagée par tous les tourniquets.
Attention
Le diagramme du pattern State ressemble fortement à celui du pattern Strategy : les deux ont un contexte qui délègue à une base polymorphe dotée de dérivées. La différence est que, dans State, les dérivées détiennent une référence vers le contexte et leur rôle premier est d'y sélectionner et invoquer des méthodes. Strategy n'impose ni cette référence ni ces appels. Autrement dit, toute instance de State est aussi une instance de Strategy, mais l'inverse est faux.
Le pattern State offre une séparation très forte entre actions (dans le contexte) et logique (répartie dans les dérivées d'état), chacune modifiable sans affecter l'autre. Il est aussi efficace que le switch imbriqué. Son coût est double : écrire les dérivées d'état est fastidieux — une machine à 20 états devient assommante à coder — et la logique se trouve dispersée, sans endroit unique pour la voir d'un coup d'œil.
SMC : le compilateur de machine à états
C'est cette double pénibilité — l'écriture répétitive des dérivées et l'absence d'un lieu unique pour la logique — qui a conduit Martin à écrire un compilateur de machine à états (State-Machine Compiler, SMC). On lui fournit une table de transition textuelle, et il génère les classes du pattern State. La description de la FSM tient ainsi en un seul fichier lisible :
FSMName Turnstile
Context TurnstileActions
Initial Locked
Exception FSMError
{
Locked {
coin Unlocked unlock
pass Locked alarm
}
Unlocked {
coin Unlocked thankyou
pass Locked lock
}
} Il suffit alors d'écrire une classe qui déclare les fonctions d'action (le contexte), puis une sous-classe qui les implémente. SMC engendre le reste sous la forme d'une machine à états à trois niveaux : une interface d'actions, la classe générée qui contient la logique et les classes d'état privées, et la classe finale qui implémente les actions. Le code généré est totalement isolé de votre code : vous n'avez jamais à le modifier, ni même à le regarder. Si un événement illégal survient — une transition jamais déclarée dans l'entrée —, le code généré lève l'exception FSMError. On atteint ainsi le meilleur des approches : logique en un seul endroit, fortement isolée des actions, solution efficace, élégante et minimale en code. Le seul coût est d'apprendre un outil de plus, ici remarquablement simple et gratuit.
Où employer les machines à états ?
Martin juge les FSM sous-utilisées. Il les emploie notamment pour les politiques de haut niveau des interfaces graphiques : ironiquement, le code de ces interfaces « sans état » est fortement piloté par l'état (quel bouton griser, quelle fenêtre afficher, où placer le focus). Concentrer ces décisions dans une FSM unique — par exemple la séquence de connexion avec ses trois tentatives avant verrouillage de l'écran — capture la politique applicative en un seul endroit facile à maintenir, et allège tout le reste du code. Il les emploie aussi pour les contrôleurs d'interaction (le dessin d'un rectangle à la souris est une FSM : clic, glisser, animation, relâcher) et pour le traitement distribué (l'envoi d'un gros bloc découpé en paquets, paquet par paquet avec accusés de réception, est encore une machine à états).
À retenir
- La famille Visitor ajoute des opérations à une hiérarchie sans la modifier, respectant l'OCP : Visitor (double répartition), Acyclic Visitor (sans cycle de dépendances), Decorator et Extension Object.
- La double répartition enchaîne deux appels polymorphes —
acceptrésout le type visité,visitrésout l'opération — formant une matrice de fonctions très rapide. - Le Visitor fige la hiérarchie visitée : excellent quand elle est stable, à fuir quand elle est volatile (préférer alors l'Acyclic Visitor et sa matrice creuse).
- Les machines à états finis se décrivent par un diagramme (STD) ou une table (STT), qui révèlent immédiatement les transitions non gérées.
- Trois implémentations de FSM : switch imbriqués (efficaces mais ingérables à grande échelle), table interprétée (logique centralisée mais plus lente), pattern State (efficace, fortement séparé, mais logique dispersée).
- Le pattern State ressemble à Strategy, mais ses dérivées détiennent une référence au contexte et y invoquent des méthodes : tout State est un Strategy, l'inverse est faux.
- Le compilateur SMC réunit le meilleur des approches en générant le pattern State depuis une table textuelle ; et la testabilité (interface
TurnstileController, variable d'état accessible) est une force qui pousse la conception vers le découplage.