Comprendre la Sainte-Trinité

Dans ce texte, j'utilise à plusieurs reprises l'initialisation standardisée, valide en C++ depuis C++ 11. Cette fonctionnalité n'est pas encore pleinement supportée par tous les compilateurs C++ au moment d'écrire ceci, alors au besoin, dans votre code, remplacez l'écriture var{init} par l'écriture var(init), comme dans 

Initialisation traditionnelleInitialisation standardisée
struct Point {
   float x, y;
   Point() : x(0), y(0) { // <--
   }
   Point(float x, float y) : x(x), y(y) { // <--
   }
};
struct Point {
   float x, y;
   Point() : x{}, y{} { // <--
   }
   Point(float x, float y) : x{x}, y{y} { // <--
   }
};

Pour alléger l'écriture, je ferai aussi souvent l'économie d'inclusions d'en-têtes, en particulier les en-têtes standards les plus usités (<algorithm> et <vector> viennent en tête) et l'en-tête <memory> auquel nous référerons à plusieurs reprises.

Il est parfois complexe de s'acclimater aux nuances d'un langage de programmation à l'autre, à plus forte partie lorsque les langages se ressemblent superficiellement mais diffèrent dans leurs fondements. Dans cet article, nous examinerons les nuances quant aux opérations qui touchent au coeur de ce que sont les objets dans un langage OO, c'est-à-dire l'accès à un objet, sa construction, sa finalisation et sa duplication.

L'idée derrière cette réflexion est de comprendre ce qu'on nomme en C++ la Sainte-Trinité, terme souvent remplacé par le plus politiquement correct « règle de trois » (qui, depuis C++ 11, est plus une « règle de cinq », comme nous le verrons plus bas). La Sainte-Trinité (ou « règle de trois ») aurait initialement été énoncée par Marshall Cline en 1991.

Comprendre ces opérations et l'interaction qu'elles ont avec nos programmes nous permet de mieux comprendre l'acte de programmer dans l'un ou l'autre de ces langages.

À propos du mot « règle »

Le mot « règle » est un peu fort ici; comme le faisait remarquer Michael Caisse lors d'une présentation à laquelle j'ai eu le bonheur d'assister lors de cppcon 2014, car ici comme ailleurs, mieux vaut ne pas cesser de réfléchir. Il existe, particulièrement dans un langage de la richesse et de la complexité de C++, des cas d'exception à presque toutes les règles.

Ainsi, ce que vous devez principalement retenir de ce qui suit est que la règle de trois (de quatre, de cinq, de zéro, selon le cas) vise à attirer votrre attention sur le cas typique, et à vous porter à vous questionner si vous avez implémenté certaines des opérations visées sans considérer les autres. Ce n'est pas un dogme ou une série de consignes; c'est une invitation à la prudence (p. ex. : si vous avez spécifié le constructeur de copie sans spécifier l'affectation, que ce soit pour l'implémenter ou pour le supprimer, alors il y a peut-être anguille sous roche).

La recommandation de Michael Caisse, à laquelle je souscris pleinement, est d'expliciter vottre intention. Utilisez au besoin les mécanismes de C++ 11 permettant d'apposer = delete ou = default sur les fonctions pour lesquelles vous souhaitez l'un ou l'autre de ces comportements. Vous autodocumenterez ainsi votre code, à la fois pour vous-mêmes, vos collègues et votre compilateur, ce qui ne peut que vous avantager.

Et surtout, l'essentiel : réfléchissez!

L'idée de base – Sémantique d'accès

Java et C# font partie des  langages OO pour lesquels la seule sémantique d'accès pour les instances d'une classe donnée est la sémantique de référence :

Dans ces deux langages, ce qui tient lieu de référence est une indirection amovible. Ces références sont commes des pointeurs en C ou en C++ au sens où l'une d'elles peut pointer à null, ou encore pointer vers divers objets au cours de son existence. Ces références diffèrent des pointeurs de C et de C++ au sens où :

C++, en contrepartie, et un langage OO pour lequel la sémantique d'accès privilégiée est la sémantique de valeur :

Ce qu'est la Sainte-Trinité

Puisque C++ supporte et encourage une sémantique d'accès direct aux objets, certaines considérations deviennent particulièrement importantes dans ce langage :

  • Qe signifie « copier un objet »? Ceci engage à une réflexion envers les opérations de construction par copie, nécessaires pour créer des objets temporaires représentant une copie d'autres objets, par exemple lors de passages de paramètres par valeur, et d'affectation. Des exemples sont proposés à droite, où :
    • un X nommé x est copié par construction dans un autre X nommé y, puis
    • le X nommé y est affecté, éventuellement, au X nommé x
  • La différence clé entre la construction par copie et l'affectation est qu'une construction se produit quand l'objet n'existait pas encore, alors que l'affectation se produit quand un objet existe et ses états doivent être remplacés.
class X { /* ... */ };
X f();
int main() {
   X x = f();
   X y = x; // construction par copie
   // ...
   x = y;   // affectation
  • Que signifie « finaliser un objet »? En C++, ceci implique réfléchir au code requis dans un destructeur, méthode appelée à la fin de la vie utile d'un objet. Typiquement, un objet est finalisé à la fin de la portée dans laquelle il a été déclaré, ce qui explique l'application omniprésente de l'idiome RAII dans ce langage.
// ...
} // appelle y.~X() et x.~X()

Puisque l'identité des objets va de pair avec la programmation en C++, le langage supplée trois (maintenant : cinq!) opérations automatiquement pour tout type :

Sainte-Trinité et invariants

Pour résumer l'impact de la Sainte-Trinité sur l'approche OO, en particulier sur la qualité de l'encapsulation :

Cas d'espèces

La Sainte-Trinité est implicitement correcte pour une classe dont tous les membres ont eux aussi une Sainte-Trinité correcte, et pour laquelle la sémantique d'une copie est clairement définie. C'est le cas pour tous les types primitifs (incluant les pointeurs; ceux-ci sont implicitement copiables, après tout). Elle doit toutefois être repensée dans les cas où un objet prend explicitement la responsabilité sur des ressources (allocation dynamique de mémoire, ouverture d'un flux, prise en charge d'un mécanisme de synchronisation, etc.). Quelques exemples concrets suivent.

Le type Point proposé à droite est tel que la Sainte-Trinité y est implicitement correcte. En effet :

  • Chaque attribut d'instance s'y copie implicitement (affectation et construction par copie)
  • Chaque attribut d'instance s'y détruit sans problème, et
  • Une instance de Point ne prend pas explicitement de ressources sous sa gouverne

Pour ces raisons, le code qui sera généré par le compilateur pour ces trois opérations clés sera correct dans ce cas-ci. Mieux vaut ne pas les coder explicitement (le code du compilateur sera meilleur que le nôtre, quoi que nous fassions).

struct Point {
   using value_type = float;
   value_type x, y;
   Point() : x{}, y{} {
   }
   Point(value_type x, value_type y) : x{x}, y{y} {
   }
};

La Sainte-Trinité pour la classe Personne proposée à droite sera implicitement correcte si elle est correcte pour le type NAS (du fait que le type std::string, lui, définit correctement ces trois opérations). Ceci nous donne un indice important de saine programmation :

  • Si possible, en C++, assurez-vous que chacun de vos types assure la saine gestion de la Sainte-Trinité pour lui, car ceci facilitera sa réutilisation par la suite (les classes qui en feront usage n'auront rien de spécial à faire)
  • Exprimé autrement, suivez la maxime proposée par Scott Meyers : Do as the ints Do, donc faites en sorte que, règle générale, vos objets soient aussi simples à utiliser que des int. Le monde s'en portera mieux.
class Personne {
   std::string nom_;
   NAS no_ass_sociale_;
public:
   Personne(const std::string &nom, const NAS &nas)
      : nom_(nom), no_ass_sociale_(nas)
   {
   }
   // ...
}};

Une classe peut être telle que ses instances gardent en elles des pointeurs sans se préoccuper du sens à donner à leur copie ou de la destruction de ce vers quoi ils pointent, mais seulement dans le cas où ces instances ne sont pas responsables du pointé en question.

Par exemple, à droite, un Registre accepte d'entreposer des adresses de Personne pour fins de consultation, mais n'est pas responsable de la gestion de leur vie. Copie un Registre ne pose pas de problème a priori, puisqu'un vector<Personne*> se copie sans peine. Toutefois, si un Registre était responsable des instances de Personne pointées par son attribut individus, alors il faudrait réfléchir au sens à donner aux opérations de copie et au nettoyage d'un Registre, donc tenir compte de la Sainte-Trinité.

class inconnu {};
class redondant {};
class Registre {
   vector<const Personne*> individus;
public:
   void ajouter(const Personne *p) {
      auto it = find(begin(individus), end(individus), p);
      if (it != end(individus)) throw redondant{};
      individus.emplace_back(p);
   }
   void retirer(const Personne *p)
   {
      auto it = find(begin(individus), end(individus), p);
      if (it == end(individus)) throw inconnu{};
      individus.erase(it);
   }
   // etc.
};

Dans l'exemple à droite, la classe TiTableau alloue dynamiquement les ressources associées à elems_. Pour cette raison, un TiTableau doit se préoccuper de la Sainte-Trinité, car le code généré par le compilateur réaliserait une copie du pointeur qu'est elems, pas une copie des éléments du tableau vers lesquels il pointe.

Ici, nous avons choisi de faire en sorte que dupliquer un TiTableau résulte en un autre TiTableau ayant le même nombre d'éléments que n'en avait le TiTableau original, avec des éléments de même valeur aux mêmes positions dans les deux cas.

class TiTableau
{
public:
   using size_type = std::size_t;
private:
   size_type nelems;
   int *elems;
public:
   TiTableau(size_type n)
      : nelems{}, elems{new int[n]}
   {
      fill(elems, elems+size(), 0);
   }
   TiTableau(const TiTableau &autre)
      : nelems{autre.size()}, elems{new int[autre.size()]}
   {
      copy(autre.elems, autre.elems+size(), elems);
   }
   void swap(TiTableau &autre) {
      using std::swap;
      swap(elems, autre.elems);
      swap(nelems, autre.nelems);
   }
   TiTableau& operator=(const TiTableau &autre) {
      TiTableau(autre).swap(*this);
      return *this;
   }
   ~TiTableau() {
      delete [] elems;
   }
   // ...
};

L'exemple à droite est un autre type de TiTableau, qui est incopiable cette fois car il possède un attribut lui-même incopiable (un unique_ptr). En définissant une sémantique claire de responsabilité pour elems, nous n'avons plus à implémenter la Sainte-Trinité. En effet :

  • La copie d'un tel TiTableau est interdite, et
  • La bonne destruction du tableau alloué dynamiquement est sous la responsabiltié du unique_ptr

Un autre indice important de saine programmation : préférez ne pas confier à une classe plus d'une responsabilité quant à la gestion de ses ressources. Si c'est possible, faites en sorte que chaque attribut soit responsable de lui-même. Votre code n'en sera que plus clair et plus simple.

class TiTableau {
public:
   using size_type = std::size_t;
private:
   size_type nelems;
   unique_ptr<int[]> elems;
public:
   TiTableau(size_type n)
      : nelems{}, elems{new int[n]}
   {
      fill(&elems[0], &elems[size()], 0);
   }
   // ...
};

Dans un langage où l'usage est de manipuler des objets directement, offrir un support automatique de la Sainte-Trinité va de pair avec des principes de base de saine programmation. Évidemment, dans un langage où l'accent est mis sur l'allocation dynamique de ressources, le partage d'objets et l'accès indirect, les pratiques sont différentes. En C# et en Java, mieux vaut penser immuabilité (pour réduire les conséquences néfastes du partage implicite des objets) que d'insister sur la copie des objets, puisque cette dernière opération y est moins naturelle, moins idiomatique.

Si vous souhaiter supprimer les opérations de copie qui auraient normalement été générées pour votre de classe de manière automatique, alors examinez l'idiome de classe Incopiable.

Comparatif d'opérations clés

Le programme C++ suivant :

class X { /* ... */ };
int main() {
   X x;
}

... n'a pas vraiment d'équivalent en Java ou en C#. En effet, ici, x est une instance de X, à laquelle le programme a directement accès. C# offre un comportement se rapprochant de ceci avec ses struct, et Java n'offre rien qui s'en rapproche.

Le code Java ou C# que l'on pourrait imaginer être « équivalent » au code C++ ci-dessus serait probablement :

C# Java
// ...
class X
{
   static void Main(string [] args)
   {
      X x = new X();
   }
}
// ...
// ...
class X {
   public static void main(String [] args) {
      X x = new X();
   }
}
// ...

Pourtant, le code C++ le plus proche de ces deux programmes serait plutôt :

#include <memory>
class X { /* ... */ };
int main() {
   using std::shared_ptr;
   auto x = shared_ptr<X>(new X);
}

Notez que, bien que shared_ptr soit la structure la plus proche en C++ d'une référence prise en charge de Java ou d'un langage .NET, les programmeurs C++ privilégieront en général le recours à unique_ptr, lui aussi de <memory>.

Là où shared_ptr définit une sémantique de partage (copier un shared_ptr signifie partager son pointé), unique_ptr définit une responsabilité exclusive sur le pointé. Le contenu pointé par un unique_ptr peut être transféré d'un unique_ptr à un autre, mais un unique_ptr ne se copie pas. Pour cette raison, unique_ptr est plus sécuritaire en situation de multiprogrammation, et est aussi plus léger en mémoire. En C++, donc, c'est unique_ptr si possible, et shared_ptr si nécessaire.

En effet :

Que chaque création d'objet implique une allocation dynamique de ressources entraîne un coût, évidemment. Il faut chaque fois trouver un endroit pour placer l'objet en cours de création, l'initialiser, puis mettre en place les mécanismes minimaux requis pour assurer le suivi de l'objet en question et, éventuellement, en assurer la collecte.

Chaque copie d'une référence sur l'objet entraîne elle aussi des coûts, du fait qu'il faut alors s'assurer ce que vers quoi la référence pointait précédemment soit informé qu'elle ne pointe plus dans sa direction, tout en informant le nouveau pointé de cette réalité. Au minimum, on parle ici d'une incrémentation et d'une décrémentation de compteurs, les deux de manière synchronisée, ou à tout le moins atomique.

On serait portés à croire que le fait qu'un objet ne soit collecté qu'éventuellement serait quelque chose de péjoratif, mais il n'en est rien, du moins du point de vue des moteurs de ces langages. En effet, ces langages appliquent des stratégies appropriées aux choix qui sont les leurs :

Risques du partage

Puisque tous les objets en C# et en Java sont alloués dynamiquement, et puisque la copie dans ces langages est d'abord et avant tout une copie de référence, donc une sémantique de partage du référé, il est très important de développer avec ces langages l'habitude de concevoir des objets immuables.

Par immuable, on entend une classe dont les instances ne peuvent être modifiées une fois construite (pas de mutateurs ou de services semblables). Sans surprise, la plupart des classes importantes de langages comme C# et Java sont immuables (String en Java et string en C# en sont de bons exemples). Pour comprendre les enjeux, voir ce texte.

Partager un objet qui n'est pas immuable est dangereux en situation de multiprogrammation, et prête à risque même en situation de monprogrammation, brisant le principe de moindre surprise. Il est difficile de savoir quand il est le plus opportun de dupliquer un objet mutable (par clonage ou par copie) pour éviter les bris d'encapsulation; les objets faisant partie d'une interface dans ces langages devraient conséquemment tous être immuables.

Quelques comparatifs

À titre de référence, voici quelques comparatifs d'opérations clés dans les principaux langages de programmation au moment d'écrire ceci.

Opération C++ C# JavaNotes

Créer un X par défaut

X x;
// ou plus explicitement
X x{}; 
X x = new X();
X x = new X();

Avec C++, l'objet est sur la pile. Avec C# ou Java, il est probablement sur le tas.

Allouer dynamiquement un X par défaut

X *p = new X;
// ou (mieux)
auto p = make_unique<X>();
// ou
auto p = make_shared<X>();
X x = new X();
X x = new X();

L'équivalent le plus direct entre C++ et les propositions de Java et de C# est shared_ptr de <memory>

Équivalence : comparer deux objets x0 et x1 de type X sur la base de leur contenu

x0 == x1
x0 == x1 // parfois
x0.Equals(x1) // plus général
x0.equals(x1)

En C++, x0 et x1 sont des objets. En C# et en Java, se sont des références; l'opérateur == compare typiquement des références (mais C# permet de surcharger cet opérateur). Notez que le sens de ces expressions dépend de l'objet (p. ex. : savoir ce que signifie comparer le contenu de deux arbres binaires dépend du programme)

Identité : vérifier si deux indirections p0 et p1 pointent au même endroit

p0 == p1
p0 == p1 // prudence
p0 == p1

En C++, p0 et p1 sont des pointeurs (des X* ou des pointeurs intelligents sur des X, du moins s'ils pointent vers des X). Avec Java, on manipule toujours des références vers des objets. En C#, ceci n'a de sens que si == n'a pas été surchargé pour le type vers lequel pointent p0 et p1 (pour une garantie plus forte, mieux vaut les transtyper tous deux en object au préalable).

Copie : déposer dans x0 une copie du contenu de x1 (le code client doit connaître les types effectifs; chose à faire pour une classe terminale)

x0 = x1;
s/o
s/o

En Java et en C#, il n'existe pas de mécanisme formel et standard pour cette opération, bien qu'il soit possible d'en mettre un en place sous forme de méthodes pour des classes terminales. Le recours systématique aux objets alloués dynamiquement fait que le clonage y est habituellement préférable à la copie

Clonage : déposer dans x0 une copie de ce vers quoi pointe x1 (l'objet réalise une copie de lui-même; chose à faire si le type est polymorphique)

x0 = x1
x0 = x1.Clone()
x0 = x1.clone()

En Java et en C#, les objets étant systématiquement manipulés de manière indirecte, le clonage est plus fréquemment pertinent que la copie. Prudence toutefois, car les interfaces standards de clonage pour Java et C# sont notoirement sous-définies

Pour bien programmer dans l'un ou l'autre de ces langages, il faut comprendre ces nuances.

Impact du mouvement

Depuis C++ 11, le langage C++ supporte la sémantique de mouvement. Cet article donne plus de détails sur le sujet, mais pour le présent article, ce qui nous intéresse est l'interaction de cette nouvelle sémantique avec la traditionnelle sémantique de copie.

En bref : il est possible depuis C++ 11 d'ajouter aux méthodes clés d'un objet un constructeur de mouvement et une affectation par mouvement. La syntaxe est :

C++ traditionnel C++ contemporain
class TiTableau {
public:
   using size_type = std::size_t;
private:
   size_type nelems;
   int *elems;
public:
   TiTableau(size_type n)
      : nelems{}, elems{new int[n]}
   {
      fill(elems, elems+size(), 0);
   }
   TiTableau(const TiTableau &autre)
      : nelems{autre.size()}, elems{new int[autre.size()]}
   {
      fill(autre.elems, autre.elems+size(), elems);
   }
   void swap(TiTableau &autre) {
      using std::swap;
      swap(elems, autre.elems);
      swap(nelems, autre.nelems);
   }
   TiTableau& operator=(const TiTableau &autre) {
      TiTableau{ autre }.swap(*this);
      return *this;
   }
   ~TiTableau() {
      delete [] elems; }
   }
   // ...
};
class TiTableau {
public:
   using size_type = std::size_t;
private:
   size_type nelems;
   int *elems;
public:
   TiTableau(size_type n)
      : nelems_{}, elems_{new int[n]}
   {
      fill(elems, elems+size(), 0);
   }
   TiTableau(const TiTableau &autre)
      : nelems{ autre.size() }, elems{ new int[autre.size()] }
   {
      copy(autre.elems, autre.elems+size(), elems);
   }
   void swap(TiTableau &autre) {
      using std::swap;
      swap(elems, autre.elems);
      swap(nelems, autre.nelems);
   }
   TiTableau& operator=(const TiTableau &autre) {
      TiTableau{ autre }.swap(*this);
      return *this;
   }
   ~TiTableau() {
      delete [] elems;
   }
   //
   // Construction par mouvement: les états du paramètre sont transférés
   // dans l'objet en cours de construction. L'objet original est laissé
   // dans un état qui permettra de le détruire sans risque
   //
   TiTableau(TiTableau && autre)
      : elems{std::move(autre.elems)}, nelems{std::move(autre.nelems)}
   {
      autre.nelems = 0;
      autre.elems = nullptr;
   }
   //
   // Affectation par mouvement: les états du paramètre sont transférés
   // dans l'objet en cours de construction. L'objet original est laissé
   // dans un état qui permettra de le détruire sans risque
   //
   TiTableau& operator=(TiTableau && autre) {
      swap(autre);
      return *this;
   }
   // ...
};

Les règles décrivant l'impact de la sémantique de mouvement sur la Sainte-Trinité sont les suivantes :

Résumé des pratiques clés avec C++

Avec C++, les règles suivantes guident la définition des opérations clés déterminant le comportement d'un objet en tant qu'entité. Dans ce qui suit, « tenir compte » signifie nme pas laisser le compilateur procéder par défaut, peu importe comment.

Pratique Explication (survol)

Sainte-Trinité

Règle de trois

Règle de

Typiquement, le trio d'opérations constitué du constructeur de copie, de l'opérateur d'affectation et du destructeur vont ensemble. Si vous tenez compte de l'une mais pas des deux autres, alors il est probable que vous fassiez une bêtise. Cela dit, il existe des exceptions à la règle.

On parlera de règle de quand on souhaitera expliciter le recours à une opération swap()

Règle de cinq

Règle de

Si vous tenez compte du constructeur de mouvement ou de l'affectation par mouvement, le compilateur présumera (sans doute avec raison) que vous devriez aussi tenir compte de la Sainte-Trinité. Pour cette raison, si vous ne les prenez pas en charge explicitement pour un type donné, alors les versions par défaut de ces opérations en seront supprimées.

Petite nuance : pour des raisons historiques, si vous implémentez un membre de la Sainte-Trinité, le compilateur ne générera pas les opérations de mouvement mais générera encore les autres membres de la Sainte-Trinité, ceci dans l'optique de ne pas briser trop de code existant. Cependant, ceci est appelé à changer dans le futur; conséquemment, il peut être sage d'expliciter votre intention systématiquement si vous implémentez certaines de ces fonctions.

Typiquement, le mouvement n'est pas nécessaire, mais constitue une optimisation. Il existe cependant plusieurs classes dont les objets sont déplaçables mais incopiables.

 Depuis C++ 11,  si vous implémentez au moins une des opérations que sont l'affectation pour copie, l'affectation de mouvement, le constructeur de copie ou le constructeur de mouvement, alors :

  • Vous devriez tenir compte du destructeur (l'implémenter ou l'affubler d'une spécification = default). Ne pas en tenir compte n'est pas interdit mais est considéré déprécié, et vous êtes en droit de vous attendre à un avertissement du compilateur
  • Vous devez tenir compte implémenter les autres fonctions de ce groupe que vous souhaitez rendre disponibles. Si vous ne le faites pas, elles seront implicitement considérées supprimées, comme dans le cas d'une spécification = delete

On parlera de règle de quand on souhaitera expliciter le recours à une opération swap()

Règle de zéro

Pour une classe qui n'est pas explicitement responsable de ressources, mieux vaut ne coder aucune des opérations de la règle de cinq (constructeur de copie, affectation, constructeur de mouvement, affectation par mouvement, destructeur) car le compilateur fera alors mieux que nous. Le nom vient de Martinho Fernandez en 2012 : http://flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html.

Lectures complémentaires

Quelques liens pour en savoir plus.


Valid XHTML 1.0 Transitional

CSS Valide !