web-dev-qa-db-fra.com

Qu'arrive-t-il aux ordures en C ++?

Java a un GC automatique qui arrête de temps en temps le monde, mais s'occupe des ordures sur un tas. Maintenant, les applications C/C++ n'ont pas ces blocages STW, leur utilisation de la mémoire ne croît pas infiniment non plus. Comment ce comportement est-il atteint? Comment les objets morts sont-ils pris en charge?

51
Ju Shua

Le programmeur est responsable de s'assurer que les objets qu'ils ont créés via new sont supprimés via delete. Si un objet est créé, mais pas détruit avant que le dernier pointeur ou la référence à celui-ci ne soit hors de portée, il tombe à travers les mailles du filet et devient un fuite de mémoire .

Malheureusement pour C, C++ et d'autres langages qui n'incluent pas de GC, cela s'accumule simplement avec le temps. Cela peut provoquer une application ou le système à manquer de mémoire et être incapable d'allouer de nouveaux blocs de mémoire. À ce stade, l'utilisateur doit recourir à la fermeture de l'application afin que le système d'exploitation puisse récupérer cette mémoire utilisée.

En ce qui concerne l'atténuation de ce problème, plusieurs choses facilitent la vie d'un programmeur. Ceux-ci sont principalement pris en charge par la nature de scope .

int main()
{
    int* variableThatIsAPointer = new int;
    int variableInt = 0;

    delete variableThatIsAPointer;
}

Ici, nous avons créé deux variables. Ils existent dans Portée du bloc , tel que défini par le {} accolades. Lorsque l'exécution sort de cette portée, ces objets sont automatiquement supprimés. Dans ce cas, variableThatIsAPointer, comme son nom l'indique, est un pointeur vers un objet en mémoire. Lorsqu'il sort de la portée, le pointeur est supprimé, mais l'objet vers lequel il pointe reste. Ici, nous delete cet objet avant qu'il ne soit hors de portée pour s'assurer qu'il n'y a pas de fuite de mémoire. Cependant, nous aurions pu également passer ce pointeur ailleurs et nous nous attendions à ce qu'il soit supprimé plus tard.

Cette nature de portée s'étend aux classes:

class Foo
{
public:
    int bar; // Will be deleted when Foo is deleted
    int* otherBar; // Still need to call delete
}

Ici, le même principe s'applique. Nous n'avons pas à nous soucier de bar lorsque Foo est supprimé. Cependant pour otherBar, seul le pointeur est supprimé. Si otherBar est le seul pointeur valide vers n'importe quel objet vers lequel il pointe, nous devrions probablement le delete dans le destructeur de Foo. C'est le concept moteur derrière RAII

l'allocation (acquisition) des ressources se fait lors de la création d'objet (spécifiquement l'initialisation), par le constructeur, tandis que la désallocation (libération) des ressources se fait lors de la destruction d'objet (spécifiquement la finalisation), par le destructeur. Ainsi, la ressource est garantie d'être conservée entre la fin de l'initialisation et le début de la finalisation (la conservation des ressources est un invariant de classe), et d'être conservée uniquement lorsque l'objet est vivant. Ainsi, s'il n'y a pas de fuite d'objet, il n'y a pas de fuite de ressource.

RAII est également la force motrice typique derrière Smart Pointers . Dans la bibliothèque standard C++, ce sont std::shared_ptr, std::unique_ptr, et std::weak_ptr; bien que j'aie vu et utilisé d'autres shared_ptr/weak_ptr implémentations qui suivent les mêmes concepts. Pour ceux-ci, un compteur de référence suit le nombre de pointeurs vers un objet donné et automatiquement deletes l'objet une fois qu'il n'y a plus de références.

Au-delà de cela, tout se résume à des pratiques et à une discipline appropriées pour qu'un programmeur s'assure que son code gère correctement les objets.

102
Thebluefish

C++ n'a pas de récupération de place.

Les applications C++ doivent disposer de leurs propres ordures.

Les programmeurs d'applications C++ doivent comprendre cela.

Quand ils oublient, le résultat est appelé une "fuite de mémoire".

82
John R. Strohm

En C, C++ et autres systèmes sans Garbage Collector, le développeur se voit proposer des fonctionnalités par le langage et ses bibliothèques pour indiquer quand la mémoire peut être récupérée.

La fonction la plus élémentaire est le stockage automatique . Plusieurs fois, la langue elle-même garantit que les articles sont éliminés:

int global = 0; // automatic storage

int foo(int a, int b) {
    static int local = 1; // automatic storage

    int c = a + b; // automatic storage

    return c;
}

Dans ce cas, le compilateur est chargé de savoir quand ces valeurs sont inutilisées et de récupérer le stockage qui leur est associé.

Lors de l'utilisation du stockage dynamique , en C, la mémoire est traditionnellement allouée avec malloc et récupérée avec free. En C++, la mémoire est traditionnellement allouée avec new et récupérée avec delete.

Le C n'a pas beaucoup changé au fil des ans, cependant le C++ moderne évite complètement new et delete et se fie plutôt aux installations de la bibliothèque (qui elles-mêmes utilisent new et delete de manière appropriée):

  • les pointeurs intelligents sont les plus connus: std::unique_ptr et std::shared_ptr
  • mais les conteneurs sont beaucoup plus répandus en fait: std::string, std::vector, std::map, ... tous gèrent en interne de façon transparente la mémoire allouée dynamiquement

En parlant de shared_ptr, il y a un risque: si un cycle de références est formé, et non rompu, alors il peut y avoir fuite de mémoire. Il appartient au développeur d'éviter cette situation, la manière la plus simple étant d'éviter shared_ptr au total et la deuxième plus simple étant d'éviter les cycles au niveau du type.

En conséquence les fuites de mémoire ne sont pas un problème en C++, même pour les nouveaux utilisateurs, tant qu'ils s'abstiennent d'utiliser new, delete ou std::shared_ptr. C'est différent de C où une discipline ferme est nécessaire, et généralement insuffisante.


Cependant, cette réponse ne serait pas complète sans mentionner la sœur jumelle des fuites de mémoire: pointeurs pendants.

Un pointeur suspendu (ou une référence suspendue) est un danger créé en conservant un pointeur ou une référence à un objet mort. Par exemple:

int main() {
    std::vector<int> vec;
    vec.Push_back(1);     // vec: [1]

    int& a = vec.back();

    vec.pop_back();       // vec: [], "a" is now dangling

    std::cout << a << "\n";
}

L'utilisation d'un pointeur ou d'une référence pendant est Comportement indéfini . En général, heureusement, il s'agit d'un crash immédiat; assez souvent, malheureusement, cela provoque d'abord une corruption de la mémoire ... et de temps en temps un comportement étrange apparaît parce que le compilateur émet du code vraiment bizarre.

Le comportement indéfini est le plus gros problème avec C et C++ à ce jour, en termes de sécurité/correction des programmes. Vous voudrez peut-être vérifier Rust pour une langue sans Garbage Collector et sans comportement indéfini.

43
Matthieu M.

C++ a cette chose appelée RAII . Fondamentalement, cela signifie que les ordures sont nettoyées au fur et à mesure plutôt que de les laisser en tas et de laisser le nettoyeur ranger après vous. (imaginez-moi dans ma chambre en train de regarder le football - comme je bois des canettes de bière et que j'en ai besoin de nouvelles, la façon C++ est de prendre la canette vide dans la poubelle sur le chemin du réfrigérateur, la façon C # est de la jeter au sol et attendez que la femme de chambre les ramasse quand elle vient faire le ménage).

Il est maintenant possible de fuir la mémoire en C++, mais pour ce faire, vous devez laisser les constructions habituelles et revenir à la façon de faire C - allouer un bloc de mémoire et garder une trace de l'emplacement de ce bloc sans aucune assistance linguistique. Certaines personnes oublient ce pointeur et ne peuvent donc pas supprimer le bloc.

27
gbjbaanb

Il convient de noter que c'est, dans le cas de C++, une idée fausse courante selon laquelle "vous devez effectuer une gestion manuelle de la mémoire". En fait, vous ne faites généralement aucune gestion de la mémoire dans votre code.

Objets de taille fixe (avec durée de vie de l'étendue)

Dans la grande majorité des cas, lorsque vous avez besoin d'un objet, l'objet aura une durée de vie définie dans votre programme et est créé sur la pile. Cela fonctionne pour tous les types de données primitifs intégrés, mais aussi pour les instances de classes et de structures:

class MyObject {
    public: int x;
};

int objTest()
{
    MyObject obj;
    obj.x = 5;
    return obj.x;
}

Les objets de la pile sont automatiquement supprimés à la fin de la fonction. En Java, les objets sont toujours créés sur le tas et doivent donc être supprimés par un mécanisme comme le garbage collection. Ceci n'est pas un problème pour les objets de pile.

Objets qui gèrent des données dynamiques (avec durée de vie de l'étendue)

L'utilisation de l'espace sur la pile fonctionne pour les objets de taille fixe. Lorsque vous avez besoin d'un espace variable, comme un tableau, une autre approche est utilisée: la liste est encapsulée dans un objet de taille fixe qui gère la mémoire dynamique pour vous. Cela fonctionne car les objets peuvent avoir une fonction de nettoyage spéciale, le destructeur. Il est garanti d'être appelé lorsque l'objet sort du cadre et fait le contraire du constructeur:

class MyList {        
public:
    // a fixed-size pointer to the actual memory.
    int* listOfInts; 
    // constructor: get memory
    MyList(size_t numElements) { listOfInts = new int[numElements]; }
    // destructor: free memory
    ~MyList() { delete[] listOfInts; }
};

int listTest()
{
    MyList list(1024);
    list.listOfInts[200] = 5;
    return list.listOfInts[200];
    // When MyList goes off stack here, its destructor is called and frees the memory.
}

Il n'y a aucune gestion de mémoire dans le code où la mémoire est utilisée. La seule chose dont nous devons nous assurer est que l'objet que nous avons écrit possède un destructeur approprié. Peu importe la façon dont nous quittons la portée de listTest, que ce soit via une exception ou simplement en y retournant, le destructeur ~MyList() sera appelé et nous n'avons pas besoin de gérer de mémoire.

(Je pense que c'est une décision de conception amusante d'utiliser l'opérateur binaire NOT , ~, Pour indiquer le destructeur. Lorsqu'il est utilisé sur des nombres , il inverse les bits; par analogie, il indique ici que ce que le constructeur a fait est inversé.)

Fondamentalement, tous les objets C++ qui ont besoin de mémoire dynamique utilisent cette encapsulation. Il a été appelé RAII ("l'acquisition de ressources est l'initialisation"), ce qui est une façon assez étrange d'exprimer l'idée simple que les objets se soucient de leur propre contenu; ce qu'ils acquièrent est le leur à nettoyer.

Objets polymorphes et durée de vie hors de portée

Maintenant, ces deux cas concernent la mémoire qui a une durée de vie clairement définie: la durée de vie est la même que la portée. Si nous ne voulons pas qu'un objet expire lorsque nous quittons la portée, il existe un troisième mécanisme qui peut gérer la mémoire pour nous: un pointeur intelligent. Les pointeurs intelligents sont également utilisés lorsque vous avez des instances d'objets dont le type varie au moment de l'exécution, mais qui ont une interface ou une classe de base commune:

class MyDerivedObject : public MyObject {
    public: int y;
};
std::unique_ptr<MyObject> createObject()
{
    // actually creates an object of a derived class,
    // but the user doesn't need to know this.
    return std::make_unique<MyDerivedObject>();
}

int dynamicObjTest()
{
    std::unique_ptr<MyObject> obj = createObject();
    obj->x = 5;
    return obj->x;
    // At scope end, the unique_ptr automatically removes the object it contains,
    // calling its destructor if it has one.
}

Il existe un autre type de pointeur intelligent, std::shared_ptr, Pour partager des objets entre plusieurs clients. Ils ne suppriment leur objet contenu que lorsque le dernier client est hors de portée, de sorte qu'ils peuvent être utilisés dans des situations où il est complètement inconnu combien de clients il y aura et combien de temps ils utiliseront l'objet.

En résumé, nous voyons que vous ne faites pas vraiment de gestion manuelle de la mémoire. Tout est encapsulé et est ensuite pris en charge au moyen d'une gestion de la mémoire entièrement automatique basée sur la portée. Dans les cas où cela ne suffit pas, des pointeurs intelligents sont utilisés qui encapsulent la mémoire brute.

Il est considéré comme une très mauvaise pratique d'utiliser des pointeurs bruts en tant que propriétaires de ressources n'importe où dans le code C++, des allocations brutes en dehors des constructeurs et des appels bruts delete en dehors des destructeurs, car ils sont presque impossibles à gérer lorsque des exceptions se produisent, et généralement difficile à utiliser en toute sécurité.

Le meilleur: cela fonctionne pour tous les types de ressources

L'un des plus grands avantages de RAII est qu'il n'est pas limité à la mémoire. Il fournit en fait un moyen très naturel de gérer les ressources telles que les fichiers et les sockets (ouverture/fermeture) et les mécanismes de synchronisation tels que les mutex (verrouillage/déverrouillage). Fondamentalement, chaque ressource qui peut être acquise et doit être libérée est gérée exactement de la même manière en C++, et aucune de cette gestion n'est laissée à l'utilisateur. Tout est encapsulé dans des classes qui acquièrent dans le constructeur et libèrent dans le destructeur.

Par exemple, une fonction verrouillant un mutex est généralement écrite comme ceci en C++:

void criticalSection() {
    std::scoped_lock lock(myMutex); // scoped_lock locks the mutex
    doSynchronizedStuff();
} // myMutex is released here automatically

D'autres langages rendent cela beaucoup plus compliqué, soit en vous obligeant à le faire manuellement (par exemple dans une clause finally), soit ils engendrent des mécanismes spécialisés qui résolvent ce problème, mais pas d'une manière particulièrement élégante (généralement plus tard dans leur la vie, lorsque suffisamment de personnes ont souffert de cette lacune). Ces mécanismes sont try-with-resources in Java and the using en C #, les deux étant des approximations du RAII de C++.

Donc, pour résumer, tout cela était un compte très superficiel de RAII en C++, mais j'espère que cela aidera les lecteurs à comprendre que la mémoire et même la gestion des ressources en C++ ne sont généralement pas "manuelles", mais en fait surtout automatiques.

26
Felix Dombek

En ce qui concerne spécifiquement C, le langage ne vous donne aucun outil pour gérer la mémoire allouée dynamiquement. Vous êtes absolument responsable de vous assurer que chaque *alloc a un free correspondant quelque part.

Là où les choses deviennent vraiment désagréables, c'est quand une allocation de ressources échoue à mi-chemin; essayez-vous à nouveau, rétrogradez-vous et recommencez-vous depuis le début, rétrogradez-vous et quittez-vous avec une erreur, libérez-vous simplement et laissez le système d'exploitation s'en occuper?

Par exemple, voici une fonction pour allouer un tableau 2D non contigu. Le comportement ici est que si un échec d'allocation se produit au milieu du processus, nous annulons tout et retournons une indication d'erreur à l'aide d'un pointeur NULL:

/**
 * Allocate space for an array of arrays; returns NULL
 * on error.
 */
int **newArr( size_t rows, size_t cols )
{
  int **arr = malloc( sizeof *arr * rows );
  size_t i;

  if ( arr ) // malloc returns NULL on failure
  {
    for ( i = 0; i < rows; i++ )
    {
      arr[i] = malloc( sizeof *arr[i] * cols );
      if ( !arr[i] )
      {
        /**
         * Whoopsie; we can't allocate any more memory for some reason.
         * We can't just return NULL at this point since we'll lose access
         * to the previously allocated memory, so we branch to some cleanup
         * code to undo the allocations made so far.  
         */
        goto cleanup;
      }
    }
  }
  goto done;

/**
 * We encountered a failure midway through memory allocation,
 * so we roll back all previous allocations and return NULL.
 */
cleanup:
  while ( i )         // this is why we didn't limit the scope of i to the for loop
    free( arr[--i] ); // delete previously allocated rows
  free( arr );        // delete arr object
  arr = NULL;

done:
  return arr;
}

Ce code est butt-ugly avec ces gotos, mais, en l'absence de toute sorte de mécanisme structuré de gestion des exceptions, c'est à peu près la seule façon de résoudre le problème sans simplement renflouer complètement, en particulier si votre code d'allocation de ressources est imbriqué sur plus d'une boucle. C'est l'une des rares fois où goto est en fait une option intéressante; sinon, vous utilisez un tas d'indicateurs et des instructions if supplémentaires.

Vous pouvez vous simplifier la vie en écrivant des fonctions d'allocateur/désallocateur dédiées pour chaque ressource, quelque chose comme

Foo *newFoo( void )
{
  Foo *foo = malloc( sizeof *foo );
  if ( foo )
  {
    foo->bar = newBar();
    if ( !foo->bar ) goto cleanupBar;
    foo->bletch = newBletch(); 
    if ( !foo->bletch ) goto cleanupBletch;
    ...
  }
  goto done;

cleanupBletch:
  deleteBar( foo->bar );
  // fall through to clean up the rest

cleanupBar:
  free( foo );
  foo = NULL;

done:
  return foo;
}

void deleteFoo( Foo *f )
{
  deleteBar( f->bar );
  deleteBletch( f->bletch );
  free( f );
}
8
John Bode

J'ai appris à classer les problèmes de mémoire en plusieurs catégories différentes.

  • Une seule goutte. Supposons qu'un programme fuit 100 octets au démarrage, pour ne plus jamais fuir. Pourchasser et éliminer ces fuites ponctuelles est agréable (j'aime avoir un rapport propre par une capacité de détection de fuite) mais n'est pas essentiel. Parfois, il y a des problèmes plus importants qui doivent être attaqués.

  • Fuites répétées. Une fonction qui est appelée de manière répétitive au cours de la durée de vie d'un programme et qui fait régulièrement des fuites de mémoire, un gros problème. Ces gouttes vont torturer à mort le programme, et peut-être l'OS.

  • Références mutuelles. Si les objets A et B se référencent via des pointeurs partagés, vous devez faire quelque chose de spécial, soit dans la conception de ces classes, soit dans le code qui implémente/utilise ces classes pour briser la circularité. (Ce n'est pas un problème pour les langues récupérées.)

  • Souvenir de trop. Ceci est le cousin diabolique des fuites d'ordures/mémoire. RAII n'aidera pas ici, ni la collecte des ordures. C'est un problème dans n'importe quelle langue. Si une variable active a un chemin qui la connecte à un morceau de mémoire aléatoire, ce morceau de mémoire aléatoire n'est pas une ordure. Rendre un programme oublieux pour qu'il puisse s'exécuter pendant plusieurs jours est délicat. Faire un programme qui peut fonctionner pendant plusieurs mois (par exemple, jusqu'à ce que le disque tombe en panne) est très, très délicat.

Je n'ai pas eu de problème sérieux de fuite depuis très, très longtemps. L'utilisation de RAII en C++ aide beaucoup à remédier à ces gouttes et fuites. (Il faut cependant être prudent avec les pointeurs partagés.) Plus important encore, j'ai eu des problèmes avec des applications dont l'utilisation de la mémoire continue de croître et de croître en raison de connexions non rompues à la mémoire qui n'est plus d'aucune utilité.

2
David Hammen