====== Pourquoi le RAII est-il fondamental en C++ ? ======
En programmation "moderne", on recherche souvent l'efficacité dans l'écriture de code. Or, l'expérience le montre, la gestion manuelle de la mémoire est une source potentielle importante de perte de temps, aussi bien lors de l'écriture du code que lors des phases de débogage ou de validation du code. Le RAII est une approche permettant d'apporter des garanties plus fortes sur la gestion de la mémoire, libérant ainsi le développeur qui peut se consacrer à d'autres problématiques.
===== La situation en C ====
Imaginons le cas d'application suivant : on souhaite créer plusieurs tableaux de taille importante de manière sécurisée. C'est-à-dire que l'on souhaite ne pas avoir de fuite mémoire (allocation d'un bloc mémoire qui ne serait pas libéré).
On pourrait écrire le code suivant par exemple (peu importe les types concrets et l'implémentation des fonctions) :
code_erreur une_fonction() {
Tableau x, y, z;
x = créer_gros_tableau();
if (!est_correctement_alloué(x))
return ERREUR_ALLOCATION;
y = créer_gros_tableau();
if (!est_correctement_alloué(y)) {
libérer_tableau(x);
return ERREUR_ALLOCATION;
}
z = créer_gros_tableau();
if (!est_correctement_alloué(z)) {
libérer_tableau(x);
libérer_tableau(y);
return ERREUR_ALLOCATION;
}
Erreur erreur_1 = fonction_1();
if (erreur_1 != aucune_erreur) {
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
return ERREUR_FONCTION;
}
Erreur erreur_2 = fonction_2();
if (erreur_2 != aucune_erreur) {
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
return ERREUR_FONCTION;
}
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
return AUCUNE_ERRREUR;
}
L'approche est de tester les erreurs retournées par les fonctions et si c'est le cas, nettoyer les données avant de quitter la fonction.
On voit ici un premier problème : il faut que le développeur appelle explicitement les fonctions pour libérer la mémoire. Dans un code aussi simple, cela ne posera généralement pas de problème. Par contre, on peut sans difficulté imaginer des situations où l'allocation de la mémoire et la libération ne se seront pas réalisées dans le corps d'une seule fonction, mais à différents emplacements du programme. Le risque d'oubli sera important dans ces conditions.
Un second problème est que le code est lourd à lire. Sans gestion des erreurs, la fonction ne ferait que 8 lignes. Il est beaucoup plus difficile de comprendre ce que fait un code lorsque les lignes importantes sont noyées au milieu de pleins de lignes accessoires. (Il serait probablement possible de réduire la taille du code, mais l'idée serait la même.)
===== En C++ =====
Est-ce que le même code est utilisable en C++ ? Il compilera sans problème, mais c'est une très mauvaise idée. En effet, le C++ possède un mécanisme de gestion des erreurs supplémentaire par rapport au C, les exceptions. Si une exception est déclenchée dans l'une des fonctions (''creer_gros_tableau'', ''fonction_1'' ou ''fonction_2''), le programme sortira directement de la fonction ''une_fonction'', sans passer par les tests des erreurs. La mémoire ne sera donc pas libérée correctement, il y aura des fuites mémoire.
Une solution est d'attraper les exceptions avec ''try'' et ''catch'' :
void une_fonction() {
Tableau x, y, z;
try {
x = créer_gros_tableau();
} catch(creation_tableau_exception) {
throw std::exception("Erreur Allocation");
}
try {
y = créer_gros_tableau();
} catch(creation_tableau_exception) {
libérer_tableau(x);
throw std::exception("Erreur Allocation");
}
try {
z = créer_gros_tableau();
} catch(creation_tableau_exception) {
libérer_tableau(x);
libérer_tableau(y);
throw std::exception("Erreur Allocation");
}
try {
fonction_1();
} catch(execution_fonction_exception) {
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
throw std::exception("Erreur Fonction");
}
try {
fonction_2();
} catch(execution_fonction_exception) {
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
throw std::exception("Erreur Fonction");
}
libérer_tableau(x);
libérer_tableau(y);
libérer_tableau(z);
}
Le code n'est pas plus simple à lire que la version en C, le code important (création des tableaux, appel des fonctions, libération) est perdu au milieu de pleins de lignes de code accessoires. De plus, c'est toujours aux développeurs d'appeler les fonctions de libération des tableaux, le risque d'oubli est toujours présent. Dans ce sens, le C++ n'a pas du tout amélioré la situation par rapport au C.
La source des deux problèmes est la même : 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.
Dans les langages managés (type Java ou C#), la libération de la mémoire est prise en charge dans un mécanisme spécial ([[http://en.wikipedia.org/wiki/Garbage_collection_(computer_science)|Gabage Collector]] pour la mémoire, [[http://en.wikipedia.org/wiki/Dispose_pattern|dispose-pattern]] pour les autres ressources, plus ''using'' en C# et ''try-with-resources'' en Java 7+), le développeur n'a plus besoin de penser à cela (ce qui n'est pas forcément une bonne chose de ne plus avoir à penser à la durée de vie des objets et de qui est responsable des objets...) L'erreur souvent faite est de croire que le C++ impose la gestion manuelle de la mémoire et est donc moins sûr, plus compliqué, moins productif que les autres langages. Ce qui n'est pas le cas, comme on va le voir.
Remarque : même si j'ai parlé d'allocation d'un tableau en mémoire dans cet exemple, le terme "acquisition d'une ressource" est à prendre au sens large. Il faut inclure tout ce qui nécessite une prise de responsabilité d'une ressource et la libération de cette responsabilité. Des exemples classiques sont l'ouverture et la fermeture d'un fichier, la connexion et la déconnexion d'une base de données, le verrouillage et la libération d'un mutex, etc.
file.open();
...
file.close();
sql.connect();
...
sql.free();
mutex.lock();
...
mutex.unlock();
Toutes ces situations présentent le même risque d'absence de libération de la ressource.
Pour en savoir plus sur l'approche exception vs retour de fonction, vous pouvez lire l'article de Aaron Lahman traduit en français : [[http://alexandre-laurent.developpez.com/cpp/retour-fonctions-ou-exceptions/|Retour de fonctions ou exceptions ?]]
===== Le RAII =====
Le RAII ("Ressource Acquisition Is Initialization") est une technique pour éviter d'avoir à appeler manuellement la fonction de libération d'une ressource. Plus de risque d'oubli ou d'exception qui empêche la libération. L'idée est de réaliser l'acquisition de ressource uniquement lors de l'initialisation d'une classe dédiée :
class TableauSecurisé {
Tableau tableau_interne;
public:
TableauSecurisé(Tableau t) : tableau_interne(t) {
}
};
void une_fonction() {
TableauSecurisé x = creer_gros_tableau();
TableauSecurisé y = creer_gros_tableau();
TableauSecurisé z = creer_gros_tableau();
fonction_1();
fonction_2();
// quand sont appelées les fonctions libérer_tableau ?
}
Dit comme ça, je suppose que cela ne vous aide pas beaucoup à comprendre comment le RAII garantit la libération de la mémoire. La raison est que le terme RAII est très mal nommé :) (désolé, Stroustrup).
En fait, ce qui fait l'intérêt du RAII, c'est surtout que la libération sera appelée dans le destructeur :
class TableauSecurisé {
Tableau tableau_interne;
public:
TableauSecurisé(Tableau t) : tableau_interne(t) {
}
~TableauSecurisé() {
libérer_tableau(tableau_interne);
}
};
void une_fonction() {
TableauSecurise x = creer_gros_tableau();
TableauSecurisé y = creer_gros_tableau();
TableauSecurisé z = creer_gros_tableau();
fonction_1();
fonction_2();
}
Dans ce cas, les objets de type ''TableauSecurisé'' sont détruits lors de la sortie de la fonction ''une_fonction'' et la fonction ''libérer_tableau'' est appelée automatiquement par le destructeur de la classe. Il n'y a plus besoin d'appeler la fonction ''libérer_tableau'' manuellement. Même en cas d'exception, le destructeur sera appelé et le tableau sera libéré. En fait, le seul cas où le destructeur ne sera pas appelé, c'est lorsque l'exception sera lancée dans le constructeur. Mais dans ce cas, la ressource n'aura pas été acquise et il ne sera pas nécessaire de la libérer.
Remarque : si le constructeur contient plusieurs lignes de code qui peuvent lancer des exceptions, le destructeur ne sera pas appelé. Il faut donc bien faire attention que dans ce cas, les ressources acquises soient manuellement libérées. Pour éviter cette problématique, le mieux est d'avoir une classe qui ne fait qu'une seule chose : gérer la ressource (et donc ne pas pouvoir lancer d'autres exceptions dans le constructeur que l'acquisition de la ressource. De plus, pour des raisons de respect du Principe de Responsabilité Unique (SRP), une classe RAII ne doit rien faire d'autre que de gérer une ressource).
Le cas de l'allocation de tableau est généralisable à n'importe quelle allocation de ressources. On peut créer facilement des classes RAII pour les problèmes cités ci-dessus :
class FichierSecurise {
Fichier fichier_interne;
public:
FichierSecurise(Fichier f) : fichier_interne(f) {
fichier_interne.open();
}
~FichierSecurise() {
fichier_interne.close();
}
};
class MutexSecurise {
Mutex mutex_interne;
public:
MutexSecurise(Mutex m) : mutex_interne(m) {
mutex_interne.open();
}
~MutexSecurise() {
mutex_interne.close();
}
};
Et ainsi de suite.
===== Le RAII et la STL =====
En pratique, écrire une classe RAII demande un peu plus de code. Il faut en effet respecter la sémantique de valeur, ajouter les constructeurs par copie, par déplacement, les opérateurs d'affectation, etc. Peut-être également utiliser des abstractions un peu plus évoluées (template) pour avoir un code plus générique.
Heureusement, une grande partie du travail peut être simplifiée, puisque la bibliothèque standard (STL) utilise le RAII et propose de nombreuses fonctionnalités pour la gestion des ressources. Ainsi, en C++, on évitera d'utiliser les syntaxes héritées du C, mais on utilisera (et abusera) des classes de la STL. Par exemple :
^ Fonctionnalité ^ En C ^ En C++ ^
| Créer une chaîne de caractères | char* s; | std::string s; |
| Créer un tableau statique | Type v[N]; | std::array a; |
| Créer un tableau dynamique | Type* v = new[N] Type; | std::vector v; |
| Créer un fichier | FILE* f; | std::ifstream f; std::ofstream f; |
| Créer un objet sur le tas | Object* o; | std::unique_ptr