Combattre les fuites de mémoire et les erreurs C++11

Les outils modernes fournis avec la bibliothèque standard du C++11 permettent de combattre les fuites de mémoire et les erreurs plus facilement et plus efficacement. Parfois, les problèmes qui ne semblent apparemment pas dangereux peuvent planter votre application. Nous allons apprendre comment les traquer et les éviter.

9 commentaires Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Qu'est-ce qu'une fuite de mémoire ?

Le langage C++ nous laisse attribuer et libérer la mémoire par nous-mêmes. Chaque variable possède sa propre zone dans la mémoire. Cette mémoire est libérée quand l'application sort de sa portée de validité qui est limitée par les caractères { et }. Dans ce cas, il n'y a aucun risque de voir la perte de mémoire augmenter, car la mémoire est libérée automatiquement. Cependant, C++ nous autorise également à allouer la mémoire de façon dynamique. Cela signifie que vous pouvez recevoir la quantité de mémoire dont vous avez besoin, et celle-ci ne sera pas libérée automatiquement par le système. Cela veut dire que vous êtes obligé de libérer cette zone lorsque vous n'en avez plus besoin. Si vous oubliez ce point, vous provoquerez des fuites mémoire.

II. Effets des fuites de mémoire

Chaque fuite de mémoire se traduit par un besoin supérieur en mémoire. En fonction de la quantité de mémoire perdue, les fuites sont plus ou moins dangereuses. Vous devez comprendre qu'une fonction qui provoque des fuites de mémoire peut provoquer une perte d'une énorme quantité de mémoire lorsqu'elle est appelée des centaines ou des milliers de fois pendant l'exécution. Bien sûr, à la fin du programme, le ramasse-miettes s'occupera de nettoyer cette mémoire non libérée. Alors, pourquoi nous inquiétons-nous de cette mémoire non libérée ? Dans le cas d'applications utilisées quotidiennement, les petites fuites mémoire sont indécelables. La situation s'aggrave lorsque des programmes fonctionnent sans relâche pendant des semaines ou même des mois (par exemple, des serveurs HTTP, DNS, e-mail, bases de données, etc.). Si de telles applications ne libèrent pas les zones inutilisées en mémoire, elles provoquent un usage plus grand de la mémoire. Cela peut affecter le système qui tuera une application pouvant avoir une fonction très importante.

La situation est encore plus grave dans le cas d'applications utilisant beaucoup de ressources. Un logiciel de rendu 3D est un bon exemple. Un processus de création de scènes 3D requiert beaucoup de mémoire vive. Si ce logiciel ne se charge pas correctement de la libération mémoire non utilisée, nous pouvons perdre beaucoup d'heures de calcul.

III. Détection

Pour détecter les fuites de mémoire (et les erreurs), nous pouvons utiliser une application dédiée comme valgrind. Ce logiciel est disponible pour les systèmes d'exploitation avec un noyau Linux et les systèmes compatibles UNIX. Pour tester notre programme avec valgrind, nous avons besoin de son exécutable.

IV. Comment utiliser valgrind ?

En premier lieu, vous devez savoir que valgrind teste le programme pendant son exécution. Il ne s'intéresse ni au code source ni au code binaire. Il suffit d'utiliser l'application. Ce qui pourrait poser problème, car les fuites de mémoire ne seront pas détectées si la fonction qui les génère reste inutilisée pendant l'exécution du programme. Le moyen le plus facile d'éviter ce problème est d'utiliser toutes les fonctions dans une simple application de test. Si votre code est plus sophistiqué et comporte de nombreux modules, écrivez de plus petites applications de test - une ou deux pour chaque module.

Si vous écrivez des tests unitaires et, pour cela, vous utilisez une bonne bibliothèque qui ne génère ni erreurs ni fuites de mémoire, vous pouvez utiliser ces tests avec valgrind. L'exécuter ressemble à ça :

valgrind [options de valgrind] /chemin/vers/application paramètres de l'application

Pour connaître les options de valgrind, écrivez :

valgrind --help

Si vous voulez obtenir des informations détaillées à propos des fuites de mémoire, utilisez l'option :

--leak-check=full

V. Exemples de fuites de mémoire

V-A. Exemple n° 1

 
Sélectionnez
#include <iostream>

int main (int argc, char** argv)
{
  int *x = new int [1000];
  {
    int y ;
    int *z = new int [1000];
  } // le pointeur z et la variable y sont enlevés de la mémoire
  delete [] x; // libère la mémoire allouée à la ligne 4
  
  return 0;
}

Dans ce programme, nous créons trois variables. Deux d'entre elles (x et z) sont des pointeurs. Nous créons également un bloc avec deux accolades. Au début, nous allouons 1000 entiers en mémoire. L'adresse du premier est dans le pointeur x. Dans la portée, nous déclarons une variable y et un pointeur z. Au pointeur, nous affectons l'adresse des 1000 entiers suivants. Comme je l'ai mentionné auparavant, lorsque le programme sort de la portée, les variables sont effacées de la mémoire. Cela concerne aussi les pointeurs. Le pointeur z est détruit, mais la mémoire vers laquelle il pointait ne l'est pas. À la huitième ligne, nous obtenons notre première fuite de mémoire. Nous avons perdu l'adresse de 1000 entiers. Et maintenant, nous ne pouvons plus libérer cette mémoire.

V-B. Exemple n° 2

Même si nous prenons soin de libérer la mémoire en utilisant l'opérateur delete, nous pouvons rencontrer des exceptions entre allocation et désallocation. Lorsqu'une exception est levée, le programme retourne à l'endroit où l'exception a été attrapée ou alors le programme se termine s'il n'y a pas de bloc try...catch. Supposons que le programme est bien écrit et qu'il supervise les exceptions levées dans un bloc catch. Si les exceptions sont supportées, le programme va probablement fonctionner, mais la mémoire ne sera pas libérée, car le programme n'aura pas atteint l'opérateur delete. Exemple :

 
Sélectionnez
#include <iostream>
#include <stdexcept>

int main(int argc, char** argv)
{
  try {
    int *x = new int[1000];
    throw std::logic_error("logic_error");
    delete [] x;
  } catch (std::logic_error &e)
  {
    std::cerr << "une exception a été levée: " << e.what() << "\n";
    return  1;
  }
}

La situation est intentionnelle. À la huitième ligne, nous pourrions avoir un autre appel de fonction qui pourrait probablement lever une exception. Si le programme continue de fonctionner, de tels cas peuvent apparaître plusieurs fois ce qui causera de grosses fuites de mémoire.

V-C. Exemple n° 3

Ce code est très semblable au précédent. Il décrit mieux une situation dans laquelle le programmeur n'est pas conscient qu'une exception pourrait être levée. Nous déclarons une classe qui alloue une quantité de mémoire dans le constructeur et la libère dans le destructeur. La classe comporte une fonction get qui retourne l'élément d'un tableau dont l'index est passé en argument. Si l'élément désigné n'existe pas la fonction lève une exception std::out_of_range exception. Voici le code de cette classe avec un exemple d'utilisation :

 
Sélectionnez
#include <iostream>
#include <stdexcept>
 
class Database
{
private:
  int size;
  int* data;
 
public:
  Database(int n) :
    size(n),
    data(new int[n])
  {}
 
  int get(int i)
  {
    if (i < 0 || i >= size)
    {
      throw std::out_of_range("Database");
    }
 
    return data[i];
  }
   
  ~Database()
  {
    delete [] data;
  }
};
 
int main(int argc, char** argv)
{
  try {
    Database *db = new Database(10);
    db->get(10);
    delete db;
  } catch (std::out_of_range &e)
  {
    std::cerr << "out_of_range in " << e.what() << "\n";
    return 1;
  }
  return 0;
}

Nous pouvons ignorer le fait que l'allocation dynamique d'un objet Database n'a aucun sens ici. Nous aurions pu le faire avec un objet standard alloué sur la pile. Imaginons que nous avons de sérieuses raisons d'utiliser un pointeur et une allocation dynamique. Lorsque nous avons une base de données avec dix éléments, l'index le plus élevé que nous ayons est 9. Essayer d'obtenir l'élément numéro 10 lèvera une exception. Le rapport de Valgrind confirme la fuite.

Dans les trois cas ci-dessus, nous pouvons éviter les fuites.

  1. La première façon d'éviter les problèmes est de créer un pointeur à l'extérieur de la portée dans laquelle il est actuellement. Cela nous donnera la possibilité de libérer la mémoire dans une portée différente de celle où elle a été allouée, car nous avons accès au pointeur qui contient son adresse. Néanmoins, nous devons nous souvenir de désallouer cette mémoire ! Nous pouvons rencontrer une exception en cours de route vers la désallocation et provoquer une fuite de mémoire.
  2. Une autre solution est d'utiliser un soi-disant gestionnaire de ressources. Nous pouvons allouer un tel gestionnaire sur la pile. Lorsque le programme sort de la portée dans laquelle le gestionnaire a été initialisé, son destructeur libère la mémoire allouée dynamiquement par le constructeur. Cela nous donne une confiance à 100 % de ne pas avoir de fuites mémoire. L'exception à cette règle se produit lorsque nous gardons des pointeurs vers de la mémoire allouée dynamiquement dans de tels conteneurs, car les conteneurs libèrent seulement les pointeurs.
  3. Si nous devons utiliser des pointeurs, il serait raisonnable de regarder du côté des pointeurs intelligents de C++11. Ces pointeurs libèrent automatiquement la mémoire vers laquelle ils pointent lorsqu'ils sont détruits. Je vous en parlerai plus tard dans cet article.

Le meilleur moyen est d'éviter toute allocation dynamique explicite de mémoire. Nous ne pouvons pas travailler sans aucune allocation dynamique, mais nous pouvons minimiser ses occurrences. Un grand nombre de programmeurs inexpérimentés traitent la présence de pointeurs et d'allocations dynamiques en C++ comme s'ils étaient toujours indispensables. Cela concerne l'utilisation de l'opérateur new dans beaucoup d'endroits où un objet standard alloué sur la pile serait suffisant et plus sécurisant. Le troisième exemple est un excellent cas de code qui correspond à cette règle. Nous n'aurions pas de problème de mémoire non désallouée. À la sortie du bloc try...catch, il serait enlevé de la mémoire.

Les fuites ne sont pas les seuls problèmes de mémoire.

VI. Corruption mémoire

Une autre erreur possible est d'utiliser de la mémoire qui ne nous appartient pas. Cette erreur est souvent faite par des programmeurs non expérimentés qui viennent de débuter le codage et ne sont pas au fait que l'index maximum d'un tableau de 100 éléments n'est pas 100, mais 99. Cette erreur peut être détectée pendant l'exécution, mais ce n'est pas une règle. Cela ne veut pas dire que tout est OK quand nous utilisons mal notre propre mémoire. Jetons un coup d'½il aux exemples.

VI-A. Exemple n° 1

Voilà une erreur de débutant :

 
Sélectionnez
int main(int argc, char** argv)
{
  int tab[100];
  tab[100] = 10;
  return 0;
}

Ce programme va probablement s'exécuter et terminer avec un code 0 - pas d'erreur. Cependant valgrind va nous indiquer invalid memory usage (utilisation invalide de la mémoire) :

==7933== Invalid write of size 4
==7933== at 0x400623: main (example5.cpp:4)
==7933== Address 0x595a1d0 is 0 bytes after a block of size 400 alloc'd
==7933== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==7933== by 0x400614: main (example5.cpp:3)

Dans ce cas, rien de sérieux n'est arrivé, mais, lorsque nous écrivons dans la mémoire hors de la zone du tableau, nous allons probablement écraser quelque chose. L'exemple suivant illustre ce cas.

VI-B. Exemple n° 2

En allant à l'extérieur des limites du tableau, nous allons manipuler un autre élément de la pile. Pour le prouver, nous pouvons exécuter ce programme :

 
Sélectionnez
#include <iostream>
 
int main(int argc, char** argv)
{
  int a[100];
  int b[100];
  b[0] = 1;
  std::cout << "b[0] = " << b[0] << "\n";
  a[100] = 2;
  std::cout << "b[0] = " << b[0] << "\n";
  std::cout << "Comparaison des adresses de a[100] et b[0]:\n";
  int* p1 = &a[100];
  int* p2 = &b[0];
  if (p1 == p2)
  {
    std::cout << "Adresses identiques! (" << reinterpret_cast<void*>(p1) << ")\n";
  } else
  {
    std::cout << "Adresses différentes!\n";
  }
  return 0;
}

Comme nous pouvons le voir, b[0] a été modifié en raison de l'utilisation d'un mauvais index dans le tableau. En utilisant a[100], nous accédons à b[0]. Dans cet exemple, valgrind ne signalera aucune erreur ! Cela est dû au fait que la mémoire appartient au processus courant et a été initialisée (nous avons écrit b[0]=1), cette erreur ne pourra pas être détectée.

Si nous utilisions une allocation dynamique de mémoire, sortir des limites provoquerait sûrement une réaction de valgrind.

VI-C. Exemple n° 3

 
Sélectionnez
int main(int argc, char** argv)
{
  int* p = new int[10];
  p[10] = 10;
  int a = p[10];
  return 0;
}

Dans ce code, nous avons deux erreurs. La première se produit lorsque nous essayons d'écrire 10 dans p[10] (une place derrière la zone allouée). La deuxième erreur se produit lorsque nous essayons de lire une valeur à cette adresse. Ces deux opérations aboutiront seulement si la zone suivant la mémoire réservée dans la troisième ligne appartient au processus courant. En étudiant ce code, nous pouvons penser que l'essai réussira. Le segment mémoire dans les nouveaux systèmes d'exploitation est toujours plus grand que les 40 octets utilisés par 10 entiers.

Comme je l'ai dit plus tôt, de telles erreurs provoquent une réaction de valgrind. Voici, une partie des avertissements donnés :

==8840== Invalid write of size 4
==8840== at 0x400621: main (example6.1.cpp:4)
==8840== Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
==8840== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==8840== by 0x400614: main (example6.1.cpp:3)
==8840==
==8840== Invalid read of size 4
==8840== at 0x40062B: main (example6.1.cpp:5)
==8840== Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
==8840== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==8840== by 0x400614: main (example6.1.cpp:3)

Des erreurs similaires se produisent lorsqu'un processus essaie d'écrire quelque chose dans une zone mémoire qui vient d'être libérée.

VI-D. Exemple n° 4

 
Sélectionnez
int main(int argc, char** argv)
{
  int* p = new int[10];
  delete [] p;
  p[2] = 10;
  return 0;
}

Le rapport de valgrind est légèrement différent du précédent :

==9086== Invalid write of size 4
==9086== at 0x400684: main (example9.cpp:5)
==9086== Address 0x595a048 is 8 bytes inside a block of size 40 free'd
==9086== at 0x4C275BC: operator delete[](void*) (vg_replace_malloc.c:490)
==9086== by 0x40067B: main (example9.cpp:4)

VII. Mémoire non initialisée

Combien de fois avez-vous entendu : « affectez la valeur de la variable après sa création ! » ? La plupart des livres sur la programmation mentionnent cette règle. Cela sera moins nocif lorsque vous ratez un examen sur les algorithmes parce que vous avez écrit int sum; au lieu de int sum = 0;. Cela serait pire de perdre de nombreuses heures en raison d'une mémoire non initialisée. Quand nous utilisons un pointeur non initialisé qui se trouve quelque part, il est hautement probable que nous utiliserons un autre processus mémoire et notre programme sera déchargé par le système d'exploitation. Les variables normales, initialisées par nous-mêmes, appartiennent certainement à notre processus alors qu'un défaut d'initialisation sera plus énervant.

 
Sélectionnez
int main(int argc, char** argv)
{
  int x;
  if (x == 10)
  {
    x = 20;
  }
  return 0;
}

Heureusement, valgrind nous informe de l'utilisation d'une variable ou d'un pointeur non initialisé :

==8777== Conditional jump or move depends on uninitialised value(s)
==8777== at 0x4005AD: main (example7.cpp:4)

VIII. Prévention des erreurs mémoire

Se prémunir des erreurs mémoire dans des programmes C++ est possible sans outils spécialisés. La présence des destructeurs, souvent sous-estimés des programmeurs, est très utile. Quand vous créez un objet qui alloue lui-même sa mémoire, son destructeur devrait prendre soin de la libérer. Voici quelques bonnes pratiques qui, utilisées, devraient vous éviter les fuites et les erreurs :

  1. Lorsque vous utilisez l'opérateur new dans un constructeur, utilisez delete dans le destructeur ;
  2. Lorsque vous utilisez un pointeur local dans une fonction membre (qui n'est pas un attribut de la classe courante), il est hautement probable qu'une variable locale soit suffisante ;
  3. Si vous êtes sûr que dans la situation précédente (2) une variable locale n'est pas suffisante, considérez l'utilisation de l'opérateur delete avant de quitter cette fonction ;
  4. Si vous devez allouer dynamiquement de la mémoire dans une fonction et ne pouvez pas la libérer avant de quitter la portée, sauvez son adresse dans un pointeur externe qui est accessible hors de la fonction.

IX. Facilités du C++11

Nous avons des conteneurs de bibliothèques standard qui libèrent la mémoire automatiquement. Les utiliser habilement vous évitera les problèmes de gestion mémoire. Il peut être intéressant d'utiliser la fonction std::vector::at à la place de l'opérateur []. Cette fonction teste si un index est à l'intérieur de la plage. Dans le cas où l'index est trop petit ou trop grand, elle lève une exception.

Avec le standard C++11, nous avons aussi les pointeurs intelligents qui libèrent la mémoire qu'ils pointent automatiquement. Je peux décrire leur fonctionnement avec deux règles simples :

  • std::shared_ptr peut être copié. Lorsque la dernière copie est détruite, la mémoire qu'il pointait est libérée ;
  • std::unique_ptr ne peut pas être copié (il est unique). Lorsqu'il est détruit, la mémoire qu'il pointait est également libérée.

Il est très important pour être cohérent d'utiliser des pointeurs intelligents pour une seule zone mémoire. Utiliser des pointeurs standard avec des pointeurs intelligents (partagés ou uniques) peut provoquer de sérieux problèmes (par exemple une double libération de mémoire). Vous devez utiliser les fonctions std::make_unique et std::make_shared pour créer des pointeurs intelligents. Vous ne devez pas les utiliser pour pointer sur de la mémoire allouée sur la pile ou sur de la mémoire libérée manuellement (par l'opérateur delete).

Suivre les règles énoncées ci-dessus devrait vous aider à éliminer les problèmes de mémoire. Les différentes facilités de C++11 et C++14 font que nous n'avons pas de bonnes excuses pour les fuites de mémoire. Je vous encourage à apprendre les nouveaux outils standard du C++. Les utiliser raisonnablement devrait rendre votre code meilleur.

X. Remerciements

Cet article est une traduction autorisée de l'article de paru sur le site de Kacper Kołodziej.

Merci aussi à ClaudeLELOUP pour sa relecture orthographique.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2015 Kacper Kołodziej. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.