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.
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 :
- Les idées de Christopher Alexander expliquées aux programmeuses et
aux programmeurs
OO, par Doug Lea en 1993 :
http://www.patternlanguage.com/bios/douglea.htm
- Un Wiki général, servant de catalogue les schémas
de conception logiciels : http://en.wikipedia.org/wiki/Category:Software_design_patterns
- Un autre Wiki général, décrivant les schémas
de conception logiciels : http://en.wikipedia.org/wiki/Design_pattern_%28computer_science%29
- Un site quelque peu pédagogique sur le sujet : http://sourcemaking.com/
- Livre en ligne de pratiques du monde du jeu vidéo, colligées par
Robert Nystrom :
http://gameprogrammingpatterns.com/ (pour
l'histoire derrière l'oeuvre, voir
http://journal.stuffwithstuff.com/2014/04/22/zero-to-95688-how-i-wrote-game-programming-patterns/)
- Quelques rubriques du Engineering Cookbook, par
Robert C. Martin, originalement publiées dans les années
'90 dans The C++ Report :
- Un texte de Peter
Norvig sur les schémas de conception dans les langages dynamiques :
http://www.norvig.com/design-patterns/
(pour le même texte mais en format « présentation », voir
http://norvig.com/design-patterns/design-patterns.pdf)
- Une réflexion avec exemples sur les schémas de conception,
par Tony Marston : http://www.tonymarston.net/php-mysql/design-patterns.html
- Ce qui se produit quand un langage finit par absorber une pratique
répandue à même ses propres idiomes : http://robey.lag.net/2011/04/30/dissolving-patterns.html
- De la pertinence d'apprendre les schémas de conception d'un
domaine donné, par Tim Benke en 2012 :
http://timbenke.de/?p=457
- Les schémas de conception seraient-ils en fait des concepts
manquants dans nos
langages de programmation?
http://c2.com/cgi/wiki?AreDesignPatternsMissingLanguageFeatures
- Texte d'Adam Petersen en 2012 sur ce
que sont (et ne sont pas) les schémas de conception :
http://www.adampetersen.se/articles/signs/signs.htm
- Résumé graphique de certains des schémas de conception les plus
connus :
http://www.celinio.net/techblog/wp-content/uploads/2009/09/designpatterns1.jpg
- Rappel dialectique sur l'importance des schémas de conception, en
particulier du livre séminal sur le sujet, par
Robert C. Martin en
2014 :
http://blog.cleancoder.com/uncle-bob/2014/06/30/ALittleAboutPatterns.html
-
Robert Nystrom « revisite » certains schémas ce conception
classiques dans le contexte du développement de jeux vidéos :
http://gameprogrammingpatterns.com/design-patterns-revisited.html
- Marc Brooker propose, dans ce texte de 2015,
ce qu'il qualifie de « défense tranquille » des schémas de conception :
http://brooker.co.za/blog/2015/01/25/patterns.html
- À propos de l'adhérence (ou non) aux principes des schémas de
conception, par Arne Mertz en 2015 :
http://arne-mertz.de/2015/02/adherence-to-design-patterns/
- Une implémentation
Java pour les schémas de conception du GoF, par
Bauke Scholtz :
https://gof-design-patterns.zeef.com/bauke.scholtz
- On a traité
Robert C. Martin de Pattern Pusher, en
2015. Que veut-on dire par là?
http://blog.cleancoder.com/uncle-bob/2015/07/05/PatternPushers.html
- En 2015, Egon Elbre propose de prendre
le temps de réapprendre les schémas de conception :
https://medium.com/@egonelbre/relearning-design-patterns-912f5094ffee
- Répertoire de schémas de conception et de propositions
d'implémentations en
Java :
https://java-design-patterns.com/patterns/
- Survol très sommaire de quelques schémas de conception
d'architecture logicielle, proposé par Vijini Mallawaarachchi en 2017 :
https://towardsdatascience.com/10-common-software-architectural-patterns-in-a-nutshell-a0b47a1e9013
- Réflexion sur l'apport historique des schémas de conception, par
Chris Oldwood en 2021 :
https://accu.org/journals/overload/28/160/overload160.pdf#page=22
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 :
- Supposons que vous développiez une interface personne machine dans
laquelle il existe des méthodes pour réagir à des événements
- Supposons que vous nommiez ces méthodes
OnClick(), OnQuit(),
OnMouseMove(), etc.
- Vous constatez qu'il existe deux manières de fermer l'application,
soit de passer par un menu tel que Fichier->Quitter
ou par le bouton Quitter
É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 :
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 :
- Il est simple, au sens où il s'exprime simplement, en peu de mots, et de
manière homogène peu importe le type
- Il est sécuritaire en tout temps, même si la source et la destination sont
un seul et même objet
- Il n'implique pas de test supplémentaire pour éviter le cas pathologique
de l'affectation sur soi
- Il réduit le couplage dans le code, en réduisant la quantité de
code redondant, du fait qu'il exploite des opérations qui seront déjà
implémentées (constructeur de copie, destructeur) et une autre
opération, swap(), qui peut pratiquement
toujours être de complexité constante,
, et se faire sans risque de lever d'exceptions
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 7 :
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 :
-
https://en.cppreference.com/w/cpp/language/raii
- Une description de cet idiome : http://www.hackcraft.net/raii/
- Une autre description de l'idiome, cette fois sous un autre nom (AC/ DC,
pour Acquire in Constructor, Destructor Cleanup) : http://blog.brush.co.nz/2009/02/raii-acdc/
- Les blocs using de C# :
- Les blocs try-with de Java 7 :
- Une réflexion sur le modèle de types de Java,
en lien avec cet idiome : http://www.artima.com/lejava/articles/javaone2009_gwyn_fisher.html
- Une variante reposant sur une macro : http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/07/21/the-final-macro.aspx
- Une réflexion sur le nom de l'idiome, par Andrzej Krzemieński
en 2012 : http://akrzemi1.wordpress.com/2012/03/09/resource-or-session/
- En 2012, Martin C. Martin se lance dans une
défense de cet idiome : http://blog.martincmartin.com/2012/07/06/in-defense-of-implicit-code-in-c/
- Dans cette présentation de 2012,
Jon Kalb recommande qu'on utilise plutôt
le vocable Responsibility Acquisition Is Initialization :
http://exceptionsafecode.com/slides/esc.pdf
- Il est possible, en
C++,
de spécialiser une méthode sur la base du traitement de
*this en tant que référence ou en tant que
référence sur un
rvalue. Cette possibilité peut améliorer certaines manoeuvres
associées à l'idiome RAII. À cet effet, article de 2014 :
http://kukuruku.co/hub/cpp/ref-qualified-member-functions
- En 2015, Pavel Frolov propose une technique
RAII pour couvrir des cas qui seraient déplaisants à traiter avec un
pointeur
intelligent :
http://accu.org/index.php/journals/2086
- Appliquer RAII avec
D,
texte de 2015 :
https://w0rp.com/blog/post/an-raii-constructor-by-another-name-is-just-as-sweet/
- Même dans les outils de tests, l'approche
RAII est saine, comme le rappelle Andrzej Krzemieński en
2015 :
https://akrzemi1.wordpress.com/2015/06/25/set-up-and-tear-down/
- Comme le rappelle avec justesse Eli Bendersky en 2016,
RAII n'a pas besoin d'exceptions pour
être un idiome pertinent :
http://eli.thegreenplace.net/2016/c-raii-without-exceptions/
- Texte de Malte Skarupke en 2016 expliquant en quoi
RAII peut simplifier la composition :
https://probablydance.com/2016/06/03/c11-completed-raii-making-composition-easier/
(prudence : les idées sont intéressantes mais certains exemples de code sont
incorrects)
- Plusieurs langages font le choix de laisser à la disposition du code
client des mécanismes de type
RAII, mais
manuels (blocs try-with
de Java,
blocs using de
C#,
instruction defer de
Go,
etc.). Toutefois, en 2022, Kevin Cox met de
l'avant que cela signifie que par défaut, ces langages proposent le
comportement incorrect (donc que
RAII
devrait être la norme, pas l'exception) :
https://kevincox.ca/2022/05/13/wrong-by-default/
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 :
- Un Wiki servant de catalogue aux Dark Patterns : http://wiki.darkpatterns.org/Home
- Un Wiki servant de catalogue aux Anti-Patterns : http://en.wikipedia.org/wiki/Anti-pattern
- Un autre catalogue d'Anti-Patterns : http://c2.com/cgi/wiki?AntiPatternsCatalog
- Des « pratiques » malsaines qui existent souvent dans
du logiciel écrit avant la vague structurante ayant découlé
de la publication du volume
du Gang of Four : http://fuzz-box.blogspot.com/2011/05/resign-patterns.html
- Un exemple « amusant » d'anti-pattern, nommé
« action à distance » : http://en.wikipedia.org/wiki/Action_at_a_distance_pattern
- Un exemple humoristique, le Front-Ahead Design, où tout
est placé dans l'interface personne/ machine : http://thedailywtf.com/Articles/FrontAhead-Design.aspx
- En 2012, Mike Hadlow décrit le fait d'utiliser
une base de données en tant que file d'attente comme étant un
anti-pattern : http://mikehadlow.blogspot.se/2012/04/database-as-queue-anti-pattern.html
- Des Dark Patterns pour les interfaces personne/ machine, texte de
2013 par Harry Brignull :
http://www.theverge.com/2013/8/29/4640308/dark-patterns-inside-the-interfaces-designed-to-trick-you
- En 2011,
Raymond Chen décrit ce qu'il nomme le
for-if antipattern, manière très inefficace de réaliser une tâche qui
devrait être simple :
http://blogs.msdn.com/b/oldnewthing/archive/2011/12/27/10251210.aspx
- Un antipattern bien connu est le Crunch Mode, ou
livraison de dernière minute qui demande beaucoup de temps supplémentaire.
Texte de Chad Fowler en 2014 expliquant comment se
débarrasser de cette vilaine pratique :
http://chadfowler.com/blog/2014/01/22/the-crunch-mode-antipattern/
- Liste de mauvaises pratiques avec
Python,
colligées par Constantine Lignos en 2014 :
http://lignos.org/py_antipatterns/
- En 2014, Edaqa Mortoray disserte sur ce qu'il
nomme False Abstraction :
http://mortoray.com/2014/08/01/the-false-abstraction-antipattern/
- Texte de Mike Hadlow en 2014 sur l'antipattern
qu'il nomme Lava Layer :
http://mikehadlow.blogspot.co.uk/2014/12/the-lava-layer-anti-pattern.html
- Ce que ce texte de 2015 par Matthew Jones
nomme Inner Platform, où ce qui se produit quand un logiciel, en
abusant de flexibilité, finit par devenir une pâle émulation d'un autre :
http://www.exceptionnotfound.net/the-inner-platform-effect-anti-pattern-primers/
- En 2015, Sahand Saba rapporte neuf mauvaises
pratiques qui, selon lui, devraient être connues des programmeuses et des
programmeurs :
http://sahandsaba.com/nine-anti-patterns-every-programmer-should-be-aware-of-with-examples.html
- Selon ce texte de 2015, l'anti-pattern le plus coûteux serait... le
recours malvenu à printf(), particulièrement dans une page Web :
http://m1el.github.io/printf-antipattern/
- Selon Piotr Solnica en 2015, et je suis plutôt
d'accord avec lui, le recours à des objets placés dans un état invalide est un
antipattern :
http://solnic.eu/2015/12/28/invalid-object-is-an-anti-pattern.html
- Le for...case, expliqué en
2017 par Remy Porter :
http://thedailywtf.com/articles/a-foursome-of-arrays (et pour une suite :
http://thedailywtf.com/articles/your-private-foursome)
- Une mauvaise pratique, au sens de pratique foncièrement malhonnête, dans
le cadre d'interfaces personne / machine, rapportée par
Raymond Chen
en 2018 :
https://blogs.msdn.microsoft.com/oldnewthing/20180730-00/?p=99365
- Ensemble de mauvaises pratiques en
C++,
colligées par
Jonathan
Wakely :
http://kayari.org/cxx/antipatterns.html
- En 2017, Michael Nygard prétend que les
Entity Services sont une mauvaise pratique et ajoutent une complexité
inutile aux systèmes sans que le retour sur l'investissement ne soit
suffisant :
http://www.michaelnygard.com/blog/2017/12/the-entity-service-antipattern/
- Ces mauvaises pratiques qui deviennent des pratiques, tout simplement,
texte de Jasper Sprengers en 2017 :
https://blog.codecentric.de/en/2017/09/anti-patterns-become-pattern/
- Mauvaises pratiques de programmation en binômes, par Siddarth en
2017 :
https://sidstechcafe.com/pair-programming-antipatterns-xperience-792fe0112aa1
- Mauvaises pratiques de tests logiciels, identifiées et expliquées par Kostis Kapelonis
en 2018 :
http://blog.codepipes.com/testing/software-testing-antipatterns.html
- Les alternatives (les if) présentés comme une mauvaise pratique, du moins
dans bien des cas, selon Joe Wright en 2016 :
https://code.joejag.com/2016/anti-if-the-missing-patterns.html
- En 2018, Sumit (je ne connais pas son nom de
famille) rapporte quelques mauvaises pratiques avec
Java :
https://sumitonsoftware.wordpress.com/2018/09/03/software-design-anti-patterns/
- Dans ce texte de 2019, Christian Findlay prend
un peu de recul face à l'appellation antipattern et relativise cette
idée :
https://christianfindlay.com/2019/06/01/anti-patterns/
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 :
- Parfois, on parle du même concept exprimé différemment (par exemple, une
classe
C++
qui exposerait des itérateurs avec des méthodes First()
et PastEnd() alors que la métaphore standard
de ce langage demande begin() et
end())
- Parfois, on parle d'ajouter des éléments manquants mais triviaux à déduire
à partir d'une interface existante (ceci peut se faire à l'aide de
traits par exemple)
- Enfin, il faut parfois construire une mécanique complète à partir d'une
autre mécanique, comme dans le cas des
reverse_iterator qui peuvent être élaborés sur la base d'itérateurs
bidirectionnels existants
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 :
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 :
- Tout comme il y a des classes fabriques, il y a des méthodes fabriques.
À leur sujet, un Wiki : http://en.wikipedia.org/wiki/Factory_method_pattern
- Un texte de Guy Peleg décrivant une mécanique pour enregistrer
des types d'objets dans une fabrique générique en C++ :
http://www.artima.com/cppsource/subscription_problem.html
- Une critique des abus de ce schéma de conception, par Mark Seeman
en 2011. Notez que l'auteur utilise des
langages
.NET
et a les avantages et les inconvénients de ce modèle.
En C++,
j'aurai réglé son problème en le découplant à
l'aide de traits, et le polymorphisme
n'aurait pas été nécessaire. Voyez-vous comment? http://blog.ploeh.dk/2011/12/19/FactoryOverload.aspx
- En 2014, Kenneth Parker explique pourquoi il
utilise des fabriques :
http://startingdotneprogramming.blogspot.ca/2014/04/factory-methods-are-better.html
- Implémenter une fabrique avec
Go,
par Matthew Brown en 2016 :
http://matthewbrown.io/2016/01/23/factory-pattern-in-golang/
- En 2015 et en 2016,
Bartlomiej Filipek relate ses efforts pour implémenter des fabriques :
- En 2018, Bartlomiej Filipek propose une
technique pour implémenter une fabrique avec mécanisme d'enregistrement pour
les types qu'elle sera appelée à fabriquer :
https://www.bfilipek.com/2018/02/factory-selfregister.html (ce texte est
suivi de
https://www.bfilipek.com/2018/02/static-vars-static-lib.html car sa
proposition dépend de l'ordre d'initialisation de variables globales, et
devient complexe lorsque mis en oeuvre dans une bibliothèque à liens
statiques)
- Texte de 2022 par
Jonathan Boccara, qui discute des fabriques abstraites :
https://www.fluentcpp.com/2022/04/06/design-patterns-vs-design-principles-abstract-factory/
- Toujours en 2022,
Jonathan Boccara aborde la question des méthodes de fabrication :
https://www.fluentcpp.com/2022/06/05/design-patterns-vs-design-principles-factory-method/
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 :
- Article de Griffin Caprio, en 2005, montrant
des exemples d'application de ce schéma de conception pour la
plateforme
.NET : http://msdn.microsoft.com/fr-fr/magazine/cc163739%28en-us%29.aspx
- Texte de Martin
Fowler sur le sujet : http://www.martinfowler.com/articles/injection.html
- Variante connue, l'inversion de contrôle, décrite par Martin
Fowler en 2005 :
http://martinfowler.com/bliki/InversionOfControl.html
- Texte de Dhananjay Nene, en 2005, montrant des
exemples d'application de ce schéma de conception pour la plateforme
Java :
http://www.theserverside.com/news/1321158/A-beginners-guide-to-Dependency-Injection
- Tutoriel pour introduire à cette pratique, par Rick Hightower en
2011 : http://code.google.com/p/jee6-cdi/wiki/DependencyInjectionAnIntroductoryTutorial_Part1
- Un texte de 2012 à propos du passage
de code où se produit une injection de legs à code où
se produit une injection de dépendances : http://blogs.telerik.com/blogs/posts/12-04-04/from-legacy-to-dependency-injection.aspx
- Comparer l'injection de dépendance et la
programmation orientée aspect :
- Refactoriser le code pour favoriser l'injection de dépendances, un texte
d'Ondrej Balas en 2014 :
- Injection de dépendances en
JavaScript en passant par des propriétés plutôt que par des constructeurs,
un texte de Stefan Isele en 2014 :
http://stefan-isele.logdown.com/posts/200710-javascript-dependency-injection-into-properties
- Injection de dépendances avec Go, texte de
2014 :
http://blog.parse.com/2014/05/13/dependency-injection-with-go/
- En 2015, Derek Comartin recommande de jeter à
la poubelle nos conteneurs d'injection de dépendances :
http://codeopinion.com/throw-out-your-dependency-injection-container/
- Injection de dépendances avec
Java,
par Hany Ahmed en 2015 :
https://dzone.com/articles/javaee-contexts-and-dependency-injection
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 :
- Le modèle contient l'essentiel des données nécessaires au bon fonctionnement de l'application – ses états. Pensez à une classe pourvue principalement d'accesseurs et de manipulateurs. Le modèle ne sait rien de la vue ou du contrôleur;
- La vue présente l'apparence de l'application. Règle générale, la vue peut consulter les états du modèle (faire des
get) mais ne peut les modifier (faire des set). La vue ne devrait pas connaître le contrôleur. La vue doit par contre être avertie lorsque le modèle est modifié, de manière à présenter une information à jour – le modèle est en général chargé de cette tâche;
- Le contrôleur est chargé de réagir aux entrées faites par l'usager. Il est chargé de créer et d'initialiser le modèle.
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 :
- Descriptifs de ce schéma de conception :
- Un Wiki sur ce sujet : http://en.wikipedia.org/wiki/Model-view-controller
- Une explication bien faite sur MSDN : http://msdn.microsoft.com/en-us/library/ff649643.aspx
- Un texte de Thomas Davis s'attardant sur la partie modèle du schéma
de conception MVC : http://backbonetutorials.com/what-is-a-model/
- Un texte d'Edward Z. Yang discutant de MVC dans
une optique de pureté, ce mot étant pris ici au sens de pureté
dans la programmation
fonctionnelle : http://blog.ezyang.com/2010/07/mvc-and-purity/
- Une variante de Martin
Fowler, la présentation séparée : http://www.martinfowler.com/eaaDev/SeparatedPresentation.html
- En 2012, James Wrightson relate qu'à
son avis, pour un petit jeu, MVC est Overkill
mais peut tout de même s'avérer utile pour contrôler un
personnage avec souris et clavier : http://boxhacker.com/blog/2012/03/11/model-view-controller/
- De l'avis de Conrad Irwin en 2012,
MVC est mort et il faut passer à autre chose. Il met de l'avant
ce qu'il nomme MOVE, pour Models, Operations,
Views and Events, donc une extension à MVC
tenant compte des événements : http://cirw.in/blog/time-to-move-on
- De son côté, Ingo Schramm explique, en
2012, qu'il n'est pas clair à ses yeux que
MOVE soit préférable à MVC :
http://ingoschramm.tumblr.com/post/26409997578/mvc-move-or-simply-a-state-machine
- Saines pratiques pour MVC avec
JavaScript, selon Alex McCaw en 2014 :
http://blog.sourcing.io/mvc-style-guide
- Selon l'auteur de ce texte de 2013,
MVC est vraiment un schéma de conception du monde des interfaces
personne/ machine :
http://ajdotnet.wordpress.com/2013/11/03/mvc-is-a-ui-pattern/
- Appliquer MVC en
Haskell, un texte de Gabriel Gonzalez en 2014 :
http://www.haskellforall.com/2014/04/model-view-controller-haskell-style.html
- Le passé, le présent et l'avenir de MVC, d'un
point de vue
Java,
par Gastón I. Silva en 2014 :
http://givan.se/p/00000010
- Gérer des exceptions dans un design de type
MVC, texte du Code
Project par Marla Sukesh en
2014 :
http://www.codeproject.com/Articles/731913/Exception-Handling-in-MVC
- Selon Alexander Jung en 2013,
MVC est un schéma de conception d'interfaces personne/ machine et ne
devrait pas être confondu avec une approche ayant des visées plus larges :
https://ajdotnet.wordpress.com/2013/11/03/mvc-is-a-ui-pattern/
- Le schéma MVC avec
Go,
par Jon Calhoun en 2015 :
http://calhoun.io/creating-controllers-views-in-go/
- Mieux comprendre MVC, par Andreas Söderlund en
2015 :
https://github.com/ciscoheat/mithril-hx/wiki/Rediscovering-MVC
- En 2016, Jean-Jacques Dubray explique pourquoi
il a abandonné ce schéma de conception :
http://www.infoq.com/articles/no-more-mvc-frameworks
- Comprendre MVC, livre en ligne par Stefano
Borini :
https://stefanoborini.gitbooks.io/modelviewcontroller/content/
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 :
- Le Wiki sur le sujet : http://en.wikipedia.org/wiki/Singleton_pattern
- Un texte de Peter Norlund, qui propose ce qu'il décrit comme une
classe de base générique en C++
pour des singletons qui soit telle que l'ordre de destruction des singletons
soit contrôlé (son approche est différente de la
mienne) : http://www.nada.kth.se/cvap/abstracts/cvap246.html
- Un texte qui postule que le schéma de conception singleton pose le
problème de la mauvaise manière, soit celui de l'identité,
alors que selon son auteur, l'idée clé est la localisation des
états. L'auteur présente son alternative, le Borg (le
code proposé est en Python) :
http://code.activestate.com/recipes/66531/
- Un texte sur les méthodes singleton en Smalltalk
et en Ruby :
http://talklikeaduck.denhaven2.com/2009/05/30/singleton-methods-in-smalltalk-and-ruby
- Un vieux (1996, donc précédant
le standard ISO de C++,
qui n'est même pas la dernière version du standard) texte de
Douglas C. Schmidt sur le Double-Checked
Locking,
une pratique (prudence! Voir ci-dessous...) qui vise à éviter
certaines conditions de course associées aux singletons : http://www1.cse.wustl.edu/~schmidt/editorial-3.html
- Un texte plus récent (2004) des bien
connus Scott
Meyers et Andrei
Alexandrescu portant sur les périls du Double-Checked
Locking : http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf
- Google fournit un outil de détection de singletons et d'autres états
globaux : http://code.google.com/p/google-singleton-detector/
- Application de ce schéma de conception dans
Chrome, pour optimiser
certains comportements du fureteur :
http://www.igvita.com/posa/high-performance-networking-in-google-chrome/#predictor
- Des singletons en
D, un
texte de 2013 :
http://davesdprogramming.wordpress.com/2013/05/06/low-lock-singletons/
- Des singletons en
Perl,
un texte de 2013 par David Farrell :
http://perltricks.com/article/52/2013/12/11/Implementing-the-singleton-pattern-in-Perl
- Une alternative aux singletons et aux variables globales en C++,
proposée par Bob Schmidt en 2015 :
http://accu.org/index.php/journals/2085
- En 2015, Arne Mertz s'interroge à savoir si le
schéma de conception Singleton est une bonne ou une mauvaise pratique :
http://arne-mertz.de/2015/04/singletons-whats-the-deal/
- Dans ce texte de 2015,
Robert C. Martin prêche
pour une application non-dogmatique du singleton, surtout en lien avec les
tests :
http://blog.cleancoder.com/uncle-bob/2015/07/01/TheLittleSingleton.html
- Plusieurs variantes d'implémentation d'une singleton en C++,
proposées par Joe Ruether en 2015 :
http://jrruethe.github.io/blog/2015/08/02/singletons/
- Explications de Zaheer Ahmed en 2015 :
http://conceptf1.blogspot.ca/2015/04/singleton-design-pattern.html
- Texte de 2016 par Paul M. Watt, qui explique
avoir eu une épiphanie au contact de ce schéma de conception :
http://codeofthedamned.com/index.php/the-singleton
- Initialisation concurrente d'un singleton en C++
et rapidité d'exécution, texte de 2016 par Rainer
Grimm :
http://www.modernescpp.com/index.php/thread-safe-initialization-of-a-singleton
- Deux manières simples d'implémenter un singleton en C++,
par Alon Gonen en 2017 :
http://cppisland.com/?p=501
- Revisiter le singleton en C++,
texte de 2017 par Giuseppe (nom de famille
inconnu) :
http://www.italiancpp.org/2017/03/19/singleton-revisited-eng/
- Texte de Marc Gregoire en 2017, qui propose
d'implémenter un singleton en C++
à l'aide de variables static magiques :
http://www.nuonsoft.com/blog/2017/08/10/implementing-a-thread-safe-singleton-with-c11-using-magic-statics/
- Discussion de manières d'implémenter un singleton en
C#, par
Jon Skeet :
http://csharpindepth.com/Articles/General/Singleton.aspx (merci à
Alexandre Leblanc pour le lien)
- Des singletons en
Java
à l'aide d'énumérations, par Dulaj Atapattu en 2017 :
https://dzone.com/articles/java-singletons-using-enum
- En 2017, Robert Shenk propose une approche en
Java
qu'il nomme les « singletons malléables » :
https://dzone.com/articles/the-malleable-singleton-pattern-bend-dont-break
- Singletons et systèmes embarqués, texte de 2014
par Jason Sachs :
https://www.embeddedrelated.com/showarticle/691.php
- Texte de 2019 par Madhuka Udantha et expliquant des techniques pour éviter de
« briser » un singleton dans un langage comme
Java
où la réflexivité dynamique est possible, où le clonage est implémenté par une
classe racine commune à tous, et où une forme de sérialisation est intégrée à
même le langage :
https://dzone.com/articles/prevent-breaking-a-singleton-class-pattern
- Les singletons avec
Haskell, par Justin Le en 2020 :
https://blog.jle.im/entry/introduction-to-singletons-1.html
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 :
- Un Wiki sur le sujet : http://en.wikipedia.org/wiki/Visitor_pattern
- Une approche sophistiquée en C++,
que son auteur Anand Shankar Krishnamoorthi nomme le visiteur coopératif :
http://www.artima.com/cppsource/cooperative_visitor.html
- Comprendre Visiteur à partir du Pattern Matching, une proposition
de Phil Freeman en 2013 :
http://blog.functorial.com/posts/2013-10-02-Visitor-Pattern.html
- Visiter un graphe en profitant des mécanismes de
C++ 14,
par Adrien Hamelin en 2014 :
https://aboutcpp.wordpress.com/2014/12/12/a-function-to-visit-nodes-of-a-graph-with-c14/
- Texte du Code Project,
par Phillip Voyle en 2015, qui décrit un visiteur
variadique
pour évaluer des expressions arithmétiques :
http://www.codeproject.com/Articles/896108/Expression-Evaluator-Example-Using-a-Variadic-Visi
- Un visiteur générique :
../Divers--cplusplus/Visiteurs-generiques.html
- Réaliser une forme de réflexivité avec un visiteur statique, texte de
2015 :
https://medium.com/@mattgician/libraryless-reflection-in-c-288d7873e3a6
- Texte du Code Project
en 2015 par Phillip Voyle qui décrit un évaluateur
d'expressions arithmétiques construit à l'aide d'un visiteur :
http://www.codeproject.com/Articles/896108/Expression-Evaluator-Example-Using-a-Variadic-Visi
- En 2016, Arne Mertz présente deux approches à
l'implémentation du schéma de conception Visiteur :
- Textes de Vittorio Romeo en 2016, portant sur la visite d'un variant
à
partir de λ :
- Quelques réflexions sur le schéma de conception Visiteur, proposées en
2017 par Ewan (qui ne donne pas son nom de famille) :
http://thejavasnowman.com/thoughts-on-the-visitor-design-pattern/
- Un cas vécu d'application du schéma de conception Visiteur en situation de
TDD, relaté par
Rafa Del Nero en
2017 :
https://nobugsproject.com/2017/09/03/design-patterns-saga-14-real-project-situations-with-visitor/
- Revisiter (!) le schéma de conception Visiteur, par Jonathan Müller en
2017 :
https://foonathan.net/blog/2017/12/21/visitors.html
- En 2017, Krzysztof Ostrowski propose une implémentation
intéressante du schéma de conception Visiteur, qui permet d'éviter les
indirections polymorphiques :
https://github.com/insooth/insooth.github.io/blob/master/visitor-pattern.md
- Visiter un arbre à coût réduit en
Java
« moderne », par Haoyi en
2018 :
http://www.lihaoyi.com/post/ZeroOverheadTreeProcessingwiththeVisitorPattern.html
- Avec
https://www.fluentcpp.com/2022/02/09/design-patterns-vs-design-principles-visitor/,
texte de 2022,
Jonathan Boccara examine le
schéma de conception
Visiteur
Vers le musée des horreurs.