Tutoriel pour apprendre la meilleure fonctionnalité de C++

Le cycle de vie des objets et variables

Andrzej Krzemieński

Traducteur

kurtcpp

Si vous voulez l'apprendre dans son intégralité, le C++ est vaste, difficile et semé d'embûches. Si vous jetez un coup d'œil à ce que certains en font, vous serez peut-être effrayé. Et de nouvelles fonctionnalités y sont régulièrement ajoutées. Il faut des années pour apprendre chaque détail de ce langage.

Mais il n'est pas nécessaire de tout en apprendre. L'utilisation efficace de C++ nécessite seulement la connaissance de quelques-unes de ses caractéristiques essentielles. Dans ce tutoriel, je vais aborder une de celles que je considère comme les plus importantes, celle qui me fait choisir le C++ plutôt que d'autres langages populaires.

Commentez

- Titre : Tutoriel pour apprendre la meilleure fonctionnalité de C++

- Auteur : Andrzej Krzemieński

- Traducteur : kurtcpp

- Parution : 7 avril 2017

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Durée de vie définie des objets

- II. Le destructeur

- III. Les côtés obscurs

- IV. Remerciements

I - Durée de vie définie des objets

Chaque objet que vous créez dans votre programme a une durée de vie précisément définie. Lorsque vous décidez d'un type de durée de vie pour un objet, vous savez exactement à quel moment la vie de votre objet commence et quand elle s'arrête.

Prenons le cas d'une variable « automatique » (NdT C.-à-d. une variable locale non statique) : son cycle de vie démarre à sa déclaration, dès que l'initialisation s'est terminée normalement (pas par une exception), et se termine lorsque l'on sort de la portée de cette variable.
Prenons le cas de deux objets locaux et qui sont déclarés au même niveau : celui des deux dont le cycle de vie a démarré plus tôt verra aussi son cycle de vie se terminer plus tard.

Pour les paramètres d'une fonction, le cycle de vie débute juste avant que l'exécution de la fonction ne démarre, et prend fin juste après l'exécution de la fonction.

Pour les variables globales (celles définies dans la portée d'un espace de noms) : le cycle de vie démarre avant que la fonction main ne démarre, et se termine après la fin de l'exécution de celle-ci. Lorsque deux variables globales sont définies dans la même unité de compilation (ce qui correspond à un fichier après inclusion des en-têtes), celle définie le plus haut démarre son cycle de vie plus tôt et le termine plus tard. En revanche, s'il s'agit de deux variables globales définies dans des unités de compilation différentes, on ne peut rien présupposer sur leur durée de vie.

Pour presque toutes les variables temporaires (il y a deux exceptions bien définies), le cycle de vie démarre quand une fonction renvoie une valeur à l'intérieur d'une expression qui l'englobe (ou quand ces variables sont créées explicitement), et se termine lorsque l'expression a été complètement évaluée.

Les deux exceptions sont les suivantes :

- quand une variable temporaire est liée à une référence globale ou locale, sa durée de vie devient celle de la référence.

1. Base && b = Derived{};

2. int main()

3. {

4. // ...

5. b.modify();

6. // ...

7. }

8. // le cycle de vie de la variable temporaire Derived se termine ici

Notez que la référence est d'un type différent (c'est une classe de base) de celui de la variable temporaire, et que l'on peut modifier cette variable. Le terme « temporaire » signifie typiquement « de courte durée de vie », mais si la variable est liée à une référence globale, elle vivra aussi longtemps que toute autre variable globale du programme ;

- la seconde exception à cette règle s'applique lorsque l'on initialise un simple tableau d'objets de types définis par l'utilisateur. Dans ce cas, si un constructeur par défaut est utilisé pour initialiser l'élément situé à l'index n et que ce constructeur par défaut a un ou plusieurs arguments par défaut, le cycle de vie de chaque variable temporaire créée pour ces arguments par défaut se termine dès que l'on passe à l'initialisation de l'élément n+1. Mais vous n'aurez probablement jamais besoin de savoir ceci.

Pour les données membres de classe, le cycle de vie démarre et se termine avec celui de l'objet englobant.

Il en va de même pour la durée de vie des autres types d'objets, qu'il s'agisse de variables statiques locales à une fonction, de variables locales à un thread, ou lorsque nous contrôlons manuellement la durée de vie d'un objet, par exemple avec new et delete, avec des allocateurs ou avec optional ou autre : dans tous ces cas, le début et la fin du cycle de vie sont bien définis et prévisibles.

Si l'initialisation d'un objet échoue (c.-à-d. qu'elle se solde par une exception), son cycle de vie ne débutera pas du tout.

Ceci résume la nature déterministe du cycle de vie des objets. Comment se présenterait un cycle de vie non déterministe ? Cela n'existe pas (encore) en C++, mais vous pouvez le trouver dans d'autres langages qui disposent d'un « ramasse-miettes » (« garbage collection » en anglais). Dans ces langages, vous créez un objet - son cycle de vie débute - mais vous ignorez quand son cycle de vie se terminera. Vous avez seulement la garantie qu'il ne se terminera pas tant que vous le référencez, mais même quand vous relâcherez la dernière référence à cet objet, il peut continuer à vivre pour une durée arbitraire, peut-être même jusqu'à l'arrêt du processus.

Mais alors, en quoi est-il si important que la durée de vie des objets soit déterministe ?

II - Le destructeur

C++ garantit que, pour tout objet d'un type classe, le destructeur sera appelé au moment où son cycle de vie se termine. Un destructeur est une fonction membre de la classe de notre objet, et on a la garantie qu'il sera la toute dernière fonction appelée pour cet objet.

Tout ceci est bien connu, mais tout le monde n'a pas conscience de ce que cela apporte. Tout d'abord, et c'est le plus important, vous pouvez utiliser le destructeur pour libérer les ressources acquises par l'objet durant son cycle de vie. Cette opération est ainsi encapsulée et donc cachée : vous (l'utilisateur) n'avez pas à invoquer de fonction dispose ou close. Il n'est donc pas possible d'oublier de libérer des ressources. Vous n'avez même pas besoin de savoir si le type que vous utilisez (en particulier dans un template, où le type est simplement T, pour vous) manipule des ressources ou non. De plus, les ressources de l'objet sont libérées immédiatement quand l'objet n'est plus nécessaire, pas dans un avenir indéterminé. Elles sont relâchées dès que possible. Ceci empêche les fuites. Rien n'est laissé à l'abandon au risque de bloquer des ressources pendant une durée imprévisible (en lisant « ressources », vous ne devriez pas penser uniquement à la mémoire, mais plutôt à des sockets ou à des connexions à une base de données).

La durée de vie déterministe des objets définit également l'ordre de destruction relatif des objets. Dans le cas où plusieurs objets locaux sont déclarés dans une même portée, ils seront détruits dans l'ordre inverse de celui dans lequel ils ont été déclarés (et initialisés). Il en va de même pour les données membres de classes : elles seront détruites dans l'ordre inverse de celui dans lequel elles ont été déclarées (et initialisées) dans le corps de la classe. Ceci est essentiel lorsque les ressources dépendent les unes des autres.

Cette fonctionnalité est supérieure au ramasse-miettes pour les raisons suivantes :

1. Elle fournit une démarche uniforme pour toutes les ressources envisageables - pas seulement la mémoire ;

2. Les ressources sont libérées immédiatement lorsqu'elles ne sont plus utilisées, et non pas quand (et si) le ramasse-miettes décide de nettoyer la mémoire (et d'invoquer les « finaliseurs ») ;

3. Son exécution ne représente pas un surcoût à l'exécution, contrairement à un ramasse-miettes.

Les langages reposant sur un ramasse-miettes offrent généralement des substituts aux techniques de gestion des ressources : l'instruction using du C# ou l'instruction try-with-resources du Java. Ils constituent certes des avancées, mais restent toutefois inférieurs à l'utilisation de destructeurs :

1. La gestion des ressources est exposée à l'utilisateur : vous devez alors savoir que le type que vous utilisez maintient des ressources et qu'il vous faut donc écrire du code supplémentaire pour provoquer leur libération. Si vous omettez l'instruction adéquate, cette ressource fera l'objet d'une « fuite » ;

2. Si celui qui maintient la classe décide de modifier son implémentation pour commencer à gérer une ressource, l'utilisateur doit également modifier son code. Ceci est la conséquence de la non-encapsulation de la libération des ressources ;

3. Ceci ne fonctionne pas bien dans le cadre d'une programmation générique : vous ne pouvez pas écrire un code qui opère uniformément sur des types qui utilisent des ressources et sur ceux qui n'en utilisent pas.

Finalement, ces instructions ne sont des substituts que pour les cas gérés en C++ par les objets « automatiques », créés dans la portée d'une fonction ou d'un bloc. Le C++ fournit d'autres types de cycle de vie pour les objets. Par exemple, vous pouvez décider que votre objet qui gère des ressources soit un membre d'un autre objet « maître », et de ce fait spécifier que ses ressources soient conservées pour toute la durée de vie de l'objet maître.

Imaginez la fonction suivante qui ouvre n flux de fichiers, puis les renvoie dans une collection. Une autre fonction les lit puis les referme automatiquement :

1. vector<ifstream> produce()

2. {

3. vector<ifstream> ans;

4. for (int i = 0; i < 10; ++i) {

5. ans.emplace_back(name(i));

6. }

7. return ans;

8. }

10. void consume()

11. {

12. vector<ifstream> files = produce();

13. for (ifstream& f: files) {

14. read(f);

15. }

16. } // tous les fichiers sont fermés

Comment obtenir ce résultat avec les instructions using ou try-with-resources ?

Notez une petite astuce ici. Nous faisons usage d'une autre fonctionnalité importante du C++ : le constructeur par déplacement. Nous utilisons le fait que std::fstream n'est pas copiable, mais déplaçable. Il en va de même - par transitivité - pour std::vector<std::ifstream>. L'opération de déplacement permet un peu d'émuler un autre cycle de vie d'objet, très particulier. Nous avons un cycle de vie « virtuel » ou « artificiel » d'une ressource (une collection de handles de fichiers) qui démarre avec celui de l'objet ans et se termine avec celui d'un autre objet défini dans une autre portée : files.

Notez que tout au long du cycle de vie « étendu » de la collection de handles de fichiers, chacun de ces handles est protégé contre les fuites, en cas d'exception. Même si la fonction name génère une exception à la cinquième itération, les quatre éléments précédemment créés ont la garantie d'être bien libérés dans la fonction produce.

De même, ces instructions de restriction de la durée de vie ne vous permettent pas de faire ceci :

1. class CombinedResource

2. {

3. std::fstream f;

4. Socket s;

6. CombinedResource(string name, unsigned port)

7. : f{name}, s{port} {}

8. // pas de destructeur explicite

9. };

Ceci vous fournit déjà quelques garanties utiles quant à la sécurité des ressources. Celles que vous avez ici seront libérées lorsque le cycle de vie de l'instance de CombinedResource se terminera. Ceci sera fait dans le destructeur défini implicitement, et dans l'ordre inverse des initialisations (vous n'avez aucun code à écrire pour cela). Dans le cas où l'initialisation de la seconde ressource, s, échoue dans le constructeur (c'est-à-dire si elle se solde par une exception), le destructeur de l'objet f déjà initialisé sera invoqué immédiatement, avant que l'exception ne soit propagée par notre constructeur. Et toutes ces garanties sont gratuites.
Comment faites-vous la même chose avec des clauses using ou try-with-resources ?

III - Les côtés obscurs

En toute impartialité, il faudrait aussi mentionner ici qu'il y a des raisons pour lesquelles certains n'aiment pas les destructeurs. Il existe des situations où le ramasse-miettes est supérieur à la solution « sans miettes » offerte par C++. Par exemple, avec un ramasse-miettes (si vous pouvez vous permettre d'en utiliser un), vous pouvez aisément représenter un graphe cyclique, en allouant simplement les nœuds et en les liant par le biais de pointeurs (ou de « références », si vous préférez). En C++, cela ne fonctionnera pas, même avec des « smart pointers ». Bien entendu, même avec un ramasse-miettes, les nœuds de tels graphes ne peuvent pas maintenir de ressources, car ça provoquerait des fuites : les clauses using et try-with-resources ne sont pas utiles pour ce cas, et on n'a pas la garantie que les finaliseurs seront bien invoqués.

J'ai également entendu dire qu'il existait des algorithmes concurrents efficaces, mais qui ne pouvaient fonctionner qu'avec l'assistance d'un ramasse-miettes. J'avoue n'en avoir jamais vu.

Certaines personnes n'aiment pas ne pas pouvoir voir l'appel du destructeur dans le code. Ce qui est un avantage pour certaines en décourage d'autres. En analysant ou en déboguant le code, il peut vous échapper que le destructeur est invoqué et peut avoir des effets de bord. Je suis déjà tombé dans un tel piège en déboguant un programme vaste et tortueux. L'objet désigné par un pointeur brut que je maintenais devenait soudainement n'importe quoi pour une raison inconnue : je ne voyais aucune fonction qui aurait pu causer cela. Ça m'a pris du temps pour me rendre compte que le même objet était aussi référencé par un unique_ptr, qui venait d'arriver silencieusement en fin de vie. Pour des objets temporaires, cela peut être encore bien pire : vous ne voyez ni le destructeur ni l'objet lui-même.

Il y a une restriction concernant l'usage des destructeurs : afin qu'ils interagissent correctement avec la remontée dans la pile (suite à une exception), ils ne doivent pas eux-mêmes générer de nouvelle exception. Cette restriction peut être très gênante pour ceux qui ont besoin de signaler l'échec de la libération d'une ressource, ou qui utilisent les destructeurs dans un autre but.

Notez qu'en C++ 11, à moins de déclarer votre destructeur en tant que noexcept(false), il est implicitement déclaré noexcept et invoquera donc std::terminate si jamais il génère lui-même une exception. Dans le cas où vous souhaitez recourir à une exception pour signaler l'échec de la libération des ressources, la recommandation est que votre type fournisse également une fonction membre de type « release » que les utilisateurs devraient appeler explicitement. Ensuite, votre destructeur devrait vérifier que les ressources ont bien été libérées, et si ce n'est pas le cas, essayer de le faire lui-même, de façon « silencieuse » (c.-à-d. en empêchant la propagation des exceptions).

Un autre inconvénient potentiel des destructeurs, concernant la libération des ressources, est qu'il vous faut parfois introduire dans votre fonction (de façon assez artificielle) un bloc (une portée) supplémentaire, dans le seul but de provoquer l'appel du destructeur d'un objet local bien avant la fin de la fonction.
Par exemple :

1. void Type::fun()

2. {

3. doSomeProcessing1();

4. {

5. std::lock_guard<std::mutex> g{mutex_};

6. read(sharedData_);

7. }

8. doSomeProcessing2();

9. }

Ici, il nous a fallu créer une portée supplémentaire afin que le mutex ne soit pas verrouillé pendant l'exécution de doSomeProcessing2. En effet, nous souhaitons libérer notre ressource (le mutex) dès la fin de son utilisation. Ceci est assez similaire à l'utilisation des clauses using et try-with-resources, à deux différences près :

1. Ceci est une exception et non pas la règle ;

2. Si nous oublions la portée, la ressource sera conservée trop longtemps, mais il n'y aura pas de fuite - car le destructeur finira forcément par être appelé.

Et voilà. Personnellement, je considère le destructeur comme l'une des caractéristiques les plus élégantes et pratiques, tous langages de programmation confondus. Et notez que je n'ai pas entamé l'explication de son autre point fort : l'interaction avec les mécanismes de gestion des exceptions. Voici ce qui m'attire vers le C++, encore plus que sa performance : l'élégance.

Une note finale : je ne souhaitais pas affirmer que cette fonctionnalité était vraiment la meilleure du langage, je voulais simplement un titre accrocheur.

IV - Remerciements

Nous remercions Andrzej Krzemieński de nous avoir autorisés à publier cette traduction de son article, paru originellement en anglais à l'adresse suivante : https://akrzemi1.wordpress.com/2013/07/18/cs-best-feature/#more-5528.

Nous remercions kurtcpp pour la traduction de ce tutoriel, ainsi que JolyLoic, Lolo78, ClaudeLELOUP, Laethy et Francis pour leur relecture.