Dans ce texte, j'utilise à plusieurs reprises l'initialisation standardisée, valide en C++ depuis C++ 11.
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 »). 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!
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 :
Puisque C++ supporte et encourage une sémantique d'accès direct aux objets, certaines considérations deviennent particulièrement importantes dans ce langage :
|
|
|
|
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 :
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 :
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). |
|
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 :
|
|
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é. |
|
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. |
|
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 :
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. |
|
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 souhaitez 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.
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 |
---|---|
|
|
Pourtant, le code C++ le plus proche de ces deux programmes serait plutôt :
|
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 :
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.
À 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# | Java | Notes |
---|---|---|---|---|
Créer un X par défaut |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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) |
|
|
|
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) |
|
|
|
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. Notez que même en C++, dupliquer un pointé à partir d'un pointeur sur un type polymorphique requiert du clonage |
Pour bien programmer dans l'un ou l'autre de ces langages, il faut comprendre ces nuances.
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 | C++ contemporain (avec pointeur intelligent) |
---|---|---|
|
|
|
Les règles décrivant l'impact de la sémantique de mouvement sur la Sainte-Trinité sont les suivantes :
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 ne pas laisser le compilateur procéder par défaut, peu importe comment.
Pratique | Explication (survol) |
---|---|
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 |
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 :
On parlera de règle de quand on souhaitera expliciter le recours à une opération swap() |
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. |
Quelques liens pour en savoir plus.