C++ : Gérer correctement ses allocations dynamiques

Formation

En Ligne

Prix sur demande

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

Description

  • Typologie

    Formation

  • Méthodologie

    En ligne

Grâce à cette formation vous pourrez acquérir les connaissances nécessaires qui vous permettrons d’ajouter des compétences à votre profil et obtenir de solides aptitude qui vous offriront de nombreuses opportunités professionnelles.

Questions / Réponses

Ajoutez votre question

Nos conseillers et autres utilisateurs pourront vous répondre

À qui souhaitez-vous addresser votre question?

Saisissez vos coordonnées pour recevoir une réponse

Nous ne publierons que votre nom et votre question

Les Avis

Les matières

  • C++

Le programme

Introduction du cours

En C++, la mémoire allouée dynamiquement n'est pas libérée automatiquement.
Vouloir gérer ceci manuellement tout en produisant du code correct est une tâche très difficile et risquée. On peut donc s'intéresser à la question suivante : comment automatiser tout cela ?

Rappels et prise de conscienceRappels

Qu'est-ce qu'une allocation dynamique ? C'est simplement l'allocation d'un bout de mémoire sur le tas. Par opposition, l'allocation statique est faite sur la pile. Les objets alloués sur le tas ont une durée de vie que le programmeur peut contrôler autrement que par les blocs : leur destruction n'est pas automatique. En revanche, pour les objets alloués sur la pile, la destruction est faite à la fin du bloc où se trouve la déclaration. Si leur destruction n'est pas automatique, c'est que c'est justement au programmeur de la faire. Ainsi, le C++ offre quatre opérateurs pour gérer l'allocation dynamique : new, new[], delete et delete[]. On les utilise très simplement :

int* array = new int[42]; delete[] array; A* obj = new A("blabla", true, 'a'); delete obj;

new alloue un objet unique sur le tas tandis que new[] alloue une série d'objets, un tableau. Respectivement, il faut utiliser delete et delete[] pour détruire les entités allouées. Il est très important de ne pas mélanger ces opérateurs, on ne fait pas n'importe quoi avec l'allocation dynamique.

Pourquoi ne pas utiliser std::malloc() qui est une fonction héritée du C ? Tout simplement parce que std::malloc() n'est pas du tout adaptée au C++ : en C, les types sont tous ce qu'en C++ on appelle PODT (plain old data type). Un certain nombre de règles doivent être satisfaites pour qu'un type soit un PODT (vous trouverez plus d'info sur wikipédia à ce sujet) et notamment l'absence de constructeur et de destructeur dans une structure. C'est là le problème : std::malloc() n'appelle pas le constructeur et la fonction de libération de la mémoire qui va avec, std::free(), n'appelle pas le destructeur. S'il n'y a pas appel au constructeur et au destructeur, automatiquement, un objet non-POD ne pourra pas correctement être construit ni correctement être détruit. Pourtant, c'est un comportement qu'on attend d'un système d'allocation dynamique en C++, et comme les opérateurs new, etc. ne sont pas aussi restrictifs et fonctionnent aussi sur les PODT, on préfèrera toujours utiliser new, delete, etc. plutôt que std::malloc() et std::free().

Quand bien même on serait amené à utiliser std::malloc(), il faut impérativement libérer la mémoire avec std::free() et avec rien d'autre. Plus généralement, il y a un principe très simple que l'on dicte très souvent aux débutants et qui permet de faire du code correct au niveau de la gestion de la mémoire : un new = un delete ; un new[] = un delete[] ; un std::malloc() = un std::free().

Le danger dans tout ça

C'est bien beau, mais si l'on suit toujours cette règle à la lettre, notre code restera toujours garanti sans fuite de mémoire et autre tracas ? Normalement oui, mais en pratique ... non. Un système d'allocations dynamiques géré entièrement ainsi ("à la main" vais-je dire) ne sera jamais ou presque jamais à 100% sécurisé. Du moins, pour un projet de taille conséquente, il est très difficile de parvenir à ce résultat et quand on y arrive, on aura tellement amoché le code qu'il ne ressemblera à plus rien. "À la main", on arrivera pas à trouver un bon compromis entre "code lisible" et "code correct". Le principal concept du C++ qui présente un réel danger pour ces systèmes gérés "à la main" sont les exceptions. Plus rarement, il peut tout simplement y avoir un oubli quelque part.

Les exceptions sont des objets qui peuvent être lancés à partir d'un point du programme (souvent quand un problème s'est présenté) et qui, une fois lancés, remontent la pile d'appels jusqu'à ce qu'ils rencontrent un système de réception (un bloc try) suivi d'un système de traitement (un ou plusieurs bloc catch). Un ensemble try - catch suit toujours cette logique : "on exécute le bout de code que je contiens et s'il lance une exception, je la rattrape et je la traite selon son type". C'est un mécanisme d'interruption de code et de gestion d'erreurs très pratique dans beaucoup de cas. Si une exception n'est jamais rattrapée, c'est-à-dire si elle n'est même pas rattrapée dans le main, on obtient une erreur fatale.

Pourquoi ces exceptions présentent-ils un réel problème pour l'allocation dynamique "nue" ? Imaginez simplement un bout de code présentant à la chaîne une allocation dynamique, un appel à une fonction (appelons-la f()) et la libération de la mémoire allouée juste avant. Si f() lance une exception, la mémoire allouée dynamiquement ne sera jamais libérée puisque l'instruction de libération ne sera jamais exécutée. On pourrait évidemment contourner ce problème avec quelques blocs en plus, mais le code deviendrait carrément ingérable et totalement incompréhensible.

Autre exemple : imaginez une classe qui fait deux allocations dynamiques pour deux pointeurs membres dans le constructeur et que leur libération est faite dans le destructeur. À priori, pas de problème. Mais si la deuxième allocation échoue (ce qui peut arriver) mais que la première a réussi, une exception est lancée et l'objet n'est ainsi pas considéré comme existant, comme construit. En d'autres termes le destructeur n'est jamais appelé et la première zone pourtant allouée avec succès ne sera jamais libérée.

D'autres problèmes qui ne sont pas forcément liés aux exceptions peuvent aussi survenir comme la tentative de libération d'une zone non-allouée (par exemple quand on essaye de delete un pointeur non-initialisé ou qu'on delete deux fois un pointeur sur une zone allouée dynamiquement), ou encore la tentative d'accès à la valeur pointée par un pointeur invalide : imaginez deux pointeurs sur le même objet alloué sur le tas, on peut très bien par accident "deleter" un pointeur et continuer à bosser avec l'autre comme si de rien n'était.

Le C++ possède pourtant la capacité de contrecarrer ces problèmes, et je vais présenter une technique très répandue de gestion sécurisée des allocations dynamiques.

Solution : les pointeurs intelligents

Une idée savante qu'on eu très tôt les programmeurs, c'est de ne plus manipuler des pointeurs "nus" (comme int*, float*, A**, etc.), mais des pointeurs encapsulés dans une classe à qui l'on confierait le travail d'assurer sa bonne gestion. Une instance d'une telle classe, allouée statiquement, serait alors en mesure de libérer à sa destruction automatiquement la mémoire allouée, ou encore d'empêcher les tentatives d'accès aux données de la zone pointée si celle-ci n'est pas allouée, etc. On parle de pointeur intelligent.

Une méthode plus générale : la RAII

Ce que j'ai décrit plus haut n'est qu'une application pratique parmi d'autres d'un principe très répandu en programmation orientée objet : RAII, pour "Ressource Acquisition Is Initialisation". Très simplement, ce principe nous dit que pour chaque acquisition de ressource que l'on fait, on crée aussi un objet qui va garantir sa bonne gestion. Cet objet encapsulera la ressource et nous donnera une autre interface, plus adaptée, une interface de plus haut niveau. C'est un idiome de programmation qui permet de faire du code plus fiable, plus maintenable, plus compréhensible, plus sécurisée et plus simple.

L'utilité de la RAII trouve sa source dans la destruction automatique des objets alloués sur la pile : chaque objet alloué statiquement sera libéré, son destructeur sera appelé et ceci même en cas d'exception. On écrira l'instruction de libération de la ressource dans le destructeur et on est certain que la ressource sera libérée, sans besoin d'un appel à delete ou close() etc. Si l'acquisition échoue dans le constructeur, l'objet RAII en tiendra compte pour la suite. Cela implique donc aussi qu'on n'aura pas besoin de contrôler immédiatement de manière barbante si l'allocation d'une ressource s'est faite correctement.

Vous connaissez tous au moins une classe de la STL qui utilise ce principe. Il s'agit de std::fstream. On vous présente toujours cette classe comme offrant un système simple et sécurisé pour manipuler les fichiers en C++. Ce qu'on oublie de dire en général, c'est que c'est totalement basé sur de la RAII : la fichier est ouvert à la construction d'un objet fstream (qui sera son représentant), alloué statiquement, et il est automatiquement fermé à la destruction de cet objet. L'approche RAII se retrouve encore ailleurs : std::vector, std::string, etc. Et elle constitue la base de la technique du pointeur intelligent.

Zoom

Pourquoi parle-t-on de pointeur intelligent ? En quoi sont-ils intelligents ?

Premièrement, ils libèrent la mémoire sans qu'on le demande explicitement et ce quand ils partent du principe qu'on en a plus besoin. Dans la même logique que std::fstream, on crée un objet statiquement représentant une zone mémoire allouée dynamiquement. En disséquant un peu, cela se présente comme un objet encapsulant un pointeur sur cette zone. On se sert alors de la libération statique de cet objet pour mettre en œuvre une libération dynamique.

Deuxièmement, ils permettent d'adopter un autre point de vue sur les entités allouées. Quand l'on travaille avec un pointeur nu, on voit un pointeur et on se dit que ce n'est qu'une variable stockant une valeur qui est une adresse d'une autre variable. Mais quand on voit dans un code une déclaration et une utilisation d'un pointeur intelligent, on voit un objet en lui-même, l'entité allouée dynamiquement prend ainsi forme et se confond dans l'entité du pointeur ; un peu comme pour un objet std::fstream duquel on dirait "voilà notre fichier".

Troisièmement, le pointeur contrôle ce que l'on veut faire et assure avant tout la sécurité du code. Le problème de l'allocation dynamique gérée "à la main", c'est souvent la copie de pointeurs ou l'accès à des données non-allouées. On peut alors très imaginer un pointeur intelligent qui mettra en place un système sécurisé de copie ou qui empêchera l'utilisateur d'accéder à une zone non-allouée.

L'objet de ce concept RAII (c'est-à-dire le pointeur intelligent) a ainsi un double rôle : il doit ajouter ou au moins préserver la sémantique du code. Autrement dit, à la vue du code, on doit être en mesure de comprendre simplement ce que l'on fait ou veut faire, et au moins tout aussi simplement qu'avec les pointeurs nus. Deuxièmement, cet objet doit s'occuper de tous les tracas de l'allocation dynamique à notre place. Les deux sont très liés : en étant "intelligents", ces pointeurs nous évitent d'écrire du code qui aurait résolu (peut-être mal) autrement les problèmes, code souvent indigeste qu'on peut donc s'épargner. On ajoute de la lisibilité, de la sémantique.

La STL a évidemment (comme toujours) pensé à nous et nous propose un type de pointeur intelligent que je vais présenter : std::auto_ptr. Malheureusement, auto_ptr souffre d'un problème (que nous allons voir tout de suite), ce qui fait qu'on le déconseille très souvent. Boost également propose toute une panoplie de pointeurs intelligents, chacun pour un usage différent. Je vais vous présenter le plus connu d'entre eux, boost::shared_ptr, et ses avantages par rapport à std::auto_ptr. Cela dit en passant, si vous n'avez pas encore installé Boost, c'est le moment de le faire, on ne fait plus grand-chose sans ce framework.

En C++, ça donne quoi ?

En C++, un pointeur intelligent est donc un objet. Le type de cet objet est quasiment toujours une instance d'une classe template, c'est-à-dire ici une classe qui prend le type de l'objet (au sens large) alloué dynamiquement en paramètre template. D'un point de vue de méta-programmation template, en créant un pointeur intelligent, on écrit d'abord une classe similaire à la classe template en remplaçant son paramètre par le type de la donnée que l'on va vouloir pointer. std::auto_ptr fonctionne de cette manière, boost::shared_ptr également (et d'autres).

Un détail surprend souvent les débutants : le paramètre template n'est pas le type du pointeur "nu" que l'on aurait eu à la place. En se disant qu'ils veulent un pointeur intelligent sur un int par exemple, ils (ou peut-être vous si vous êtes débutant) écrivent intuitivement "pointeur_intelligent < int* >", pourtant ceci n'aura pas l'effet attendu. En effet, il ne faut pas considérer un pointeur intelligent comme un objet encapsulant un pointeur, mais plutot comme un vrai pointeur dont on précise le type de la donnée pointée. Dans ce cas, "pointeur_intelligent < int >" est donc correct.

Un autre détail d'ordre "C++" : l'allocation dynamique est tout de même faite en-dehors du pointeur intelligent ; ce n'est pas lui qui s'en charge, il se contente de récupérer un pointeur sur une zone déjà allouée ou NULL.

Première exemple : std::auto_ptr

La STL fourni donc un pointeur intelligent standard : std::auto_ptr, défini dans le fichier <memory>. Il s'agit donc d'une classe template qui prend en paramètre le type traité. Le constructeur de std::auto_ptr prend un pointeur "nu" en paramètre qui pointera sur une zone que l'on aura pris soin d'allouer. Généralement, on retrouve donc l'allocation dynamique directement en tant qu'expression dans les paramètres du constructeur lors de la déclaration du pointeur intelligent.

std::auto_ptr considère qu'il manipule une zone allouée avec l'opérateur new : ne vous permettez donc pas de lui envoyer une zone allouée avec new[] car il tentera tôt ou tard d'y appliquer l'opérateur delete. Il y a d'autres pointeurs intelligents qui sont faits pour cela, en particulier un que je mentionnerai plus loin. Parce que l'allocation à proprement parler n'est pas gérée par la classe, cette dernière n'est pas susceptible de lancer une exception (une exception std::bad_alloc ou encore une exception lancée depuis le constructeur de l'objet alloué dynamiquement aurait été possible sinon).

std::auto_ptr surcharge les opérateurs classiques que l'on peut appliquer aux pointeurs nus : operator*, operator->, operator=, etc. mais aussi la conversion vers d'autres types de pointeurs. La sémantique d'un vrai pointeur est donc gardée sauf qu'on a ajouté de l' "intelligence". Exemple sans intérêt :

#include <memory> // du code... std::auto_ptr < std::string > ptr(new std::string("hello")); std::cout << *ptr << std::endl; // affiche hello std::cout << ptr -> at(1) << std::endl; // affiche e *ptr += " world !"; // du code...

Et on n'écrira aucune libération de mémoire, c'est entièrement l'objet ptr qui s'en occupera à sa destruction. Tout est bien beau, mais quel est alors le problème de std::auto_ptr que j'ai mentionné plus haut ? Pour le comprendre, il faut comprendre la stratégie de std::auto_ptr pour gérer les copies. En effet, la plupart des problèmes que l'on rencontre avec les pointeurs nus existent parce qu'il y a eu copie de pointeur. C'est donc principalement au niveau des copies de pointeurs intelligents qu'il faut ajouter justement de l' "intelligence". Par exemple, si l'on part du principe que la libération de mémoire est faite à coup sûr dans le destructeur, est-il seulement raisonnable de laisser la possibilité de copier un pointeur intelligent ? Si l'on est aussi restrictif, on garde beaucoup de problèmes qu'on souhaiterait pourtant régler.

Plusieurs approches sont possibles pour résoudre le problème, mais l'idée de base c'est d'adopter un certain point de vue sur les copies. std::auto_ptr considère que chaque pointeur intelligent est un propriétaire unique d'une zone allouée dynamiquement. Ce type considère les copies simplement comme un changement de propriétaire. On ne libère la mémoire dans le destructeur que si l'objet en question est propriétaire de quelque chose, ce qu'il n'est donc plus forcément (ou n'a jamais été si le pointeur n'a jamais été initialisé). Le pointeur copié est donc en quelque sort "destitué" de son titre de propriétaire et ne libèrera rien, ça sera à la copie de s'en charger.

Le problème de std::auto_ptr est donc là : on est certain que la mémoire allouée sera libérée tôt ou tard, mais peut-être ... trop tôt. Prenez par exemple le code suivant :

std::auto_ptr < int > ptr(new int(42)); f(ptr); *ptr = 36;

f() est une fonction qui prend en paramètre un pointeur intelligent std::auto_ptr. En supposant qu'il n'y a pas passage par référence, il y aura une copie lors de l'envoi de ptr à cette fonction. Autrement dit, c'est le pointeur intelligent que l'on retrouvera dans f() qui sera le propriétaire de l'entier alloué à la ligne 1. Conséquence : ptr n'en est plus le propriétaire et n'y a donc plus droit d'accès. La ligne 3 n'a donc aucun sens, d'autant plus qu'au moment où l'on exécute cette instruction, le pointeur intelligent propriétaire interne à f() aura déjà libéré l'entier. Si l'on est pas à l'aise avec l'approche de std::auto_ptr, il vaut donc mieux éviter de l'utiliser pour ne pas tomber dans de pièges subtils de ce genre.

Deuxième exemple : boost::shared_ptr

Comment résoudre ce problème de copie et de propriétaire unique ? Une astuce possible serait d'autoriser les propriétaires multiples et...

Appeler le centre

Avez-vous besoin d'un coach de formation?

Il vous aidera à comparer différents cours et à trouver la solution la plus abordable.

C++ : Gérer correctement ses allocations dynamiques

Prix sur demande