Prise en charge de l'allocation dynamique de mémoire – Détection de fuites

Dans le but d'apprendre comment surcharger les opérateurs d'allocation et de libération dynamique de mémoire que sont new, new[], delete et delete[], une application pratique est de mettre au point une stratégie de comptabilité de la mémoire allouée et de la mémoire libérée. En ayant accès à ces données, il devient possible de détecter certains types de fuite de ressources.

Pour faciliter la comptabilité des ressources, j'utiliserai un singleton, qui sera une classe incopiable. Le singleton en soi se nommera ComptableMemoire et se déclinera comme suit :

#ifndef ALLOCATION_H
#define ALLOCATION_H

//
// J'insérerai ici les prototypes des opérateurs
// new, new[], delete et delete[]
//

class ComptableMemoire {
public:
   using value_type = long long;
private:
   static ComptableMemoire singleton;
   value_type alloue = {};
   ComptableMemoire()= default;
   void fuite();
   void surliberation();
public:
   ComptableMemoire(const ComptableMemoire&) = delete;
   ComptableMemoire& operator=(const ComptableMemoire&) = delete;
   void allocation(size_t n) {
      alloue += static_cast<value_type>(n);
   }
   void deallocation(size_t n) {
      alloue -= static_cast<value_type>(n);
      if (combien() < 0) surliberation();
   }
   value_type combien() const {
      return alloue;
   }
   static ComptableMemoire &get() noexcept {
      return singleton;
   }
   ~ComptableMemoire() {
      if (combien()) fuite();
   }
   class TraceurLocalSimplet {
      value_type avant;
   public:
      TraceurLocalSimplet() : avant{ComptableMemoire::get().combien()} {
      }
      ~TraceurLocalSimplet();
   };
};
#endif

Les méthodes clés de ComptableMemoire sont allocation(), deallocation() et combien(). Retenons que ComptableMemoire comptabilisera la mémoire allouée mais n'allouera rien par elle-même.

Notez que certains blocs ne sont pas supposés être balancés; un TraceurLocalSimplet est un outil dont on se servira pour tester l'hypothèse qu'un bloc a une stratégie équilibrée d'allocation dynamique de mémoire, tout simplement.

Remarquez la classe locale TraceurLocalSimplet. Cette classe aura un rôle tout simple :

Ceci permettra de vérifier si, dans un bloc de code donné, les opérations d'allocation et de libération dynamique de mémoire sont balancées.

Remarquez aussi que le destructeur de ComptableMemoire invoque une méthode nommée fuite() dans son destructeur dans la mesure où une fuite de mémoire a été détectée. Cette stratégie peut être risquée : un ComptableMemoire est un singleton statique et tout objet dont il pourrait se servir pour souligner la fuite détectée (incluant les flux de sortie et d'erreur standard) est susceptible d'avoir été détruit au moment de la destruction du ComptableMemoire en tant que tel.

Nous verrons plus tard une stratégie plus stable et moins dangereuse de signaler les fuites (à court terme, déclarer un TraceurLocalSimplet est beaucoup mieux).

En ajoutant les prototypes des opérateurs d'allocation et de libération dynamique de mémoires dans leur déclinaison globale, on obtient le fichier Allocation.h suivant :

#ifndef ALLOCATION_H
#define ALLOCATION_H

//
// Allocation et libération dynamique de mémoire. Surcharge
// des opérateurs globaux par une version comptabilisant ce
// qui est alloué et facilitant le traçage des fuites.
//
void* operator new(size_t);
void* operator new[](size_t);
void operator delete(void*);
void operator delete[](void*);

class ComptableMemoire {
public:
   using value_type = long long;
private:
   static ComptableMemoire singleton;
   value_type alloue = {};
   ComptableMemoire()= default;
   void fuite();
   void surliberation();
public:
   ComptableMemoire(const ComptableMemoire&) = delete;
   ComptableMemoire& operator=(const ComptableMemoire&) = delete;
   void allocation(size_t n) {
      alloue += static_cast<value_type>(n);
   }
   void deallocation(size_t n) {
      alloue -= static_cast<value_type>(n);
      if (combien() < 0) surliberation();
   }
   value_type combien() const {
      return alloue;
   }
   static ComptableMemoire &get() noexcept {
      return singleton;
   }
   ~ComptableMemoire() {
      if (combien()) fuite();
   }
   class TraceurLocalSimplet {
      value_type avant;
   public:
      TraceurLocalSimplet () : avant{ComptableMemoire::get().combien()} {
      }
      ~TraceurLocalSimplet();
   };
};
#endif

Le fichier source Allocation.cpp accompagnant ces déclarations se détaille quant à lui comme suit :

#include "Allocation.h"
#include <iostream>
#include <cstdlib>
#include <new>
using namespace std;

void * operator new(size_t n) {
   //
   // Allocation d'un size_t supplémentaire à chaque new (très
   // coûteux, mais simple). Le pointeur retourné est sur l'espace
   // attribué pour les données; la taille attribuée se trouve
   // juste avant les données en mémoire. Le size_t supplémentaire
   // n'est pas comptabilisé par la mécanique de détection des
   // fuites puisqu'il s'agit d'un détail d'implémentation.
   //
   void *p = malloc(n + sizeof(size_t));
   if (!p) throw std::bad_alloc{ };
   ComptableMemoire::get().allocation(n);
   *(static_cast<size_t *>(p)) = n;
   return static_cast<size_t *>(p) + 1;
}

void *operator new[](size_t n) {
   //
   // Allocation d'un size_t supplémentaire à chaque new[] (très
   // coûteux, mais simple). Le pointeur retourné est sur l'espace
   // attribué pour les données; la taille attribuée se trouve
   // juste avant les données en mémoire. Le size_t supplémentaire
   // n'est pas comptabilisé par la mécanique de détection des
   // fuites puisqu'il s'agit d'un détail d'implémentation.
   //
   void *p = malloc(n + sizeof(size_t));
   if (!p) throw bad_alloc{};
   ComptableMemoire::get().allocation(n);
   *(static_cast<size_t *>(p)) = n;
   return static_cast<size_t *>(p) + 1;
}

void operator delete(void *p) {
   if (!p) return;
   size_t *q = static_cast<size_t *>(p) - 1;
   ComptableMemoire::get().deallocation(*q);
   free(q);
}

void operator delete[](void *p) {
   if (!p) return;
   size_t *q = static_cast<size_t *>(p) - 1;
   ComptableMemoire::get().deallocation(*q);
   free(q);
}

void ComptableMemoire::fuite() {
   cerr << "Fuite de " << combien() << " bytes" << endl;
}

void ComptableMemoire::surliberation() {
   cerr << "Libération abusive de " << combien() << " bytes" << endl;
}

ComptableMemoire ComptableMemoire::singleton;

ComptableMemoire::TraceurLocalSimplet::~TraceurLocalSimplet() {
   auto diff = ComptableMemoire::get().combien() - avant;
   if (diff) // hum...
      cerr << "Fuite locale de " << diff << " bytes" << endl;
}

Remarquez principalement que new et new[] sont identiques et que delete et delete[] le sont aussi. Ce n'est pas une loi ou une obligation; il s'avère simplement que pour ce schèma très simple d'allocation et de libération de mémoire, il n'est pas nécessaire d'être plus sophistiqué que cela.

L'essentiel du travail fait par new/ new[] et delete/ delete[] ici est :

Ce qui complique un peu la sauce est l'incontournable ensemble de conversions explicites de types appliquées aux divers pointeurs manipulés ici (de void* à size_t* et inversement).

Un programme de test suit. Le petit bloc interne allouant de la mémoire à travers les pointeurs temp et temp2 détectera une fuite de sizeof(int) bytes (sizeof(int)==4 sur ma machine) du fait que temp2 n'y est pas libérée et le programme dans son ensemble détectera une fuite de 11*sizeof(int) bytes (les dix int du tableau q et le int pointé par temp2).

#include "Allocation.h"
int main() {
   {
      ComptableMemoire::TraceurLocalSimplet tr;
      int *temp = new int[5];
      int *temp2 = new int{4};
      delete[] temp;
   }
   int *p = new int(3);
   int *q = new int[10];
   delete p;
}

Valid XHTML 1.0 Transitional

CSS Valide !