dans Programmation

Simplifier la gestion de la mémoire en C++ avec RAII

Tutoriel publié à l’origine sur Progdupeupl

La gestion des ressources est un problème récurrent en informatique. En effet, on ne dispose que de ressources limitées : RAM, disques durs, nombre de calculs par seconde, etc. Et aujourd’hui, il faut admettre qu’on charge de plus en plus de ressources qui prennent de la place. Il faut donc les gérer efficacement. Certains langages, comme le C, obligent l’utilisateur à allouer et libérer de la mémoire pour les ressources et il faut dire que c’est contraignant.

Le C++, en raison de l’approche historique qui en est malheureusement faite dans beaucoup d’ouvrages, est utilisé par certains développeurs comme le C, en gérant les ressources de manière manuelle. Pourtant, il existe un idiome très simple et efficace que nous allons découvrir dans ce tutoriel. Alors oubliez vos new et delete et découvrez ce que C++ offre.

Un grand merci à Davidbrcz pour son aide à l’amélioration de ce tutoriel, ainsi que tous ceux qui ont relevé des fautes (mention spéciale à Dominus Carnufex).

Gestion manuelle de la mémoire

Bien souvent, dès qu’on manipule des ressources externes, du type image à charger et afficher, connexion à une base de données, ou à un serveur, ou autres, il est inévitable de devoir réserver de la mémoire de façon dynamique. Pour ceux qui ont fait du C, vous pensez sans doute aux pointeurs et vous avez bien raison. Prenons donc un bête exemple : on se connecte à une base de données, on récupère un nombre fixé de noms de trains, on ouvre un fichier, on le verrouille, on travaille ensuite dessus avant de tout refermer comme il se doit.

Pourtant, ce code est juste une horreur à éviter. Pourquoi ? Parce qu’aucune vérification n’est faite. Si une seule opération échoue, on est bon pour un segfault. Alors, sécurisons ce code (merci à Taurre ).

Quelle plaie à écrire ! Non seulement c’est long, mais en plus, c’est plus complexe à comprendre, on peut avoir oublié certains cas, bref, un cauchemar. Et encore, on aurait pu avoir à initialiser plus de ressources encore.

Peut-être certains d’entre vous pensent que goto, c’est un héritage du C dépassé, et qu’en C++ on devrait plutôt utiliser les exceptions. Soit, essayons.

Finalement, ce code ne nous apporte aucun avantage par rapport au précédent : toujours aussi gros, toujours aussi illisible, et nous ne sommes même pas sûrs de couvrir tous les chemins possibles : un oubli est possible, une fonction apparemment inoffensive peut lancer une exception, bref, toujours un cauchemar à maintenir.

Que retenir jusque là : que la détection d’erreurs par retour de fonctions et goto ou par le biais d’exceptions nécessite d’ajouter des if ou des try catch toutes les deux lignes. En fait, dans ces cas de figure, chaque ligne où l’on acquiert une ressource qui n’est pas suivie d’un if ou entourée d’un try catch est suspecte et peut potentiellement faire échouer l’exécution.

Le cœur du problème tient en une phrase : le développeur doit écrire du code spécifique pour la libération de la mémoire et la gestion des erreurs. Pour améliorer la situation, il faut obligatoirement libérer le développeur de cette tâche, qu’elle soit automatique. Or, contrairement au C# ou au Java qui disposent d’un mécanisme de libération de la mémoire transparent et automatique appelé garbage collector, il n’est rien de tel en C++1. Sommes-nous donc condamnés à devoir écrire des codes aussi lourds ? Non, car une solution existe déjà.

L’idiome RAII

Le C++ propose un idiome particulier appelé RAII, pour Resource Acquisition Is Initialization, ce que l’on peut traduire par « acquisition de ressources lors de l’initialisation » en français. Comment fonctionne-t-il ? Chaque ressource sera manipulée par une variable locale qui va l’acquérir à la construction et la libérer à la destruction. Ainsi, l’utilisateur n’aura même plus à se soucier d’appeler les fonctions free, unlock et autres delete pour que la libération des ressources ait bien lieu.

Pour appliquer cet idiome en C++, nous allons utiliser les classes et en particulier le couple constructeur(s) / destructeur. On peut parler de capsules RAII.

  • Toutes les ressources seront acquises dans le constructeur ; si des ressources sont impossibles à acquérir, on lève une exception. Ainsi, il n’y a pas de risque de créer un objet incomplet (Ill formed en anglais) donc pas de risque de fuite de mémoire : la norme garantit en effet que si un constructeur lève une exception, toute la mémoire des membres déjà allouée est libérée.
  • Toutes les ressources seront libérées dans le destructeur. Celui-ci étant appelé automatiquement dès que l’objet est détruit, on y écrira tous les mécanismes de libération de la ressource acquise dans le constructeur.

Un constructeur ne peut acquérir, au maximum, qu’une seule ressource non encapsulée par un mécanisme RAII. La classe contenant pour ce constructeur devient alors une capsule RAII pour cette ressource.

Voyons sans plus tarder comment appliquer ce principe à notre code précédent. Commençons par encapsuler nos ressources dans des classes, en prennant par exemple le SGBD.

Maintenant, nous pouvons écrire du code aussi simple que celui ci-dessous (et nous verrons que nous pouvons faire encore plus simple dans la section suivante).

Les ressources sont libérées à la sortie du bloc dans lequel nous les avons acquises, c’est-à-dire ici en sortant de la fonction. Voyez par vous-mêmes l’exemple suivant.

Gestion des erreurs

l reste néanmoins un problème que nous ne gérons pas encore : que fait-on si une erreur survient lors de l’acquisition ou de la libération des ressources ? Examinons chacun des cas.

Erreur lors de l’acquisition

Si on ne peut acquérir une ressource, alors l’objet ne peut être construit. Le mieux est donc de lancer une exception.

Lors de la construction d’un objet, si jamais le constructeur lance une exception, alors toute la mémoire réservée pour les membres sera libérée. Si jamais le constructeur a alloué de la mémoire dynamiquement de quelque manière que ce soit, alors cette dernière n’est pas libérée.

Enfin, un conseil important que je répète : si on a plusieurs ressources à acquérir dans un même constructeur, il vaut mieux que chaque ressource soit encapsulée dans sa propre capsule RAII ; ainsi, chaque ressource sera libérée par son propre destructeur et on s’évite bien des soucis.

Erreur lors de la destruction

Ces cas-là sont problématiques. En effet, il est impossible de lancer une exception. Pourquoi ? Nous savons que le destructeur d’un objet sera appellé si une exception est lancée dans le code ; or, si le destructeur lance lui aussi une exception, nous nous retrouvons avec deux exceptions sur les bras, ce qui provoque un appel à la fonction terminate() et donc l’arrêt brutal du programme. De même, n’appelez jamais de fonctions dans le destructeur qui sont susceptibles de lancer des exceptions.

On peut néanmoins utiliser un système de logs pour informer l’utilisateur qu’une erreur dans la libération des ressources est arrivée. Quant à savoir si l’on continue l’exécution ou s’il vaut mieux tout arrêter, c’est à vous de voir en fonction des situations.

Un mot sur le dispose pattern

eut-être venez-vous d’un langage où il existe un mot-clef finally, utilisé à la suite d’un try catch et exécuté peu importe si une exception a été attrapée ou non ; ou bien existe-t-il des constructions similaires du type using (C#), with (Python) ou encore try-with-ressources (Java 7+). Dans tous les cas, le but est le même : empêcher des fuites de mémoire en libérant des ressources précédemment allouées. C’est ce qu’on appelle le dispose pattern.

Pourtant, C++ ne fournit pas de mot-clef ou de construction similaire à celles de Java ou C# pour la simple et bonne raison que RAII nous permet de faire la même chose de façon plus efficace. Qu’est-ce qui me permet de dire ça ? Je laisse le créateur du C++ répondre.

Bjarne Stroustrup

In a system, we need a « resource handle » class for each resource. However, we don’t have to have an « finally » clause for each acquisition of a resource. In realistic systems, there are far more resource acquisitions than kinds of resources, so the « resource acquisition is initialization » technique leads to less code than use of a « finally » construct.

Traduction libre

Dans un système, il faut une « capsule RAII » pour chaque ressource. Cependant, nous n’avons pas besoin d’une clause « finally » pour chaque acquisition de ressource. Dans des systèmes réalistes, il y a beaucoup plus d’acquisitions de ressources que de types de ressources, donc le RAII conduit à écrire moins de code que l’utilisation d’une construction avec « finally ».

Exemples d’application avec la bibliothèque standard

La bibliothèque standard utilise énormément cet idiome, à travers des noms qui vous sont certainement familliers : std::string, std::array, std::vector, std::ifstream, etc. Quand on y réfléchit, a-t-on déjà libéré manuellement un std::string ? Non, car c’est fait automatiquement pour nous. Et pour vous montrer à quel point la bibliothèque standard est infiniment supérieure à tout ce qu’on pourait faire manuellement, reprenons notre code de début en utilisant les mécanismes standards.

N’est-ce pas plus clair à lire et à comprendre ? Premier point à retenir : toujours utiliser au maximum la bibliothèque standard. Pourquoi se frustrer à faire un code comme on ferait en C quand on peut profiter de mécanismes éprouvés, performants et sûrs comme ceux proposés par la bibliothèque standard ? Donc faites-y appel le plus possible, ce sera du temps et du confort de gagnés.

Cas particulier des pointeurs

Notre code n’est pas encore tout à fait satisfaisant. En effet, il reste un pointeur. Or, les pointeurs nus sont source de beaucoup de problèmes en C++. Et si on pouvait ne pas avoir à écrire Sgbd_Capsule, ce serait encore mieux. Heureusement, la bibliothèque standard arrive encore une fois à notre secours en fournissant des pointeurs intelligents qui nous libèrent des contraintes de libération que l’on connait si bien en C.

La norme C++11 nous propose plusieurs types de pointeurs intelligents :

  • std::auto_ptr : déprécié, à ne plus utiliser ;
  • std::unique_ptr : comme son nom l’indique, à utiliser quand on ne veut avoir qu’un seul pointeur sur un objet ;
  • std::shared_ptr : utilise un système de comptage de références qui permet que plusieurs pointeurs pointent un même objet, ce dernier étant libéré quand le dernier pointeur pointant dessus est détruit ;
  • std::weak_ptr : si l’on n’y prend pas garde, les std::shared_ptr peuvent entrainer un problème de références circulaires (lisez donc cet article de Developpez qui illustre ce problème). Il sert également dans le cas d’une ressource avec plusieurs observateurs non propriétaire. Je vous invite à lire cet article pour des explications plus approfondies sur lequel choisir.

Nous avons également deux templates bien pratiques :

  • std::make_shared<T> : construit un objet T et le met dans un std::shared_ptr (disponible avec C++11) ;
  • std::make_unique<T> : construit un objet T et le met dans un std::unique_ptr (disponible avec C++14, voir ici pour une implémentation en C++11).

Ces templates sont à utiliser le plus possible car ils permettent d’écrire un code exception-safe. Lisez l’article de Herb Sutter à ce propos.

Et en plus, le mieux du mieux, on peut définir des deleters, c’est-à-dire définir comment le pointeur va libérer sa ressource. Il suffit simplement de créer une classe sur ce modèle que l’on passera ensuite en argument à notre pointeur intelligent.

Et comme un exemple vaut mille explications, utilisons ce principe avec notre SGBD et notre mécanisme de verrouillage qui se prêtent bien au jeu. Mais comme rien n’est parfait, les fonctions std::make_shared<T> et std::make_unique<T> ne prennent pas de deleter en argument. Il nous faut passer par la construction classique.

Les pointeurs intelligents nous permettent également d’éviter le problème du constructeur qui alloue lui-même de la mémoire que nous avons vu dans la section précédente. En effet, les pointeurs intelligents seront bien libérés même si l’on rencontre une exception. Donc utilisez-les dès que vous pouvez, quite à réécrire une version fonctionelle des pointeurs intelligents ou utiliser Boost si vous ne pouvez pas compiler en C++11 / C++14.

Deuxième point à retenir : chaque fois qu’il est nécessaire d’utiliser des pointeurs, utilisez des pointeurs intelligents. Les cas où vous devrez obligatoirement utiliser des pointeurs nus sont très rares, alors utilisez la solution la plus confortable.

Bonnes pratiques

L’idéal, quand on gère des ressources, est de les libérer dès que possible. Non seulement cela est obligatoire dans certains cas (afin de ne pas faire attendre un processus trop longtemps pour ouvrir un fichier par exemple), mais en plus cela permet de soulager le système. Comment traduire cette bonne pratique en utilisant l’idiome RAII ? Eh bien, il faut que l’on détruise nos objets s’occupant des ressources le plus vite possible, ce qui est possible en utilisant des blocs d’instructions.

Il s’agit d’une pratique courante que vous pourrez voir dans certains codes. Et bien entendu, le corolaire : ne déclarez vos objets que quand vous en avez besoin et pas avant. Alors oubliez les réflexes du C89 qui consistent à déclarer toutes les variables au début d’un bloc et ne le faites que pour un usage immédiat (sauf exception).

La const-correctness

Ce n’est pas une bonne pratique spécifique au RAII, mais dès qu’une ressource est censée être constante, alors il faut impérativement utiliser le mot-clef const. Cela donne des garanties à l’utilisateur et, couplé avec des références, permet un passage en argument plus rapide.

D’ailleurs, petite astuce (merci Herb Sutter), si l’on veut déclarer un objet constant alors que ses paramètres dépendent de conditions, on peut y arriver grâce aux lambdas.

Et dans les autres langages ?

Bien que le C++ ait été le précurseur et le plus grand utilisateur de l’idiome RAII, aujourd’hui, il n’est plus le seul. D’autres langages permettent, par des moyens assez similaires, d’utiliser une sorte de RAII.

Avec C

Bien que cette possibilité soit offerte par une extension de GCC et donc non standard, elle mérite le détour et peut être intéressante pour ceux dont les applications ne seront compilées que par GCC. Il s’agit de l’attribut cleanup. Voici un exemple tiré de la page Wikipédia consacrée au RAII.

Avec D

Le D fournit trois méthodes pour permettre la libération des ressources, dont une identique à celle utilisée en C++ : le couple constructeur / destructeur d’une classe. Les exemples suivants sont tirés du site officiel.

La seconde façon se rapproche de celle de Java avec un try finally.

Enfin, il existe une troisième méthode, originale par rapport aux deux autres : scope(exit). Tout le code qui sera placé après cette instruction sera exécuté peu importe si la fonction se termine normalement ou si une exception est lancée. Elle se décline également sous deux autres formes : scope(failure) où le code ne sera exécuté qu’en cas d’exception et scope(success) où le code sera exécuté en cas de déroulement normal. La documentation complètera mes explications.

Avec Rust

Rust, langage développé par la fondation Mozilla, utilise le RAII de la même manière que C++. Et comme un code est plus parlant, voici celui tiré de la page consacrée au RAII avec Rust.

Nous voilà arrivés à la fin de ce tutoriel qui, je l’espère, vous en aura appris un peu plus sur C++. Bien entendu, le RAII n’est pas parfait : le pire qui puisse arriver est une erreur dans le destructeur. Mais hormis ces cas critiques, c’est un idiome particulièrement pratique et puissant, alors usez-en et abusez-en !

1 : Le C++ peut se voir doter d’un garbage collector. C’est quelque chose de prévu par la norme. Dans ce tutoriel, nous n’aborderons pas cette possibilité.

Laisser un commentaire