Cette page vous donne les différences entre la révision choisie et la version actuelle de la page.
pourquoi_le_raii_est_fondamental_en_c [2014/03/20 10:46] 217.128.162.199 [En C++] suppression catch(...) |
pourquoi_le_raii_est_fondamental_en_c [2020/11/16 18:11] (Version actuelle) gbdivers |
||
---|---|---|---|
Ligne 1: | Ligne 1: | ||
- | ====== Pourquoi le RAII est fondamental en C++ ? ====== | + | ====== 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. | 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. | ||
Ligne 55: | Ligne 55: | ||
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. | 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 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. | + | 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) | + | 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++ ===== | ===== 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 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 a des fuites mémoire. | + | 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'' : | Une solution est d'attraper les exceptions avec ''try'' et ''catch'' : | ||
Ligne 114: | Ligne 114: | ||
</code> | </code> | ||
- | 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 accessoire. 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. | + | 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. | 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, 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. | + | 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 par exemple 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. | + | 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. |
<code cpp> | <code cpp> | ||
Ligne 137: | Ligne 137: | ||
Toutes ces situations présentent le même risque d'absence de libération de la ressource. | 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 ===== | ||
Ligne 160: | Ligne 162: | ||
</code> | </code> | ||
- | 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) | + | 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 : | En fait, ce qui fait l'intérêt du RAII, c'est surtout que la libération sera appelée dans le destructeur : | ||
Ligne 184: | Ligne 186: | ||
</code> | </code> | ||
- | 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'a pas été acquise et il n'est pas nécessaire de la libérer. | + | 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). | 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). | ||
Ligne 222: | Ligne 224: | ||
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 : | 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++ ^ | + | ^ Fonctionnalité ^ En C ^ En C++ ^ |
- | | Créer une chaîne de caractères | char* s; | std::string s; | | + | | Créer une chaîne de caractères | char* s; | std::string s; | |
- | | Créer un tableau de données | Type* v; | std::vector<Type> v; | | + | | Créer un tableau statique | Type v[N]; | std::array<Type, N> a; | |
- | | Créer un fichier | File f; | std::iofstream f; | | + | | Créer un tableau dynamique | Type* v = new[N] Type; | std::vector<Type> v; | |
- | | Créer un objet sur le Tas | Object* o; | std::unique_ptr<Object> p; | | + | | Créer un fichier | FILE* f; | std::ifstream f; std::ofstream f; | |
- | | || std::shared_ptr<Object> p; | | + | | Créer un objet sur le tas | Object* o; | std::unique_ptr<Object> p; | |
- | | Verrouiller un mutex | ? | std::lock_guard<std::mutex> l; | | + | | ::: |:::| std::shared_ptr<Object> p; | |
+ | | Verrouiller un mutex | ? | std::lock_guard<std::mutex> l; | | ||
- | On voit qu'une grande partie des problématiques en C vient de l'utilisation des pointeurs nus ("nu" en opposition aux pointeurs "intelligent" unique_ptr et shared_ptr, qui garantissent la sécurité du code). La conséquence est qu'en C++ "moderne", on appliquera la règle suivante : | + | On voit qu'une grande partie des problématiques en C vient de l'utilisation des pointeurs nus ("nus" en opposition aux pointeurs "intelligents" ''std::unique_ptr'' et ''std::shared_ptr'', qui garantissent la sécurité du code). La conséquence est qu'en C++ "moderne", on appliquera la règle suivante : |
- | > **Aucun pointeur nu, aucun new, aucun delete (et encore moins de malloc ou de free).** | + | > **Aucun pointeur nu, aucun ''new'', aucun ''delete'' (et encore moins de ''malloc'' ou de ''free'').** |
- | (Bien sûr, comme toutes les règles, celle-ci peut être violée, mais on ne doit le faire qu'avec de bonnes raisons, en connaissance de cause et en encapsulant au maximum dans un code dédié). | + | (Bien sûr, comme toutes les règles, celle-ci peut être transgressée, mais on ne doit le faire qu'avec de bonnes raisons, en connaissance de cause et en encapsulant au maximum dans un code dédié.) |
===== Je n'utilise pas le C++11/14, est-ce que le RAII est intéressant pour moi ? ===== | ===== Je n'utilise pas le C++11/14, est-ce que le RAII est intéressant pour moi ? ===== | ||
- | Il faut admettre un état de fait : il n'est pas toujours possible d'utiliser un compilateur à jour, supportant pleinement la version actuelle du C++ (contrainte dans un environnement professionnel, machine n'ayant pas de compilateur récent, etc). Dans le cas d'une utilisation amateur par contre, aucune raison de ne pas mettre à jour son compilateur, surtout que les compilateurs les plus à jour (Clang 3.4, Gcc 4.9) sont gratuits. | + | Il faut admettre un état de fait : il n'est pas toujours possible d'utiliser un compilateur à jour, supportant pleinement la version actuelle du C++ (contrainte dans un environnement professionnel, machine n'ayant pas de compilateur récent, etc.). Dans le cas d'une utilisation amateur par contre, aucune raison de ne pas mettre à jour son compilateur, surtout que les compilateurs les plus à jour (Clang 3.4, Gcc 4.9) sont gratuits. |
- | Dans ce cas, il ne sera pas possible d'utiliser les classes comme ''std::unique_ptr'', ''std::shared_ptr'' ou ''std::lock_guard''. Cependant, cela ne signifie pas qu'il faut abandonner le RAII. Il existe d'autres classes supportant depuis longtemps le RAII dans la STL (''std::vector'', ''std::string'', etc), qu'il faut absolument utiliser. Pour les autres classes, il sera possible d'utiliser des bibliothèques apportant ces fonctionnalités (en premier lieu Boost : ''boost::unique_ptr'', ''boost::shared_ptr'', ''boost::lock_guard''). | + | Dans ce cas, il ne sera pas possible d'utiliser les classes comme ''std::unique_ptr'', ''std::shared_ptr'' ou ''std::lock_guard''. Cependant, cela ne signifie pas qu'il faut abandonner le RAII. Il existe d'autres classes supportant depuis longtemps le RAII dans la STL (''std::vector'', ''std::string'', etc.), qu'il faut absolument utiliser. Pour les autres classes, il sera possible de se servir des bibliothèques apportant ces fonctionnalités (en premier lieu Boost : ''boost::scoped_ptr'', ''boost::shared_ptr'', ''boost::lock_guard''). |
- | Et même si ces classes ne sont pas disponible, il faut comprendre une chose : le RAII n'est pas défini par un ensemble de classe de la STL, c'est une façon de concevoir sa gestion des ressources. Les classes de la STL ne sont qu'une implémentation du RAII. Si on ne peut pas utiliser les classes de la STL, il ne faut surtout pas écrire du code directement sans RAII : | + | Et même si ces classes ne sont pas disponibles, il faut comprendre une chose : le RAII n'est pas défini par un ensemble de classes de la STL, c'est une façon de concevoir sa gestion des ressources. Les classes de la STL ne sont qu'une implémentation du RAII. Si on ne peut pas utiliser les classes de la STL, il ne faut surtout pas écrire du code directement sans RAII : |
<code cpp> | <code cpp> | ||
Ligne 249: | Ligne 252: | ||
</code> | </code> | ||
- | Il faut au contraire prendre le temps d'écrire des classes RAII apportant des garanties fortes de libération des ressources. L'utilisation de new et delete dois être localisé uniquement dans ces classes RAII et exclu du reste du code. | + | Il faut au contraire prendre le temps d'écrire des classes RAII apportant des garanties fortes de libération des ressources. L'utilisation de ''new'' et ''delete'' doit être localisée uniquement dans ces classes RAII et exclue du reste du code. |
<code cpp> | <code cpp> | ||
Ligne 255: | Ligne 258: | ||
int* p; | int* p; | ||
public: | public: | ||
- | IntArraySafe(int n) : p(new int[i]) { | + | IntArraySafe(int n) : p(new int[n]) { |
} | } | ||
- | ~IntSafe() { | + | ~IntArraySafe() { |
delete[] p; | delete[] p; | ||
} | } | ||
}; | }; | ||
</code> | </code> | ||
- | |||
===== On m'a dit d'utiliser les pointeurs nus avec Qt, qui a raison ? ===== | ===== On m'a dit d'utiliser les pointeurs nus avec Qt, qui a raison ? ===== | ||
Ligne 286: | Ligne 288: | ||
</code> | </code> | ||
- | Le principe est que lors de la destruction d'un objet dérivé de ''QObject'', le destructeur prend la liste des objets enfant et les détruit (ce qui provoque une destruction en cascade des objets parent-enfant). Lorsque l'on utilise l'une des syntaxes précédentes (ou équivalent), les objets seront aussi détruits comme n'importe quel objet C++. Il y aura donc tentative de double destruction des objets, ce qui provoque une erreur à l'exécution. | + | Le principe est que lors de la destruction d'un objet dérivé de ''QObject'', le destructeur prend la liste des objets enfants et les détruit (ce qui provoque une destruction en cascade des objets parent-enfant). Lorsque l'on utilise l'une des syntaxes précédentes (ou équivalent), les objets seront aussi détruits comme n'importe quel objet C++. Il y aura donc tentative de double destruction des objets, ce qui provoquera une erreur à l'exécution. |
Au contraire, les objets en dehors d'une hiérarchie doivent être détruits selon les approches classiques du C++ : | Au contraire, les objets en dehors d'une hiérarchie doivent être détruits selon les approches classiques du C++ : | ||
Ligne 320: | Ligne 322: | ||
Cela est un héritage des premiers jours de Qt, il n'est malheureusement pas possible de faire autrement. Ce qui veut dire que lorsque l'on utilise Qt, il faut utiliser des syntaxes différentes selon le type des objets et surtout ne pas oublier de définir un parent. | Cela est un héritage des premiers jours de Qt, il n'est malheureusement pas possible de faire autrement. Ce qui veut dire que lorsque l'on utilise Qt, il faut utiliser des syntaxes différentes selon le type des objets et surtout ne pas oublier de définir un parent. | ||
+ | |||
+ | À lire : [[http://blog.codef00.com/2011/12/15/not-so-much-fun-with-qsharedpointer/|(Not so much) Fun with QSharedPointer]]. | ||
{{tag> C++ Qt}} | {{tag> C++ Qt}} |