C++ : le RAII
Rédigé par Nicolas K Aucun commentaireLe C++ a souvent l’image d’un langage compliqué avec lequel il faut gérer la mémoire manuellement et où toute inadvertance mène irrémédiablement à une erreur inexplicable ou à des fuites mémoires fourbes.
Pourtant, un seul principe permet d’éviter les fuites mémoires et un grand nombre d’erreurs inexplicables : le RAII (qui signifie Resource Acquisition Is Initialization). En français : l’acquisition d’une ressource doit correspondre à l’initialisation.
Ce tuto va parler de ce principe, comment sa mise en place est facilitée par le C++ moderne et comment le mettre en place pour ses propres ressources.
Sans RAII, les dangers
En C++ moderne, le RAII est appliqué par le langage lui-même et il est donc assez rare de devoir le gérer dans ses propres classes dans un projet purement C++. Pour autant, il reste indispensable de le comprendre car il reste utile dans bien des situations ; par exemple lorsqu’on souhaite utiliser une bibliothèque C dans un projet C++. Prenons cet exemple avant de généraliser le principe.
La plupart du temps, les bibliothèques C bien écrites permettent de créer des objets via une fonction createObjet
, objets qu’il faut ensuite libérer en utilisant une fonction freeObjet
.
Lorsqu’on souhaite utiliser un tel objet, on a un code qui peut ressembler à cela :
int main()
{
auto objet = createObjet();
// on utilise notre objet
freeObjet(objet);
}
Ce code a un énorme défaut : il faut toujours penser à libérer notre objet en appelant la méthode freeObjet
. C’est déjà source d’erreur. Mais surtout, il faut veiller à ce que cette méthode soit appelée dans tous les chemins d’exécution possibles. Imaginons le code qui suit :
void traitement()
{
auto tableau = std::vector<int>{};
auto objet = createObjet();
tableau.at(0) = 7;
freeObjet(objet);
}
Ici, la ligne tableau.at(0) = 7
envoie une exception. Cette exception n’est pas attrapée. La méthode freeObjet
n’est pas appelée.
Autre cas :
int traitement()
{
auto objet = createObjet();
if(!aBesoinDeTraitement(objet)) return 0;
auto resultat = traiterObjet(objet);
freeObjet(objet);
return resultat;
}
Ce code vous paraît-il correct ? On a bien pensé à libérer la mémoire dans le cas où le traitement est effectué, mais pas dans le cas du if qui retourne 0. Ce code entraîne donc une fuite de mémoire dans le cas où l’objet n’a pas besoin d’être traité.
Le RAII à la rescousse
Mais alors quelle est la solution ? Comment faire en sorte que la ressource soit toujours libérée, quoi qu’il arrive ?
Ce que vous savez peut-être déjà, c’est que pour un objet alloué de façon statique, dans la pile, le destructeur est toujours appelé. C’est pour cette raison qu’une bibliothèque (bien faite) en C++ ne nécessite jamais d’appeler manuellement une méthode de libération de ressource :
int traitement()
{
auto tableau = std::vector<int>{};
auto objet = ObjetCPP{};
if(!aBesoinDeTraitement(objet)) return 0;
tableau.at(0) = 7;
auto resultat = traiterObjet(objet);
return resultat;
}
Dans le code précédent, avec ObjetCPP
une classe C++ bien faite, le destructeur est appelé quoi qu’il arrive : dans le cas où l’objet n’a pas besoin de traitement et ensuite malgré le fait qu’une exception est lancée par la ligne tableau.at(0) = 7;
En quoi consiste donc le principe RAII ? Simplement à utiliser ce mécanisme systématiquement lors de l’acquisition de ressource, en encapsulant toutes les ressources dans des classes dédiées à l’acquisition et la libération de chacune d’elles. En d’autres termes, dans notre cas, on peut écrire le code suivant :
class ObjetCPP {
public:
ObjetCPP() {
ressource = createObjet();
}
protected:
~ObjetCPP() {
freeObjet(ressource);
}
private:
Objet* ressource;
};
Et maintenant, si on utilise toujours ObjetCPP
et plus Objet*
, on n’aura plus jamais de fuite de mémoire !
Vous remarquerez que le destructeur est protected
(pour rappel, un destructeur doit être protected
ou virtual
, cf l’une des règles du NVI dans le tuto précédent. J’écrirai peut-être prochainement un article sur cette règle en particulier).
Un petit problème subsiste : les fonctions aBesoinDeTraitement
et traiterObjet
ont besoin d’un pointeur vers Objet
et non d’un ObjetCPP
. Mais il est relativement simple d’adapter le code pour qu’il fonctionne.
Le RAII dans la bibliothèque standard
Maintenant comme je le disais, en C++ moderne, il est rare de devoir gérer les ressources soi-même. Et la raison pour cela est simple : le C++ moderne intègre dans sa bibliothèque standard des objets qui le font pour nous.
Ainsi :
- on n’utilise plus de pointeurs bruts en C++ moderne, on utilise plutôt les pointeurs intelligents (std::unique_ptr , std::shared_ptr, std::weak_ptr);
- on n’utilise plus les tableaux bruts, on utilise plutôt les conteneurs de la STL (std::array, std::vector);
- on n’utilise plus de chaînes de caractères brutes (char*) mais plutôt std::string ou dans certains cas std::string_view;
- et avec ce même principe, dans un programme multi-thread, on va utiliser std::lock_guard pour verrouiller un mutex et le libérer automatiquement lors de la destruction de l’objet lock_guard.
Pour résumer
Le RAII est un principe simple qui permet de ne plus avoir à se soucier de la gestion de la mémoire. Il consiste simplement à se reposer totalement sur le fait que le destructeur est appelé quoi qu’il arrive lorsqu’un objet est alloué statiquement.
Pour résumer : toute allocation de ressource doit se faire à l’initialisation d’un objet (d’où le nom de RAII, Resource Acquisition Is Initialization) et ainsi, en libérant la ressource dans le destructeur de la classe, puisque celui-ci est toujours appelé, on est assuré que la ressource sera toujours libérée.
Et enfin, ce principe étant utilisé par la bibliothèque standard, il n’est pas nécessaire de se préoccuper de la mémoire dans la majorité des cas (si on utilise bien les pointeurs intelligents, les conteneurs de la STL et plus généralement la bibliothèque standard).
Le C++ n’a jamais été aussi facile !