Vous trouverez quelques volumes de référence à propos des schéma de conception dans mes suggestions de lecture. Le classique est bien sûr celui du Gang of Four.

Bases sur les schémas de conception

Les schémas de conception, ou Design Patterns de leur nom anglais, sont des pratiques qui ont fait leurs preuves et qui sont suffisamment rodées et documentées pour être décrites par leur nom et enseignées comme telles. Ces pratiques sont estimées indépendantes du langage de programmation utilisé pour les implémenter, bien qu'on les associe habituellement aux langages de programmation orientée objet.

Quelques raccourcis :

Un schéma de conception n'est pas du code, mais bien un descriptif d'une façon de faire récurrente et connue. Il y a donc plusieurs manières d'implémenter chacun d'entre eux. Les schémas de conception, pris individuellement, ne sont typiquement pas révolutionnaires. Au contraire, il est probable que la plupart de ces pratiques vous soient connues, parce que vous les avez déjà appliquées sans trop y penser, par réflexe ou parce que cela vous semblait être une bonne idée pour le problème que vous cherchiez à résoudre. C'est l'idée de codifier ces pratiques qui est brillante, en fait : nommer les pratiques et les décrire facilite la pédagogie, l'apprentissage et formalise le tout de manière à faire en sorte que nous évitions les écueils ou que nous fassions bien, mais pas tout à fait assez bien les choses.

Certaines pratiques reconnues sont plutôt locales à certains langages ou groupes de langages. Dans ce cas, on tend à parler d'idiomes de programmation (Programming Idioms), laissant le terme schéma de conception (Design Pattern) pour les pratiques transférables d'un langage à l'autre. Ne considérez toutefois pas les idiomes comme des pratiques mineures : dans un cas comme dans l'autre, on parle de pratiques usitées, connues et documentées, qui ont fait leurs preuves et dont on connaît bien les avantages et les inconvénients.

Vous trouverez ci-dessous quelques liens et quelques articles, classés par catégorie, à propos des schémas de conception et de considérations connexes. Lorsque des articles de votre humble serviteur sont disponibles, vous trouverez un ou plusieurs liens vous y menant. Des articles d'autres auteurs sont aussi indiqués dans chaque cas, et il en sera de même pour des critiques du schéma de conception (lorsque j'aurai des liens appropriés à proposer).

Notez qu'il existe des schémas de conception dans plusieurs catégories de pratiques, pas seulement logicielles (on accorde habituellement le crédit de l'idée originale d'un langage de schémas de conception et de pratiques recommandables à Christopher Alexander, un architecte). Bien entendu, ici, c'est le créneau logiciel qui nous intéressera. En informatique, le livre clé sur le sujet est Design Patterns: Elements of Reusable Object-Oriented Software, un classique qui a étonnamment bien vieilli.

Comme pour toute chose, il faut lire les divers articles et les diverses critiques ci-dessous avec discernement. Règle générale, en pratique, rien n'est complètement bon ou complètement mauvais, et il faut, face à des critiques, essayer de comprendre les raisons qui les ont provoquées et la manière par laquelle l'auteur a implémenté ou conçoit le schéma de conception.

Idées générales à propos des schémas de conception

Quelques liens et articles d'ordre général :

Des liens sur les schémas de conception pour les interfaces personne/ machine :

À propos de l'importance des abstractions et des risques de créer des dépendances malsaines, un exemple :

Évidemment, vous ne voulez pas dupliquer la fonctionnalité (peu de choses sont pires en développement logiciel que la réutilisation par copier/ coller).

Le mauvais réflexe est de faire en sorte que l'une des deux méthodes appelle l'autre (que OnQuitter() appelle OnFichierQuitter(), disons). En effet, cela créerait une dépendance artificielle entre deux contrôles (et vous ne voulez pas ajouter de tels boulets à votre design).

Le réflexe sain est de constater que la fonctionnalité de quitter l'application sera requise à plus d'un endroit et de l'extraire de ces deux contrôles pour la placer ailleurs, puis de faire en sorte que les contrôles sollicitent cette méthode tierce, plus générale. Voyez-vous pourquoi?

Des liens sur les schémas de conception pour le Web :

Des liens sur les schémas de conception en programmation fonctionnelle :

Quelques schémas de conception pour les microservices, répertoriés par Chris Richardson : http://microservices.io/patterns/

Quelques critiques :

Quelques liens pertinents vers des bibliothèques ou des catalogues de schémas de conception et de pratiques :

Idiomes de programmation

Quelques raccourcis vers des idiomes connus :

Là où les schémas de conception sont des pratiques généralement applicables dans l'ensemble des langages de programmation (typiquement ceux qui sont orientés objet), les idiomes sont des pratiques plus locales, qui dépendent de mécanismes que certains langages n'ont pas (par exemple l'idiome RAII, qui exige une finalisation déterministe) ou qui ont trait aux façons de faire d'un langage donné (par exemple, la création dynamique intempestive d'objets dans un langage offrant un mécanisme de collecte automatique des ordures).

Quelques liens d'ordre général :

Dans un ordre d'idées connexe, texte de 2021 par Enda Phelan qui présente des « idiomes » (au sens linguistique du terme) de la pratique de la programmation et de l'ingénierie logicielle : https://endaphelan.me/posts/software-idioms-you-should-know/

Affectation sécuritaire

L'affectation en C++ est une opération qui peut être complexe à implémenter. Heureusement, Herb Sutter (sur la base de techniques mises de l'avant par Jon Kalb si je ne m'abuse) nous a montré que, si nous avons implémenté le constructeur de copie, la destruction et la méthode swap(), alors l'affectation peut être implémentée de manière simple et sécuritaire.

Supposons une classe X dont la structure est, à la base, telle que proposé à droite.

Remarquez qu'un X est responsable de son attribut tab, ce qui est rendu visible par le constructeur paramétrique et par le destructeur de cette classe.

#include <algorithm>
class X {
   int *tab;
   std::size_t n;
public:
   X(std::size_t n) : tab{ new int[n] }, n{ n } {
   }
   X(const X &autre) : tab{ new int[autre.n] }, n{ n } {
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, tab);
   }
   ~X() {
      delete [] tab;
   }
   // etc.
};

Une mauvaise implémentation de l'affectation pour un X serait celle à droite.

En effet, cette implémentation ne fonctionne pas dans le cas d'un programme tel que :

X a(3);
a = a;

car la destination (*this) détruit, en éliminant ses propres états, la source. De plus, si le new[] échoue, nous avons ici un vilain problème de sécurité, la destination étant détruite mais jamais remplacée par de nouveaux états – on a alors un objet de destination dans un état incorrect, un bris patent d'encapsulation..

#include <algorithm>
class X {
   int *tab;
   std::size_t n;
public:
   X(std::size_t n) : tab{ new int[n] }, n{ n } {
   }
   X(const X &autre) : tab{ new int[autre.n] }, n{ autre.n } {
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, tab);
   }
   ~X() {
      delete [] tab;
   }
   X& operator=(const X &autre) {
      using std::copy;
      delete [] tab;
      tab = new int[autre.n];
      n = autre.n;
      copy(autre.tab, autre.tab + autre.n, tab);
      return *this;
   }
   // etc.
};

Une variante correcte, mais beaucoup plus lourde, serait celle proposée à droite. Dans ce cas, si une exception survient lors du new[], alors celle-ci filtre jusqu'au code client et l'objet demeure cohérent.

Cela demeure une approche quelque peu artisanale, à repenser pour chaque opérateur d'affectation.

 

#include <algorithm>
class X {
   int *tab;
   std::size_t n;
public:
   X(std::size_t n) : tab{ new int[n] }, n{ n } {
   }
   X(const X &autre) : tab{ new int[autre.n] }, n{ n } {
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, tab);
   }
   ~X() {
      delete [] tab;
   }
   X& operator=(const X &autre) {
      int *p = new int[autre.n];
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, p);
      delete [] tab;
      tab = p;
      n = autre.n;
      return *this;
   }
   // etc.
};

Une « amélioration » à ce schème serait de ne pas réaliser l'allocation de mémoire et les copies de contenu dans le cas où un objet est affecté à un autre.

Cependant, l'irritant de cette « amélioration » est que chaque appel à cet opérateur implique un test (un if), donc que tous paient pour éviter un problème relativement rare, un cas pathologique.

 

#include <algorithm>
class X {
   int *tab;
   std::size_t n;
public:
   X(std::size_t n) : tab{ new int[n] }, n{ n } {
   }
   X(const X &autre) : tab{ new int[autre.n] }, n{ n } {
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, tab);
   }
   ~X() {
      delete [] tab;
   }
   X& operator=(const X &autre) {
      if (*this != &autre) {
         int *p = new int[autre.n];
         using std::copy;
         copy(autre.tab, autre.tab + autre.n, p);
         delete [] tab;
         tab = p;
         n = autre.n;
      }
      return *this;
   }
   // etc.
};

L'idiome d'affectation sécuritaire a plusieurs avantages sur les implémentations aritsanales :

Voici comment cet idiome se présente en pratique :

  • Une méthode swap() doit être implémentée pour permuter les états de deux instances du même type. En général, cette méthode sera et se fera sans lever d'exceptions, du fait que la plupart des objets pour lesquels on souhaite implémenter la Sainte-Trinité sont responsables de ressources, typiquement (mais pas nécessairement) de pointeurs, et que permuter des pointeurs (qui sont des primitifs) ne lève pas d'exceptions
  • L'affectation en tant que telle s'implémente :
    • en construisant une copie anonyme du paramètre reçu par l'opération d'affectation
    • en permutant les états de cette temporaire anonyme avec ceux de l'objet de destination, et
    • en détruisant implicitement les états de l'objet anonyme (celui-ci n'ayant pas de nom, il expirera suite à l'exécution de cette expression.
#include <algorithm>
class X {
   int *tab;
   std::size_t n;
public:
   X(std::size_t n) : tab{ new int[n] }, n{ n } {
   }
   X(const X &autre) : tab{ new int[autre.n] }, n{ n } {
      using std::copy;
      copy(autre.tab, autre.tab + autre.n, tab);
   }
   ~X() {
      delete [] tab;
   }
   void swap(X &autre) noexcept {
      using std::swap;
      swap(tab, autre.tab);
      swap(n, autre.n);
   }
   X& operator=(const X &autre) {
      X{ autre }.swap(*this);
      return *this;
   }
   // etc.
};

Ce faisant, le coût d'une affectation est égal à la somme du coût d'une copie (ce qui est normal, puisqu'il faut copier les états de la source à la destination), du coût d'un nettoyage (ce qui est normal, puisqu'il faut nettoyer les états de la destination avant affectation) et d'un coût constant, celui des permutations d'états.

Dans une brillante présentation de 2014, Sean Parent a mis de l'avant qu'on peut faire encore mieux pour un type déplaçable, donc implémentant la sémantique de mouvement. Dans un tel cas, il est possible d'exprimer l'affectation comme suit :

class X {
   // ...
public:
   X(const X&); // constructeur de copie
   X(X&&); // constructeur de mouvement
   X& operator=(const X &autre) {
      X temp = autre; // copie
      *this = std::move(temp); // mouvement
      return *this;
   }
   // ...
};

Pour les types déplaçables, il est possible de tirer un (léger) gain de vitesse d'une copie suivie d'un mouvement (le mouvement impliquant typiquement deux opérations machine par état à déplacer) en comparaison avec une copie suivie d'une permutation (qui implique typiquement trois opérations machine par état à déplacer).

Quelques textes d'autres sources :

CRTP

L'idiome CRTP (pour Curiously Recurring Template Pattern), qu'on devrait à James O. Coplien dans une édition de 1995 du C++ Report, est une utilisation surprenante de la généricité, par laquelle le nom d'un enfant est utilisé dans la définition du nom de son parent (générique). Concrètement, pour une classe générique B<T>, l'idiome CRTP définit des enfants D<B<D>>, donc utilise le nom de l'enfant D en lieu et place du type générique du parent (le type T dans B<T>).

Cet idiome a un nombre étonnant d'applications pertinentes.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Critiques :

Immuabilité

L'immuabilité est une manière de concevoir des classes de manière à rendre leurs instances impossibles à modifier une fois construites.

On utilise souvent cet idiome dans les langages où il est impossible de définir des instances constantes (comme Java et C#, par exemple). En effet, prenant l'exemple de Java, si un programme définit un final X x = new X(); pour une classe X donnée, c'est la référence x qui est constante (elle ne peut mener vers une autre instance de X que celle qui lui est donnée à l'initialisation), pas le référé (celui-ci est pleinement modifiable).

Plusieurs types clés des modèles Java et .NET sont immuables pour cette raison (le cas canonique dans chaque cas est la classe String).

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Incopiable

L'idiome des classes incopiables est un idiome important du langage C++, du fait que la Sainte-Trinité (le constructeur de copie, l'affectation et la destruction) est générée automatiquement pour toute classe. Dans les cas où un objet est responsable d'une ressource, la question de la gestion de sa copie entre en ligne de compte : partagera-t-on cette ressource? La copiera-t-on? La clonera-t-on?

Quand on n'est pas en mesure de trancher, ou quand la copie de l'objet responsable de la ressource serait un problème, l'idiome de la classe incopiable entre en jeu.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Nifty Counter (aussi connu sous le nom de Schwarz Counter)

Cet idiome décrit la tenue à jour d'un compteur de références partagé sur un objet global, de manière à ce que cet objet soit créé lorsqu'il est demandé une première fois, détruit lorsqu'il est relâché la dernière fois, et partagé entre-temps. Ceci peut entre autres être utile pour des objets globaux tels que std::cout en C++. Cette technique va comme suit.

Un fichier d'en-tête déclare l'objet global à partager (ici : Serveur), qui a des attributs de classe qu'il importe d'initialiser une seule fois de manière non-triviale, et définit une variable globale statique (invisible à l'édition des liens) à même le fichier d'en-tête. Cette dernière, nommée ici Initialiseur, servira à gérer le compteur de références en question.

#ifndef SERVEUR_H
#define SERVEUR_H
class Serveur {
   friend struct Initialiseur;
   // ... attributs de classe ...
public:
   Serveur();
   // services ...
};
struct Initialiseur {
   Initialiseur();
   ~Initialiseur();
} __ze_initialiseur; // la variable globale
#endif

Un fichier source, où le constructeur d'un Initialiseur incrémentera (avec prudence) un compteur global interne au fichier et invisible à l'édition des liens, et où le destructeur d'Initialiseur décrémentera (avec tout autant de prudence) le même compteur.

Si, à la construction, un Initialiseur fait le constat que c'est lui qui a fait passer le compteur de 0 à 1, alors il assurera l'initialisation des attributs globaux de Serveur.

Si, à la destruction, un Initialiseur fait le constat que c'est lui qui a fait passer le compteur de 1 à 0, alors il assurera le nettoyage des attributs globaux de Serveur.

Notez que, pour être Thread-Safe, une implémentation doit aussi s'assurer que les états globaux ne soient pas utilisés avant que leur initialisation n'ait été complétée, une tâche qui n'est pas banale; le Nifty Counter est un outil qui prédate les ordinateurs multi-coeurs.

 

#include "Serveur.h"
#include <atomic>
static atomic<long> ze_nifty_counter { 0L }; // le compteur en question
Initialiseur::Initialiseur() {
   long avant = ze_nifty_counter.load();
   while (ze_nifty_counter.compare_exchange_weak(avant, avant + 1))
      ;
   if (avant == 0) { // si c'est moi qui ai changé le compteur de 0 à 1
      // initialiser les états globaux de Serveur...
   }
}
Initialiseur::~Initialiseur() {
   long avant = ze_nifty_counter.load();
   while (ze_nifty_counter.compare_exchange_weak(avant, avant - 1))
      ;
   if (avant == 1) { // si c'est moi qui ai changé le compteur de 1 à 0
      // nettoyer les états globaux de Serveur...
   }
}
// ...

Ainsi, chaque fichier source qui inclura Serveur.h inclura aussi une variable globale gérant un même Nifty Counter. Le problème de « qui est responsable de l'initialisation des états » est résolu de par la gestion du Nifty Counter, elle-même implémentée par les variables globales implicitement ajoutées à chaque unité de traduction.

Quelques textes d'autres sources :

État nul, ou Null-State

L'idiome d'état nul représente la capacité de représenter un état par défaut pour un type donné, et de ramener un objet de ce type à cet état. L'état nul est un état reconnaissable en tant que tel, et deux instances d'un état nul pour un même type devraient typiquement être égales au sens du contenu.

Quelques textes d'autres sources :

Interface non-virtuelle, ou NVI

Cousin proche du schéma de conception Template Method, cet idiome implique :

  • Une classe parent offrant des services polymorphiques; et
  • Au moins une classe enfant implémentant des services.

Dans un tel cas, l'idiome NVI recommande que :

  • L'interface publique du parent soit faite de méthodes qui ne sont pas polymorphiques;
  • Les services polymorphiques, abstraits ou non, soient protégés;
  • Que les services du parent encadrent, lors d'un appel, ceux des enfants.

Par NVI, il est possible de centraliser en un même lieu (les méthodes du parent) des services (i) de saisie de statistiques d'utilisation (nombre d'appels, durée d'exécution des appels des services implémentés par les enfants, ce genre de truc), (ii) de validation des préconditions des fonctions (p. ex. : ce paramètre sera non-nul), (iii) de validation des postconditions (p. ex. : l'état de la variable globale x sera identique avant et après l'appel à la fonction), etc.

L'exemple à droite met ceci en relief : le parent encadre l'appel aux services de l'enfant à partir d'une interface concrète, bien que les services de l'enfant soient eux-mêmes sollicités par polymorphisme.

#include <chrono>
using namespace std; // bof
using namespace std::chrono; // re-bof
struct TimedOperation {
   system_clock::duration proceder() {
      auto avant = system_clock::now();
      proceder_impl();
      return system_clock::now() - avant;
   }
   virtual ~TimedOperation() = default;
protected:
   virtual void proceder_impl() = 0;
};
class GrosCalcul : public TimedOperation {
   void proceder_impl(); // quelque chose qui prend du temps
   // ...
};

Un exemple semblable avec C# serait :

using System;
using System.Diagnostics;
using System.Threading;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace z
{
   abstract class OpérationMinutée
   {
      public abstract string Nom
      {
         get;
      }
      public void Exécuter()
      {
         var minuterie = new Stopwatch();
         minuterie.Start();
         ExécuterImpl();
         minuterie.Stop();
         Console.WriteLine($"Exécution de {Nom} en {minuterie.ElapsedMilliseconds} ms");
      }
      protected abstract void ExécuterImpl();
   }
   class OpérationÀTester : OpérationMinutée
   {
      int tempsExécution;
      public override string Nom => "Petit test"; }
      protected override void ExécuterImpl()
      {
         Thread.Sleep(tempsExécution);
      }
      public OpérationÀTester(int dodo)
      {
         tempsExécution = dodo;
      }
   }
   class Program
   {
      static void Main(string[] args)
      {
         OpérationMinutée op = new OpérationÀTester(500);
         op.Exécuter();
      }
   }
}

Quelques textes :

Paramètres nommés

Il arrive que les paramètres d'une fonction, par exemple ceux d'un constructeur, entraînent une confusion chez les clients. Pensez par exemple à une classe Rectangle comme la suivante (ébauche) :

class Rectangle {
   // ...
public:
   constexpr Rectangle(int,int);
   // ...
};

Dans le constructeur paramétrique, les noms des paramètres sont documentaires, mais l'utilisation d'une telle classe est une source d'erreurs. Par exemple, dans le programme ci-dessous, sur la seule base du code source, il n'est pas clair que le Rectangle nouvellement créé soit de largeur 7 et de hauteur 3 ou de largeur 3 et de hauteur:

int main() {
   Rectangle rect{ 3,7 };
   // ...
}

Certains langages évitent ces ambiguïtés en permettant au code client de nommer les paramètres. Ceci introduit une forme de distance entre l'ordre des paramètres dans le code appelant et dans la signature de la fonction appelée. En C++, il est possible d'en arriver à un résultat semblable d'au moins deux manières.

Une des approches est de permettre l'enchaînement d'initialisations nommées, comme le montre l'exemple à droite.

Le principal défaut de cette approche est qu'il brise l'encapsulation, au sens où il est possible d'avoir un Rectangle qui ne soit que partiellement initialisé, mais l'approche peut être convenable dans les cas où les états par défaut de l'objet nouvellement construit sont adéquats pour la classe.

Un irritant accessoire (quoique...) de cette approche est qu'elle est inefficace, au sens où l'objet doit être construit dans sa version par défaut, puis ses états sont remplacés par des versions plus proches des attentes du code client. Ce coût est banal ici, avec une classe dont les états sont constitués d'une paire d'entiers, mais peut être nettement plus douloureux à absorber lorsque les états ne sont pas triviaux (une std::string, un vecteur, une autre sorte d'objet complexe à initialiser, ...)

class Rectangle {
   // ...
   int hauteur,
       largeur;
public:
   Rectangle();
   Rectangle& Largeur(int valeur) {
      // ... valider? ...
      largeur = valeur;
   }
   Rectangle& Hauteur(int valeur) {
      // ... valider? ...
      hauteur = valeur;
   }
};
// ...
int main() {
   auto rect = Rectangle{}.Largeur(3).Hauteur(7);
   // ...
}

L'autre est de définir un type par paramètre, comme le montre l'exemple à droite.

Cette approche a plusieurs avantages :

  • Elle est explicite
  • Elle n'entraîne aucun coût à l'exécution
  • Elle permet de localiser les règles d'affaires d'un type à même ce type. Ici, c'est Largeur qui définira les règles de validité d'une largeur de Rectangle, et qui garantira le respect de ces règles
  • Elle permet d'offrir plusieurs signatures pour une même fonction, si cela semble opportun. Ici, le code client peut instancier un Rectangle avec une Largeur puis une Hauteur, ou encore avec une Hauteur puis une Largeur, au choix

Son inconvénient principal est qu'il peut être fastidieux de définir plusieurs types : si cette approche est utilisée de manière abusive, le nombre de types dans un programme risque d'exploser; de même, les types peuvent avoir des particularités contextuelles (peut-être la largeur d'un Rectangle et celle d'un Cercle sont-elles soumises à des règles distinctes), ce qui implique de réfléchir au design des classes (recours à des espaces nommés, à des classes internes, à des dépôts de classes auxiliaires, à des fabriques, etc.)

Dans les classes Hauteur et Largeur, il aurait aussi été possible de remplacer l'accesseur valeur() par un opérateur de conversion au type du substrat, par exemple :

class Hauteur {
   int valeur;
public:
   constexpr explicit Hauteur(int valeur) noexcept : valeur{ valeur }{
   }
   constexpr explicit operator int() const noexcept {
      return valeur;
   }
};

C'est une question de préférence, en fait (appeler static_cast<int> sur une Hauteur ou appeler sa méthode valeur()).

class Largeur {
   int valeur_;
public:
   constexpr explicit Largeur(int valeur)
      : valeur_{ valeur } // ... valider?
   {
   }
   constexpr int valeur() const {
      return valeur_;
   }
};
class Hauteur {
   int valeur_;
public:
   constexpr explicit Hauteur(int valeur)
      : valeur_{ valeur } // ... valider?
   {
   }
   constexpr int valeur() const {
      return valeur_;
   }
};
class Rectangle {
   // ...
public:
   // ...
   constexpr Rectangle(Largeur, Hauteur);
   constexpr Rectangle(Hauteur, Largeur);
   // ...
};
// ...
int main() {
   Rectangle rect{ Largeur{ 3 }, Hauteur{ 7 } };
   // ...
}

Quelques textes d'autres sources :

pImpl

L'idiome pImpl (pour Private Implementation) est une pratique par laquelle il est possible, en C++, de concevoir des objets dont l'implémentation est véritablement opaque. Le compilateur et la classe travaillent de concert pour isoler de manière stricte l'implémentation de l'interface. Par conséquent, le code client est découplé de l'implémentation de l'objet, et devient strictement portable (au sens de la compilation, à tout le moins).

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

RAII

L'idiome RAII (pour Resource Acquisition is Initialization) est fortement répandu en C++, du fait que ce langage propose un modèle permettant la finalisation déterministe (grâce aux destructeurs) des objets automatiques. Le langage C#, avec les blocs using, et Java 7, avec les blocs try-with (inspirés de cette proposition de Joshua Bloch) offrent d'ailleurs maintenant des mécanismes similaires.

Prenons par exemple la fonction g() à droite, qui alloue (pour des raisons obscures) dynamiquement une instance de X, puis la passe à la fonction f() qui n'est pas noexcept.

Ici, si f() devait lever une exception, la finalisation de *p (réalisée par delete p dans ce code plus-que-douteux) ne serait pas atteinte, et le programme subirait une fuite de mémoire (problème souvent moins grave qu'il n'y paraît), mais surtout une possible fuite de ressources dû à la non-finalisation de *p (imaginez si X::~X() devait être responsable de compléter une transaction bancaire...)

Allouer dynamiquement des ressources est utile... quand il y a un besoin. Sur la base du code de droite, ce besoin n'est pas évident, et il est probable qu'ici, une allocation automatique aurait été plus adéquate :

void g() {
   X x;
   // ...
   f(&x);
   // ...
} // aucune fuite, plus simple, plus rapide

Faisons donc comme si l'allocation dynamique était pertinente ici, pour les besoins de l'illustration.

class X { /* ... */ };
int f(const X*);
void g() {
   auto p = new X;
   // ...
   f(p); // si f() lève une exception, *p ne sera pas détruit
   // ...
   delete p;
}

Si nous souhaitons automatiser la finalisation du pointé, il suffit de confier cette responsabilité à une variable locale (ici, un std::unique_ptr<X>) et de laisser son destructeur s'en charger.

#include <memory>
class X { /* ... */ };
int f(const X*);
void g() {
   std::unique_ptr<X> p { new X };
   // ...
   f(p.get()); // si f() lève une exception, *p ne sera pas détruit
   // ...
} // destruction implicite du pointé

L'idiome RAII apparaît un peu partout en C++ :

  • Automatisation de la libération des mutex avec un lock_guard ou un unique_lock
  • Automatisation de la fermeture d'un fichier
  • Automatisation de la libération des ressources associées à un conteneur, etc.

L'extrait de code à droite montre quelques cas simples d'applications de cet idiome.

Notez qu'il est important de nommer les objets qui sont responsables de libérer des ressources en fin de portée, sans quoi ces objets seront créés... puis détruits non pas en fin de portée, mais bien à la fin de l'expression. Ainsi, dans ajouter() à droite, retirer le nom _ du lock_guard<mutex> aurait pour conséquence de déverrouiller m immédiatement, et de laisser l'appel à data.insert() se faire sans synchronisation.

Il y a un truc pour éviter de nommer ces variables, comme le rappelle ce texte de 2018 : utiliser l'opérateur , plutôt que le ; pour éviter que l'expression ne se complète immédiatement. Ainsi, il est techniquement possible de remplacer ceci :

void ajouter(string_view s) {
   lock_guard<mutex> _ { m }; // variable nommée
   data.insert(end(data), begin(s), end(s));
} // deux expressions; _ meurt ici

... par cela :

void ajouter(string_view s) {
   lock_guard<mutex> { m }, // variable anonyme, l'expression se poursuit après...
   data.insert(end(data), begin(s), end(s)); // fin de l'expression, elle meurt ici
}

... mais je ne vous le recommande pas. C'est une source de confusion sans fin.

#include <fstream>
#include <string>
#include <string_view>
#include <mutex>
#include <thread>
#include <iterator>
using namespace std;
class zone_transit {
   string data; // RAII : les ressources dans data
                // seront automatiquement libérées
                // quand *this (donc, quand data) sera
                // détruit
   mutex m;
public:
   void ajouter(string_view s) {
      lock_guard<mutex> _ { m }; // RAII : verrouillage ici...
      data.insert(end(data), begin(s), end(s));
   } // ... et déverrouillage ici
   string extraire() {
      string s;
      unique_lock<mutex> verrou { m }; // RAII : verrouillage ici...
      s.swap(data);
      verrou.unlock(); // ... et déverrouillage peut-être ici (si tout va bien)
      return s;
   } // ... ou là si un ennui survient
};

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Critiques :

Mauvaises pratiques

Il existe aussi de mauvaises pratiques, que ce soit des erreurs commises de bonne foi et qui se perpétuent pour quelque raison que ce soit (souvent parce que les gens n'ont pas le temps de se poser la question à savoir « est-ce une bonne idée? »), qu'on nomme souvent des Anti-patterns (un texte qu'on doit apparemment à Andrew Koenig, du moins selon Martin Fowler dans http://martinfowler.com/bliki/AntiPattern.html), ou des gestes carrément hostiles, commis par des gens malveillants sur une base délibérée, qu'on nomme alors des Dark Patterns.

Quelques liens pertinents sur ces sujets :

Adaptateur

Aussi appelé Adapter (anglais), cette pratique correspond à insérer le code nécessaire entre deux interfaces qui ne se correspondent pas en tout point mais pour lesquelles les différences sont suffisamment mineures pour qu'il soit possible d'adapter l'une à l'autre :

Textes d'autres sources :

Bâtisseur

Aussi appelé Builder, ce schéma décrit un objet qui connaît une séquence d'opérations lui permettant d'assembler des objets. Petit cousin de Fabrique.

Textes d'autres sources :

Bytecode

Ce schéma survient naturellement lorsque le besoin de flexibilité d'un système est tel qu'il vaut mieux encoder les actions sous forme de données destinées à une machine virtuelle.

À ce sujet :

Clonage

Dans un langage OO où l'on manipule un objet polymorphique mutable par voie d'indirection (référence, pointeur), il peut arriver que l'on souhaite dupliquer cet objet. Cette duplication doiut alors être subjective dans la plupart des cas : l'objet étant polymorphique, on ne sait typiquement pas si l'indirection manipulée mène vers une instance du type statique (visible dans le code) ou vers une instance d'un type dynamique distinct (une instance d'une classe dérivée).

Le clonage est cette technique de duplication subjective.

À ce sujet :

Commande

Ce schéma correspond à représenter des actions posées dans un programme sous la forme d'entités logicielles, par exemple pour être en mesure de préparer une séquence d'instructions à exécuter en bloc (des macros) ou pour mettre en place un mécanisme d'annulation ou de réexécution de commandes (Undo/ Redo).

Un exemple complet implémentant un outil simpliste d'édition de texte avec annulation et réexécution suit.

Les inclusions standards sont en petit nombre. J'utiliserai :

  • L'en-tête <memory> pour unique_ptr
  • Les en-têtes <string> et <string_view>, puisque nous manipulerons du texte
  • L'en-tête <cassert> pour valider quelques cas critiques, et
  • Je préfèrerai <vector> à <stack> du fait que je ne serai pas toujours puriste dans mon utilisation des piles permettant d'annuler et de refaire

En particulier, je permettrai d'afficher le contenu d'une pile, pour faciliter le débogage, ce qui est plus simple à réaliser avec un conteneur généraliste comme std::vector qu'avec un « conteneur » dont l'interface est limitée comme std::stack.

#include <memory>
#include <string>
#include <string_view>
#include <iostream>
#include <cassert>
#include <algorithm>
#include <vector>
using namespace std;

Toutes les commandes seront des dérivés de l'interface CommandeImpl montrée à droite. Une commande devra savoir s'exécuter, s'annuler et (pour fins de débogage) se décrire sur un flux. Notez qu'en pratique, une commande s'exécutera ou s'an nulera sur l'état du programme; dans le cas simple décrit ici, l'état du programme n'est que la chaîne de caractères en cours d'édition.

struct CommandeImpl {
   virtual void executer(string&) = 0;
   virtual void annuler(string&) = 0;
   virtual ostream& decrire(ostream&) const = 0;
   virtual ~CommandeImpl() = default;
};

Le code client ne sera pas invité à utiliser des CommandeImpl* ou des pointeurs intelligents de CommandeImpl. Il ne s'agirait pas d'une interface naturelle d'utilisation. En C++, il est d'usage de proposer au code client des objets simples à manipuler et qui se comportent un peu comme des int (suivant une maxime proposée par Scott Meyers : Do as the ints do!).

La classe Commande ici a les caractéristiques suivantes :

  • Elle est incopiable car son attribut p est lui-même incopiable
  • Elle est déplaçable, implémentant la sémantique de mouvement, ce qui permet de l'entreposer dans un conteneur
  • Elle englobe une sorte de CommandeImpl qui ne peut être nulle
  • Ses services concrets (executer(), annuler() et decrire()) sont des relais vers le CommandeImpl qu'elle englobe

Remarquez que le nom Commande (le nom « évident ») a été réservé à la classe qui devrait être utilisée. C'est un facteur parmi tant d'autres pour inviter le code client à se restreindre aux meilleures pratiques de programmation.

Remarquez aussi que je n'en ai pas fait un pImpl strict, du fait que le souhait ici est que CommandeImpl soit implémentée par autant que classes spécialisées que jugé nécessaire par le code client.

class Commande {
   unique_ptr<CommandeImpl> p;
public:
   Commande(unique_ptr<CommandeImpl> p) : p{ std::move(p) } {
      assert(p && "Une implementation non-nulle de commande est requise");
   }
   Commande(Commande&&) = default;
   Commande(nullptr_t) {
      assert(false && "Une implementation non-nulle de commande est requise");
   }
   Commande& operator=(Commande&&) = default;
   void executer(string &s) {
      p->executer(s);
   }
   void annuler(string &s) {
      p->annuler(s);
   }
   ostream& decrire(ostream &os) const {
      return p->decrire(os);
   }
};

Une Commande s'affiche sans peine sur un flux, mais le fruit de cette action est de réaliser une projection polymorphique (variant selon le type effectif de la CommandeImpl) sur le flux en question. Flexible pour la commande, simple pour le code client.

ostream& operator<<(ostream &os, const Commande &cmd) {
   return cmd.decrire(os);
}

Pour notre programme de démonstration, les seules commandes seront des entrées de texte, qui pourront être exécutées (ajoutant le texte à la chaîne de caractères qui représente l'état de l'édition en cours) ou annulées (supprimant le texte de la fin de la chaîne de caractères qui représente l'état de l'édition en cours).

Remarquez que tous ses services sont privés, outre son constructeur paramétrique.

Le texte conservé dans une EntreeTexte sera une ligne entrée au clavier. Le saut de ligne ne sera pas conservé dans l'attribut texte, pour simplifier le code de decrire(), mais les opérations annuler() et executer() en tiendront compte.

class EntreeTexte : public CommandeImpl {
   string texte;
   void executer(string &dest) {
      dest += texte;
      dest += '\n';
   }
   void annuler(string &dest) {
      assert(dest.size() >= texte.size() + 1); // +1 pour le '\n'
      dest = dest.substr(0, dest.size() - (texte.size() + 1));
   }
   ostream& decrire(ostream &os) const {
      return os << "Entree du texte \"" << texte << '\"';
   }
public:
   EntreeTexte(string_view s) : texte{ s } {
   }
};

Passons maintenant au code client de ces quelques classes.

Pour fins décoratives, deux fonctions banales englobent l'exécution du programme de test :

  • presenter(), qui affiche un petit mot de bienvenue, et
  • au_revoir(), qui salue l'usager et affiche le fruit de la séance d'édition
void presenter() {
   cout << "Petit editeur magique\n"  << endl;
}
void au_revoir(string_view s) {
   cout << "Le resultat de votre seance d'edition va comme suit:\n" << s
        << "\n\nA la prochaine!"  << endl;
}

La séance d'édition sera interactive et permettra à l'usager de réaliser des actions. La gamme des actions possibles est décrite par l'énumération forte Action.

enum class Action : short {
   MONTRER, AJOUTER, ANNULER, REEXECUTER, VISUALISER, QUITTER
};

Pour réduire le recours à des constantes directement dans le code, et pour garantir un peu de souplesse si la gamme des actions s'enrichit, un prédicat est_quitter() permet de savoir si une Action donnée implique de quitter le programme.

bool est_quitter(Action action) noexcept {
   return action == Action::QUITTER;
}

La fonction interactive choisir_action() offre une gamme d'options à l'usager et retourne un choix valide pour une Action.

Pour alléger l'écriture, j'ai traité les entrées incorrectes (qui ne constituent pas un entier valide) comme des erreurs graves. Cela dit, il serait possible d'enrichir le traitement d'erreurs pour que ce cas soit considéré comme moins critique.

class ActionInvalide {};
Action choisir_action() {
   static const char * NOMS[] {
      "Montrer les options",
      "Ajouter texte",
      "Annuler plus recent",
      "Reexecuter plus recent",
      "Visualiser edition courante",
      "Quitter"
   };
   enum { N = sizeof(NOMS) / sizeof(NOMS[0]) };
   short choix;
   do {
      for (int i = 0; i < N; ++i)
         cout << "Entrez " << i << " pour l'option \"" << NOMS[i] << "\"\n";
      cout << "\nVotre choix? ";
      if (!(cin >> choix))
         throw ActionInvalide{};
   } while (choix < 0 && N <= choix);
   return static_cast<Action>(choix);
}

Le véritable client de la classe Commande dans ce programme est la classe Executer, un foncteur jouant le rôle d'un automate. C'est lui qui tient à jour les commandes qu'il est possible d'annuler ou de réexécuter, qui applique l'annulation ou la réexécution, qui réalise les actions que l'usager souhaite faire, etc.

Les éléments clés de cette classe sont :

  • L'attribut canevas, qui est la chaîne que le programme éditera (l'état du programme)
  • La méthode presenter_piles(), qui présente le contenu des piles undo (commandes qu'il est possible d'annuler) et redo (commandes annulées mais qu'il est possible de réexécuter) à partir de la tête (la prochaine Commande à annuler ou à réexécuter, selon le cas). Cette méthode, bien que non-essentielle, est utile pour déboguer et comprendre la mécanique du programme
  • La méthode annuler(), qui annule la plus récente Commande exécutée et la déplace dans la pile des commandes qu'il est possible de réexécuter
  • La méthode reexecuter(), qui réexécute la plus récente Commande annulée et la déplace dans la pile des commandes qu'il est possible d'annuler
  • La méthode entrer_ligne(), qui lit une ligne à la console (en évitant un problème avec getline()), ajoute cette action dans la pile des commandes qu'il est possible d'annuler, et vide la pile des commandes qu'il est possible de réexécuter
  • La méthode visualiser(), qui ne fait qe projeter à la sortie standard le texte en cours d'édition, et
  • La méthode resultat(), qui retourne le fruit de l'édition

La méthode clé dans la gestion des actions est operator(), qui permet d'utiliser un Executer comme une fonction unaire. Cette méthode dirige l'exécution de l'automate en sollicitant les services appropriés selon les circonstances.

Notez que j'ai fait le choix de passer canevas en paramètre aux méthodes annuler(), reexecuter() et visualiser(). Ce choix est politique : je ne suis pas convaincu qu'Executer devrait être responsable de gérer canevas, et il se pourrait que je modifie le tout éventuellement pour faire en sorte que cet état clé du programme soit plutôt passé à Executer::operator() par son client.

En limitant le couplage entre les méthodes d'instance et canevas, je me garde un peu de latitude.

class AnnulationVide {};
class ErreurLecture {};
class ReexecutionVide {};
class Executer {
   string canevas;
   vector<Commande> undo, redo;
   void presenter_piles() {
      if (undo.empty())
         cout << "\nAucune commande a annuler\n";
      else {
         cout << "\nCommandes a annuler:\n";
         for (auto i = undo.rbegin(); i != undo.rend(); ++i)
            cout << '\t' << *i << '\n';
      }
      if (redo.empty())
         cout << "\nAucune commande a reexecuter\n";
      else {
         cout << "\nCommandes a reexecuter:\n";
         for (auto i = redo.rbegin(); i != redo.rend(); ++i)
            cout << '\t' << *i << '\n';
      }
      cout << endl;
   }
   void annuler(string &s) {
      if (undo.empty()) throw AnnulationVide{};
      auto cmd = move(undo.back());
      undo.pop_back();
      cmd.annuler(s);
      redo.emplace_back(move(cmd));
   }
   void reexecuter(string &s) {
      if (redo.empty()) throw ReexecutionVide{};
      auto cmd = std::move(redo.back());
      redo.pop_back();
      cmd.executer(s);
      undo.emplace_back(std::move(cmd));
   }
   void entrer_ligne() {
      string ligne;
      cout << "\nEntrez du texte puis <enter>: ";
      cin.ignore();
      if (!getline(cin, ligne))
         throw ErreurLecture{};
      undo.emplace_back(make_unique<EntreeTexte>(ligne));
      redo.clear();
      canevas += ligne;
      canevas += '\n';
      cout << '\n';
   }
   void visualiser(string_view s) {
      cout << "\nEtat de l'edition en cours:\n\n" << s << '\n';
   }
public:
   void operator()(Action action) {
      try {
         switch (action) {
         case Action::MONTRER:
            presenter_piles();
            break;
         case Action::ANNULER:
            annuler(canevas);
            break;
         case Action::AJOUTER:
            entrer_ligne();
            break;
         case Action::REEXECUTER:
            reexecuter(canevas);
            break;
         case Action::VISUALISER:
            visualiser(canevas);
            break;
         default:
            assert("Ne devrait jamais arriver ici" && false);
         }
      } catch (AnnulationVide&) {
         cerr << "\nRien a annuler\n" << endl;
      } catch (ReexecutionVide&) {
         cerr << "\nRien a reexecuter\n" << endl;
      }
   }
   const string& resultat() const {
      return canevas;
   }
}};

Enfin, le programme de test est tout simple, et s'exprime par la séquence suivante :

  • Souhaiter la bienvenue à l'usager
  • Permettre à l'usager de choisir une action
  • Tant que l'action n'est pas de quitter, exécuter cette action et permettre d'en choisir une autre
  • À la toute fin, afficher le fruit de l'édition
int main() {
   Executer executer;
   presenter();
   try {
      for (auto action = choisir_action(); !est_quitter(action); action = choisir_action())
         executer(action);
   } catch (ActionInvalide&) {
      cerr << "Action invalide choisie; fin du programme" << endl;
   } catch (ErreurLecture&) {
      cerr << "Erreur de lecture; fin du programme" << endl;
   }
   au_revoir(executer.resultat());
}

La beauté du schéma de conception Commande est qu'il permet d'abstraire sous forme d'objets polymorphiques les actions d'un programme pour les exécuter (ou les annuler) aux moments jugés opportuns.

Textes d'autres sources :

Command Query Responsibility Segregation (CQRS)

Ce schéma tient au fait que les modèles utilisés pour lire et pour modifier de l'information peuvent être distincts. Ceci correspond bien au principe de faible couplage, forte cohésion des designs orientés objets.

À ce sujet :

Décorateur

Ce schéma sert à enrichir ou à modifier (« décorer », d'où le nom du schéma de conception) une implémentation existante d'une interface.

À ce sujet :

Délégation

Par cette pratique, un objet affiche qu'il est en mesure de réaliser certaines opérations mais, à l'interne, les délègue à un autre objet. Dans certains cas, comme celui de l'agrégation telle qu'elle se présente sous COM, la délégation peut s'inscrire dans une pratique idiomatique d'optimisation. De nombreuses variantes existent, comme par exemple la programmation par politiques.

À propos de ce schéma de conception :

Dirty Flag

Cette pratique se résume à ne pas prendre action tant qu'une action n'est pas requise.

À ce sujet :

Enchaînement de méthodes

Cette pratique consiste à faire en sorte que les méthodes d'un objet puissent être littéralement « enchaînées », l'une à la suite de l'autre, dans une structure plus complexe. L'optique sous-jacente est de créer des API plus fluides.

Prenons par exemple la classe Rectangle très simple à droite. Outre ses services de base (p. ex. : des accesseurs const nommés respectivement largeur() et hauteur()), on y trouve deux services, aussi nommés largeur() et hauteur() dans cet exemple (aucune obligation de leur donner ces noms, évidemment) mais prenant cette fois un paramètre et retournant chaque fois une référence sur l'instance propriétaire de la méthode (sur *this). C'est par ces services (des mutateurs) que s'articule ici l'enchaînement de méthodes.

Si nous examinons le programme de test, la construction du Rectangle nommé r0 demande de connaître le sens associé à chacun des paramètres qui lui sont passés. Plus concrètement, dans cet exemple, est-ce que la valeur 3 représente une hauteur ou une largeur? Évidemment, les deux choix sont légitimes, et le programmeur en charge de rédiger le code client doit consulter la documentation de la classe pour l'instancier convenablement.

L'instanciation de r1, par contre, est beaucoup plus explicite. Elle repose sur :

  • La construction d'un Rectangle par défaut
  • Sur ce Rectangle par défaut anonyme, on appelle la méthode hauteur(3) ce qui en modifie la hauteur et retourne une référence sur le Rectangle suivant la modification
  • Sur ce Rectangle encore, on appelle la méthode largeur(5) ce qui en modifie la largeur et retourne une référence sur le Rectangle suivant la modification
  • Enfin, r1 est initialisé par copie du Rectangle anonyme ainsi construit, de manière explicite et étapiste

Cet exemple montre comment profiter de l'enchaînement de méthodes à la construction, mais il est évidemment possible d'appliquer cette pratique à d'autres cas d'espèces.

 

#include <iostream>
class Rectangle {
public:
   class LargeurInvalide{};
   class HauteurInvalide{};
private:
   int largeur_ = 1, hauteur_ = 1;
   static bool est_hauteur_valide(int valeur) {
      return valeur > 0;
   }
   static bool est_largeur_valide(int valeur) {
      return valeur > 0;
   }
   static int valider_hauteur(int valeur) {
      if (!est_hauteur_valide(valeur))
         throw HauteurIncorrecte{};
      return valeur;
   }
   static int valider_largeur(int valeur) {
      if (!est_largeur_valide(valeur))
         throw LargeurIncorrecte{};
      return valeur;
   }
public:
   int largeur() const {
      return largeur_;
   }
   int hauteur() const {
      return hauteur_;
   }
   Rectangle& hauteur(int valeur) {
      hauteur_ = valider_hauteur(valeur);
      return *this;
   }
   Rectangle& largeur(int valeur) {
      largeur_ = valider_largeur(valeur);
      return *this;
   }
   // etc.
   Rectangle(int largeur, int hauteur)
      : largeur_{ valider_largeur(largeur) },
        hauteur_{ valider_hauteur(hauteur) }
   {
   }
   Rectangle() = default;
   // ... etc.
};
int main() {
   //
   // ici, r est-il haut de 3 et large de 5 ou l'inverse?
   // pas clair à partir de la signature...
   //
   Rectangle r0{ 3, 5 };
   //
   // ici, c'est limpide
   //
   auto r1 = Rectangle{}.hauteur(3).largeur(5);
   
}

Quelques textes d'autres sources :

Fabrique (Factory)

L'idée derrière le schéma de conception Fabrique est de donner à une entité la responsabilité d'en créer une autre. Ce faisant, il est par exemple possible de coupler la construction d'un objet avec des opérations la précédant ou lui succédant, comme dans le cas d'un objet Autonome qui doit représenter un thread mais, le comportement qu'il représente étant polymorphique, ne peut être démarré avant d'avoir été pleinement construit.

Une autre application du schéma de conception Fabrique et de l'initialisation en deux temps est de réduire la quantité de code dans les constructeurs, préservant ainsi le rôle d'initialisation des états qui est traditionnellement dévolu à ces fonctions bien spéciales et facilitant l'entretien du code par la suite; il est plus facile de spécialiser une classe dont les constructeurs vont à l'essentiel. Voir https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rnr-two-phase-init pour des détails.

Jointes aux interfaces, les fabriques sont extrêmement utiles dans une optique de mise à jour dynamique du code. Il est en effet possible de cacher complètement les types réellement instanciés à l'intérieur du code de la fabrique, ce qui permet de modifier le code d'instanciation sans recompiler le code client.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Façade

Ce schéma de conception a pour vocation d'offrir une interface simplifiée pour une entité plus complexe. Ceci permet entre autres de corriger des défauts de design dans une API, et d'unifier l'utilisation de plusieurs composants interreliés.

Quelques textes d'autres sources :

Un Wiki sur le sujet : http://en.wikipedia.org/wiki/Facade_pattern

Implémenter ce schéma de conception en C# pour offrir une interface homogène à plusieurs conteneurs, texte d'Eric Vogel en 2014 : http://visualstudiomagazine.com/articles/2013/06/18/the-facade-pattern-in-net.aspx

Flyweight

L'idée derrière le schéma de conception Flyweight est de partager les états (immuables, dans l'immense majorité des cas) des objets entre eux, pour faciliter la création d'une vaste quantité d'objets sans consommer une quantité excessive de mémoire.

Quelques textes d'autres sources :

Injection de dépendance

Ce schéma de conception permet de définir des classes capables de réaliser des tâches à partir de la combinaison de fonctionnalités définies en partie par des tiers. Plusieurs versions de ce schéma de conception existent, certaines étant statiques alors que d'autres sont dynamiques. Une variante de cette approche est la programmation par politiques.

Un exemple polymorphique, basé sur des interfaces, serait celui visible à droite. Nous y voyons :

  • Une interface Decodeur, qui dicte comment une opération du décodage d'un flux doit se faire (ici, prendre le flux et en consommer une chaîne de caractères)
  • Quelques implémentations de cette interface, soit une qui procède une ligne à la fois, une qui procède un mot à la fois, et une qui s'arrête à la rencontre d'un délimiteur, celui-ci inclus... Cette liste n'épuise bien sûr pas les possibilités
  • Une implémentation, ZeDecodeur, qui saura comment consommer le texte d'un flux sur la base d'un Decodeur qui lui sera éventuellement fourni. Notez que cette implémentation exige un Decodeur non-nul, mais qu'on aurait aussi pu implémenteur une version par défaut, sujette à être remplacée par un Decodeur fourni par le code client si cela s'avère pertinent, et
  • Du code de test
#include <string>
#include <istream>
#include <memory>
#include <cassert>
#include <sstream>
#include <fstream>
#include <iostream>
using namespace std;
//
// Interface à implémenter
//
class FluxEpuise {};
struct Decodeur {
   virtual string consommer(istream &) = 0;
   virtual ~Decodeur() = default;
};
//
// Quelques exemples d'implémentation
//
struct DecodeurLigneParLigne : Decodeur {
   string consommer(istream &is) {
      string ligne;
      if (!getline(is, ligne)) throw FluxEpuise{};
      return ligne;
   }
};
struct DecodeurMotParMot : Decodeur {
   string consommer(istream &is) {
      string mot;
      if (!(is >> mot)) throw FluxEpuise{};
      return mot;
   }
};
class DecodeurViaDelimiteur : public Decodeur {
   char delim;
public:
   DecodeurViaDelimiteur(char delim) : delim{delim} {
   }
   string consommer(istream &is) {
      if (!is) throw FluxEpuise{};
      is >> std::noskipws;
      string s;
      char c;
      for(; is.get(c) && c != delim; s.push_back(c))
         ;
      if (is) s.push_back(c);
      is >> std::skipws;
      return s;
   }
};
//
/ La classe dans laquelle se fera l'injection
//
class ZeDecodeur {
   unique_ptr<Decodeur> decodeur_;
public:
   ZeDecodeur(unique_ptr<Decodeur> &&decodeur)
      : decodeur{ std::move(decodeur) }
   {
      assert(decodeur);
   }
   string consommer(istream &is) {
      return decodeur->consommer(is);
   }
};
//
// Exemple d'utilisation
//
void decoder(ZeDecodeur zd, istream &is, ostream &os) {
   using std::endl;
   try {
      for(;;)
         os << zd.consommer(is) << endl;
   } catch (...) {
   }
}
int main() {
   decoder(
      ZeDecodeur{
         make_unique<DecodeurLigneParLigne>()
      },
      ifstream{ "in.txt" }, cout
   );
   decoder(
      ZeDecodeur{
         make_unique<DecodeurMotParMot>()
      },
      ifstream{"in.txt"}, cout
   );
   decoder(
      ZeDecodeur{
         make_unique<DecodeurViaDelimiteur>('}')
      },
      ifstream{ "in.txt" }, cout
   );
}

Un exemple générique, moins fortement couplé, serait celui visible à droite.

La mécanique est essentiellement la même, à quelques nuances près :

  • Nous évitons l'allocation dynamique de mémoire
  • Nous évitons aussi l'imposition d'un parent commun unique tel que l'interface Decodeur de la version polymorphique
  • Nous n'avons pas la possibilité de changer d'implémentation dynamiquement si le coeur nous en dit, le type de décodeur étant inscrit dans le type de ZeDecodeur
#include <string>
#include <istream>
#include <sstream>
#include <fstream>
#include <iostream>
using namespace std;
//
// Quelques exemples d'implémentation
//
class FluxEpuise {};
struct DecodeurLigneParLigne {
   string consommer(istream &is) {
      string ligne;
      if (!getline(is, ligne)) throw FluxEpuise{};
      return ligne;
   }
};
struct DecodeurMotParMot {
   string consommer(istream &is)    {
      string mot;
      if (!(is >> mot)) throw FluxEpuise{};
      return mot;
   }
};
class DecodeurViaDelimiteur {
   char delim;
public:
   DecodeurViaDelimiteur(char delim) : delim{delim} {
   }
   string consommer(istream &is) {
      if (!is) throw FluxEpuise{};
      is >> std::noskipws;
      char c;
      string s;
      for(; is.get(c) && c != delim_; s.push_back(c))
         ;
      if (is) s.push_back(c);
      is >> std::skipws;
      return s;
   }
};
//
// La classe dans laquelle se fera l'injection
//
template <class D>
   class ZeDecodeur {
      D decodeur;
   public:
      ZeDecodeur(D decodeur) : decodeur{decodeur} {
      }
      string consommer(istream &is) {
         return decodeur.consommer(is);
      }
   };
template <class D>
   ZeDecodeur<D> CreerDecodeur(D &&decodeur) {
      return ZeDecodeur<D>{std::forward<D>(decodeur)};
   }
//
// Exemple d'utilisation
//
template <class D>
   void decoder(ZeDecodeur<D> zd, istream &is, ostream &os) {
      try {
         for(;;)
            os << zd.consommer(is) << endl;
      } catch (...) {
      }
   }
int main() {
   decoder(
      CreerDecodeur(DecodeurLigneParLigne{}),
      ifstream{ "in.txt" }, cout
   );
   decoder(
      CreerDecodeur(DecodeurMotParMot{}),
      ifstream{ "in.txt" }, cout
   );
   decoder(
      CreerDecodeur(DecodeurViaDelimiteur{'}'}),
      ifstream{ "in.txt" }, cout
   );
}

Quelques textes d'autres sources :

Critiques de cette pratique :

« To keep large programs well structured, you either need superhuman will power, or proper language support for interfaces » – Greg Nelson (source)

Interface

L'idée derrière le schéma de conception Interface est de déterminer, souvent par une abstraction polymorphique, une strate de services opaque qui seront implémentés par d'autres entités. Ceci permet d'exprimer les algorithmes sur une base plus abstraite et plus générale, et tend à mener vers du code plus réutilisable.

Ce schéma de conception est si répandu que plusieurs langages (souvent orientés objets), et pas les moins connus, en ont fait un concept de niveau langage plus qu'une pratique. Pour cette raison, nombreux sont ceux qui ne le voient plus comme un schéma de conception. Et pourtant...

À propos des interfaces en C# et de certaines subtilités propres à ce langage, voir : ../Divers--cdiese/Interfaces.html

Quelques textes d'autres sources :

Intermédiaire (Proxy)

L'idée derrière le schéma de conception Intermédiaire (on utilise souvent le nom anglais Proxy) est de placer une entité tierce entre deux entités, pour ajouter la couche d'abstraction proverbiale dont fait mention le théorème fondamental de l'informatique.

Ce schéma de conception est très répandu, en particulier dans les systèmes répartis où il facilite la mise en place d'approches comme la communication RPC par exemple.

Certains concepts ne peuvent se représenter par des intermédiaires (en C++, certains échecs bien connus comme celui de la spécialisation du type vector<bool>, qui ont essayé de représenter des booléens par des bits, en attestent). Cela n'empêche pas des expérimentations amusantes pour qui n'est pas trop puriste (comme ceci).

Quelques textes d'autres sources :

Itérateur

L'idée derrière les itérateurs est d'offrir une abstraction du concept de parcours d'une séquence. Ceci permet la rédaction d'algorithmes applicables à une séquence d'éléments, et ce de manière indépendante du conteneur dans lequel les éléments sont entreposés (arbre, vecteur, liste simplement ou doublement chaînée, etc.).

Certaines bibliothèques, dont STL pour C++, exploitent beaucoup cette pratique, ce qui permet d'approcher l'orthogonalité entre algorithmes et conteneurs, accroissant du même coup l'éventail d'outils disponibles pour fins de développement logiciel.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Critiques du schéma de conception Itérateur

Modèle/ Vue/ Contrôleur

L'approche Modèle/ Vue/ Contrôleur (MVC pour les intimes) est un schéma de conception servant au développement d'applications, le mot « application » étant pris au sens de systèmes informatiques offrant une interface personne/ machine, ce qui sied bien au développement d'interfaces Web placées par-dessus un ou plusieurs générateurs de contenu (ASP, JSP, Servlet, etc.). LE texte séminal de Trygve Reenskaug à ce sujet remonte à 1979 : https://heim.ifi.uio.no/~trygver/2007/MVC_Originals.pdf

L'idée derrière MVC est de formaliser la séparation entre une interface personne/ machine et ses mécanismes sous-jacents. Règle générale, le contrôleur est associé aux événements en entrée, le modèle est associé aux traitements sous-jacents, et la vue est associée à ce qui est proposé à l'humain, souvent dans une interface visible à l'écran. Ces trois éléments constitutifs sont en interaction mutuelle et forment un triangle.

Selon la vision MVC :

On remarquera qu'une des qualités du découpage MVC est qu'il permet d'avoir plusieurs vues sur un même modèle. Une variante, le « Modèle Document Vue », groupe la vue et le contrôleur de plus près; c'est l'approche de la bibliothèque MFC de Microsoft. Ce couplage serré a le gros défaut de rendre le code difficile à segmenter, et tend à rendre les interfaces MFC difficiles à réutiliser ou à faire évoluer dans le temps. Le Modèle Document Vue se prête surtout à des tâches pouvant être représentées par des classes terminales.

L'approche MVC a ceci d'intéressant qu'elle permet de formaliser en bonne partie un découpage et des comportements logiciels typiques. Cette formalisation entraîne une systématisation et permet de développer des infrastructures de prise en charge de bonnes parties de ces modèles. Avec Java, la technologie Struts est un bon exemple d'une infrastructure de ce genre.

Comprendre le modèle MVC permet de bien utiliser la plupart des infrastructures commerciales d'interfaces personne/ machine contemporaines.

Il existe des Frameworks spécifiquement dédiés au développement d'applications selon MVC, par exemple Angular.js : ../Web/JavaScript-Outils.html#angularjs

Quelques textes d'autres sources :

Critiques :

Modèle/ Vue/ Présentation (MVP)

Variante de MVC axée sur les interfaces personne/ machine, MVP place le volet présentation entre la vue et le modèle, et délègue à la présentation la responsabilité sur la logique applicative.

À ce sujet :

Modèle/ Vue/ Vue/ Modèle (MVVM)

Le format Web tend vers une variation de MVC par laquelle la vue est encore plus découplée du modèle qu'à l'habitude. En particulier, c'est souvent le fureteur qui doit consulter le modèle pour vérifier si des changements y ont été apportés. Cette approche, MVVM, est populaire chez les gens qui utilisent beaucoup les outils Microsoft ou des Frameworks Web tels qu'Angular.js.

Avec MVVM, le contrôleur est remplacé par un simple lien entre la vue et le modèle, et ce lien se limite souvent à une transformation des données.

À ce sujet :

Null Object

L'idée derrière le Null Object est d'avoir une implémentation par défaut (ne faisant rien) d'un comportement polymorphique donné, dans le but d'éviter les cas particuliers ou dégénérés dans le code, et ainsi de réduire les tests et les risques d'erreurs. Un Null Object peut souvent prendre la place d'une référence nulle ou d'un pointeur nul dans un programme; puisque le Null Object est un objet valide, nul besoin de vérifier s'il est là ou non – s'il est là, alors son comportement est essentiellement de ne rien faire.

Un exemple (simple) est présenté à droite, à la fois sans Null Object et avec Null Object. Dans les deux cas, l'exemple présente un combat fort inégal à trois mettant en scène les monstres Bob, Joe et Bill. Bob est armé d'un bâton, Joe est armé d'une scie à chaîne alorsque Bill est désarmé. C'est le cas d'un monstre désarmé qui nous intéresse ici.

Le premier cas n'applique pas le schéma de conception Null Object. On y représente donc les armes possédées par un monstre donné à l'aide de pointeurs, pour fins d'indirection polymorphique, et le fait d'être désarmé est représenté par un pointeur nul vers une arme.

Puisque le pointeur peut être nul, il doit être testé à chaque fois qu'on souhaite utiliser ce vers quoi il pointe. Cela signifie que chaque appel à Monstre::frapper() pour un Monstre donné implique un test (un if) et, si le test réussit, un appel polymorphique à la méthode frapper() de l'arme vers laquelle mène le pointeur.

Si les monstres désarmés sont rares, alors la majorité des tests seront superflus, gaspillant des ressources; le fait qu'un monstre désarmé soit possible impose cependant la présence de ce test (l'oublier pourrait faire planter le programme).

#include <string>
#include <random>
#include <algorithm>
#include <iostream>
using namespace std;
//
// Quelques globales pour alléger l'exemple...
// (ne faites pas ça à la maison!)
//
random_engine rd;
mt19937 prng{ rd() };
uniform_int_distribution<int> distrib{ 10,50 };
//
//
//
class Monstre;
struct Arme {
   virtual void frapper(Monstre &) = 0;
   virtual ~Monstre() = default;
};
class Monstre {
   int vie;
   Arme *arme;
   string nom_;
public:
   Monstre(const string &nom, Arme *arme)
      : nom_{ nom }, vie{ 100 }, arme{ arme }
   {
   }
   string nom() const {
      return nom_;
   }
   void bobo(int degats) {
      vie -= degats;
   }
   bool mort() const noexcept {
      return vie <= 0;
   }
   void frapper(Monstre &m) {
      if(arme)
         arme->frapper(m);
   }
};
struct Baton : Arme {
   void frapper(Monstre &m) {
      m.bobo(distrib(prng));
   }
};
struct Chainsaw : Arme {
   void frapper(Monstre &m) {
      m.bobo(distrib(prng));
   }
};
bool est_vivant(const Monstre &m) {
   return !m.mort();
}
int main() {
   Chainsaw chainsaw;
   Baton baton;
   Monstre monstres[] {
      Monstre("Bob", &chainsaw),
      Monstre("Joe", &baton),
      Monstre("Bill", nullptr) // désarmé!
   };
   enum { N = std::size(monstres) };
   uniform_int_distribution<int> monstre_distrib{ 0, N-1 };
   while(count(begin(monstres), end(monstres), est_vivant) > 1) {
      // un monstre peut s'auto-mutiler ou s'acharner
      // sur un cadavre (ils sont bêtes)
      if (auto &m = monstres[monstre_distrib(prng)]; !m.mort())
         m.frapper(monstres[monstre_distrib(prng)];
   }
   cout << "Le gagnant: "
        << find_if(begin(monstres), end(monstres), est_vivant)->nom()
        << endl;
}

En appliquant le schéma de conception Null Object, le test devient redondant, comme le montre l'exemple à droite.

Notez que, pour mieux illustrer le principe, ce nouvel exemple utilise des références plutôt que des pointeurs à titre d'indirection polymorphique. En C++, outre quelques perversions techniques, une référence ne peut être nulle.

Le code est plus simple, en général plus rapide (à moins que le cas particulier que représente le Null Object ne soit en fait un cas fréquent, car dans ce cas, les tests avec if pourraient coûter moins cher que des appels polymorphiques répétés (et encore, si nous considérons les risques accrus de sécurité qu'entraîne la nécessité de tester le pointeur chaque fois, il n'est pas clair que ce soit un réel gain).

Enfin, nous avons un gain documentaire : le concept d'être désarmé est représenté par un type, dûment nommé, et ne requiert plus vraiment qu'on l'accompagne de commentaires.

#include <string>
#include <random>
#include <string>
#include <algorithm>
#include <iostream>
using namespace std;
//
// Quelques globales pour alléger l'exemple...
// (ne faites pas ça à la maison!)
//
random_engine rd;
mt19937 prng{ rd() };
uniform_int_distribution<int> distrib{ 10,50 };
//
//
//
class Monstre;
struct Arme {
   virtual void frapper(Monstre &) = 0;
   virtual ~Monstre() = default;
};
class Monstre {
   int vie;
   Arme &arme;
   string nom_;
public:
   Monstre(const string &nom, Arme &arme)
      : nom_{ nom }, vie{ 100 }, arme{ arme }
   {
   }
   string nom() const {
      return nom_;
   }
   void bobo(int degats) {
      vie -= degats;
   }
   bool mort() const noexcept {
      return vie <= 0;
   }
   void frapper(Monstre &m) {
      arme.frapper(m);
   }
};
struct Baton : Arme {
   void frapper(Monstre &m) {
      m.bobo(distrib(prng));
   }
};
struct Chainsaw : Arme {
   void frapper(Monstre &m) {
      m.bobo(distrib(prng));
   }
};
struct Desarme : Arme {
   void frapper(Monstre &) {
   }
};
bool est_vivant(const Monstre &m) {
   return !m.mort();
}
int main() {
   Chainsaw chainsaw;
   Baton baton;
   Desarme desarme;
   Monstre monstres[] {
      Monstre{ "Bob", chainsaw },
      Monstre{ "Joe", baton },
      Monstre{ "Bill", desarme }
   };
   enum { N = std::size(monstres) };
   uniform_int_distribution<int> monstre_distrib{0, N-1};
   while(count(begin(monstres), end(monstres), est_vivant) > 1) {
      // un monstre peut s'auto-mutiler ou s'acharner
      // sur un cadavre (ils sont bêtes)
      if (auto &m = monstres[monstre_distrib(prng)]; !m.mort())
         m.frapper(monstres[monstre_distrib(prng)];
   }
   cout << "Le gagnant: "
        << find_if(begin(monstres), end(monstres), est_vivant)->nom()
        << endl;
}

Quelques textes d'autres sources :

Observateur

L'idée derrière le schéma de conception Observateur est de formaliser l'idée d'un service d'abonnement, par exemple pour réagir à des événements dans une interface personne/ machine ou lors de l'arrivée de données sur un flux.

Le code à droite est un petit exemple écrit en réponse à une question de Zinedine Bedrani, cohorte 07 du DDJV et qui utilise quelques trucs chouettes de C++ 11 comme auto, les λ et les shared_ptr.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Critiques :

Texte d'Ingo Maier, Tiark Rompf et Martin Odersky en 2010, qui recommande de déprécier ce schéma de conception, et de le remplacer par des pratiques de programmation réactive : https://infoscience.epfl.ch/record/148043/files/DeprecatingObserversTR2010.pdf

#include <locale>
#include <vector>
#include <memory>
#include <algorithm>
#include <iostream>
using namespace std;
struct ILecteurTouches {
   virtual void reagir(char) = 0;
   virtual ~ILecteurTouches() = default;
};
class DejaAbonne {};
class PasAbonne {};
class serveur_touches {
   vector<shared_ptr<ILecteurTouches>> abonnes;
public:
   void abonner(shared_ptr<ILecteurTouches> p) {
      if (!p) return;
      if (find(begin(abonnes), end(abonnes), p) != end(abonnes))
         throw DejaAbonne{};
      abonnes.push_back(p);
   }
   void desabonner(shared_ptr<ILecteurTouches> p) {
      if (!p) return;
      auto it = find(begin(abonnes), end(abonnes), p);
      if (it == end(abonnes))
         throw PasAbonne{};
      abonnes.erase(it);
   }
   void agir() {
      if (char c; cin >> c)
         for(auto & p : abonnes_)
           p->reagir(c); // <-- observateur!
   }
};
//
// supposons une classe qui gère la logique du jeu
// (simplifiée à l'ultra-extrême, bien entendu)
//
class Jeu {
   bool fini = false;
public:
   void quitter() {
      fini = true; }
   }
   bool fin() const {
      return fini;
   }
};
class afficheur_touche : public ILecteurTouches {
   void reagir(char c) {
      cout << c;
   }
};
class evenement_quitter : public ILecteurTouches {
   Jeu &jeu;
   locale &loc;
public:
   evenement_quitter(Jeu &jeu, const locale &loc = locale{""})
      : jeu{ jeu }, loc{ loc }
   {
   }
   void reagir(char c) {
      if (toupper(c, loc) == 'Q') // bof
         jeu.quitter(); // par exemple
   }
};
int main() {
   Jeu jeu;
   serveur_touches svr;
   svr.abonner(make_shared<evenement_quitter>(jeu)));
   svr.abonner(make_shared<afficheur_touche>()));
   while (!jeu.fin())
      svr.agir();
}

Ordonnanceur

L'idée derrière ce schéma de conception est de formaliser un mécanisme décrivant l'ordre dans lequel des tâches seront réalisées, et de mettre en place les requis pour assurer leur synchronisation.

Quelques textes d'autres sources :

Regroupement (Pooling)

L'idée derrière ce schéma de conception est de réduire le coût de la création dynamique de ressources (souvent des ressources lourdes comme des threads, des outils de synchronisation ou des connexions à des bases de données) en créant ces ressources a priori puis en les distribuant au besoin à ceux qui en ont besoin.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Singleton

L'idée derrière les singletons est d'avoir une classe telle qu'elle ne puisse être instanciée qu'une seule fois par programme, pas plus, tout en évitant que cette contrainte ne dépende de la gentillesse et de la discipline des programmeuses et des programmeurs.

Quelques textes de votre humble serviteur :

Quelques textes d'autres sources :

Critiques du schéma de conception Singleton

State

Le schéma de conception State tient à la représentation des états d'un automate par des objets, et à la navigation d'un objet à l'autre en fonction des circonstances en tant que flux d'exécution du programme résultant. En général, on utilise ce schéma de conception pour éviter de recourir à une masse d'alternatives ou à de très longues sélectives.

Un exemple viendra quand j'aurai quelques minutes.

Quelques textes d'autres sources :

Stratégie

Le schéma de conception Stratégie tient à offrir plusieurs implémentations pour une même interface, tout en laissant le code client faire le choix de l'implémentation en fonction du contexte. Il ressemble en ceci aux idiomes NVI et pImpl, qui vont tous deux plus en détail dans les modalités. Ce schéma de conception permet entre autres à un même client de se construire à partir de plusieurs stratégies comportementales distinctes.

Quelques textes d'autres sources :

Template Method

Ce schéma de conception encadre l'exécution d'un groupe de fonctions par une « fonction cadre » qui sert de modèle général au traitement, tout en permettant de spécialiser les étapes de ce traitement.

 Par exemple, imaginons un robot dont la tâche est, de manière récurrente :

  • Regarder autour de lui
  • S'il voit des individus, détecter ceux qui semblent être des intrus
  • S'il y en a, trouver le plus près d'entre eux, et le confronter

Nous savons que plusieurs types de robots existent, que chaque type de robot a ses propres capteurs, ses propres modalités de mouvement, sa propre stratégie algorithmique de détection des intrus, sa propre approche à la résolution de conflits, etc. mais que l'algorithme général est celui présenté ici.

Avec Template Method, une classe parent (disons Robot) décrira l'algorithme général en encadrant des opérations (probablement protected) des classes dérivées, et ces dernières implémenteront le détail des opérations qui sont sollicitées par l'algorithme cadre.

De cette manière, tous les types de Robot auront un comportement conforme lorsque pris « à haut niveau », mais pourront tout de même exprimer leurs spécificités dans le détail de leur action.

#include <algorithm>
#include <vector>
#include <iterator>
// ...
class Individu { /* ... */ };
class Robot {
public:
   virtual ~Robot() = default;
   // algorithme cadre
   void agir() {
      using namespace std;
      vector<Individu> v = examiner();
      vector<Individu> intrus;
      copy_if(begin(v), end(v), back_inserter(intrus), [&](const Individu &ind) {
         return semble_intrus(ind);
      });
      if(!intrus.empty()) {
         auto lequel = trouver_plus_proche(intrus);
         confronter(lequel);
      }
   }
protected:
   // méthodes à spécialiser
   virtual std::vector<Individu> examiner() const = 0;
   virtual bool semble_intrus(const Individu &) const = 0;
   virtual Individu trouver_plus_proche(const std::vector<Individu>&) const = 0;
   virtual void confronter(const Individu&) = 0;
};

Quelques textes d'autres sources :

Visiteur

Le visiteur est un drôle d'oiseau, qui est difficile d'entretien lorsqu'il est mis en application de manière classique mais dont on ne voudrait pas se passer pour la navigation de structures complexes. Certains (comme Vincent Thériault, un de mes anciens étudiants à la cohorte 02 du DDJV) font des miracles avec ce schéma de conception. D'autres (comme Andrei Alexandrescu, dans son livre Modern C++ Design) essaient d'en atténuer la lourdeur.

Les pratiques avec ce schéma de conception se raffinent encore aujourd'hui, en couplant polymorphisme et généricité. Le fin mot reste à venir...

L'idée derrière le visiteur est de permettre à un objet de naviguer la structure d'une autre objet de l'intérieur. Ceci permet entre autres de distinguer la navigation de structures complexes, par exemple des arbres et des graphes, des opérations faites lors de cette navigation (modification de certaines noeuds, affichage de leur contenu).

L'exemple donné à droite est celui d'un arbre binaire générique minimaliste. Un prédicat donné à la compilation permet à l'arbre de décider chaque fois si un élément en cours d'ajout doit être placé à gauche ou à droite d'un noeud donné.

Les méthodes visiter(), déclinées en version const et non-const, permettent à un foncteur de s'inviter dans une instance de cette classe pour y appliquer des opérations sur les valeurs de chaque noeud.

Le programme principal montre deux exemples de tels visiteurs, soit l'un qui affichera chaque noeud (traversée en profondeur, de gauche à droite) et l'autre qui doublera la valeur de chaque noeud.

Le schéma de conception Visiteur couple les objets capables de visiter avec les méthodes qui permettent de les accueillir et de les faire naviguer dans la structure interne d'un objet. On pourrait les qualifier d'itérateurs intrusifs, en quelque sorte.

Suite à une séance en classe avec les chics étudiants de la cohorte 07 du DDJV, j'ai ajouté un exemple permettant d'injecter un foncteur capable d'accumuler de l'information sur les noeuds visités. Pour ce faire, j'ai fait passer les fonctions visiter() du type void au type F, donc au type du paramètre représentant l'opération en cours de visite. Cette sémantique est connexe à celle utilisée pour std::for_each() dans STL, mais demande que les opérations visiteuses puissent être copiées. Ceci explique le recours à une sémantique de mouvement dans le foncteur aff dans le programme principal – l'affectation n'est pas implémentée sur un flux tel qu'un std::ostream.

struct PlusPetitQue {
   template <class T>
      bool operator()(const T &a, const T &b) {
         return a < b;
      }
};

template <class T, class Pred = PlusPetitQue>
   class arbre_binaire {
   public:
      using value_type = T;
   private:
      struct Noeud {
         value_type valeur;
         Noeud *gauche {}, *droite{};
         Noeud(const value_type &valeur) : valeur{ valeur } {
         }
      };
      Noeud *racine {};
      Pred pred;
      void ajouter(const value_type &valeur, Noeud *p) {
         if (pred(valeur, p->valeur))
            if (p->gauche)
               ajouter(valeur, p->gauche);
            else
               p->gauche = new Noeud(valeur);
         else
            if (p->droite)
               ajouter(valeur, p->droite);
            else
               p->droite = new Noeud(valeur);
      }
   public:
      arbre_binaire(Pred pred = {}) : pred{pred} {
      }
      bool empty() const noexcept {
         return !racine;
      }
      void ajouter(const value_type &valeur) {
         if (empty())
            racine = new Noeud{valeur};
         else
            ajouter(valeur, racine);
      }
   private:
      void clear_from(Noeud *p) {
         if (p->gauche) {
            clear_from(p->gauche);
            delete p->gauche;
         }
         if (p->droite) {
            clear_from(p->droite);
            delete p->droite;
         }
      }
   public:
      void clear() noexcept {
         if (!racine) return;
         clear_from(racine);
         delete racine;
         racine = {};
      }
      ~arbre_binaire() {
         clear();
      }
   private:
      template <class F>
         F visiter(F fct, Noeud *p, int depth) {
            fct(p->valeur, depth);
            if (p->gauche) fct = visiter(fct, p->gauche, depth + 1);
            if (p->droite) fct = visiter(fct, p->droite, depth + 1);
            return fct;
         }
      template <class F>
         F visiter(F fct, const Noeud *p, int depth) const {
            fct(p->valeur, depth);
            if (p->gauche) fct = visiter(fct, p->gauche, depth + 1);
            if (p->droite) fct = visiter(fct, p->droite, depth + 1);
            return fct;
         }
   public:
      template <class F>
         F visiter(F fct) {
            if (!racine_) return fct;
            return visiter(fct, racine_, 0);
         }
      template <class F>
         F visiter(F fct) const {
            if (!racine_) return fct;
            return visiter(fct, racine_, 0);
         }
   };

#include <algorithm>
#include <iostream>
#include <string>
#include <random>

int main() {
   using namespace std;
   random_engine rd;
   mt19937 rng{ rd() };
   int vals[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
   shuffle(begin(vals), end(vals), rng);
   arbre_binaire<int> arbre;
   for(auto val : vals)
      arbre.ajouter(val);
   class aff {
      std::ostream &os;
   public:
      aff(std::ostream &os) : os{ os } {
      }
      void operator()(int i, int depth) {
         os << std::string(depth, ' ') << i << endl;
      }
   };
   class cumuler {
      int cumul {};
   public:
      cumuler() = default;
      void operator()(int val, int) {
         cumul += val;
      }
      int valeur() const {
         return cumul;
      }
   };
   arbre.visiter(aff{ cout });
   arbre.visiter([](int &val, int) { val *= 2; });
   arbre.visiter(aff{ cout });
   cout << "Somme: "
        << arbre.visiter(cumuler{}).valeur()
        << endl;
}

Quelques textes d'autres sources :


La section décrivant les principaux schémas de conception en parallélisme a été déplacée dans une page à part entière, à même la section sur le parallélisme et la programmation concurrente de ce site.


Vers le musée des horreurs.


Valid XHTML 1.0 Transitional

CSS Valide !