C++ Moderne - La gestion de la mémoire

Il paraît qu'il faut gérer la mémoire soi-même en C++, oui, mais pas n'importe comment.

C++ Moderne - La gestion de la mémoire

« En C++, on alloue la mémoire avec new et on la libère avec delete. »

Une idée malheureusement très répandue au sujet des programmeurs C++, c'est qu'ils doivent gérer la mémoire manuellement. Cette idée est même tellement répandue que bien trop de développeurs C++ y croient eux-même.

Gérer la mémoire manuellement signifie faire usage du mot-clé new pour allouer dynamiquement un objet ou un tableau d'objets (new[]) en cas de besoin, et ne pas oublier de libérer le tout avec delete (ou delete[]) sous peine d'avoir un memory leak, c'est à dire une fuite de mémoire.

Programmeur tentant de colmater un memory leakProgrammeur tentant de colmater un memory leak[1]

Cette idée colle à la peau du C++ en terme de réputation; j'ai pour ma part souvent entendu des développeurs C#, Java, JS, etc. dire que le C++ est un langage vieillot où il faut tout faire soi-même et que ça nous pètera à la tronche tôt ou tard.
Si effectivement le C++ est un langage assez ancien (sa première version remonte tout de même à 1983), il n'en est pas moins un langage qui reste à jour (sa dernière version remonte en effet à moins d'un an[2]) et qui dispose donc de fonctionnalités modernes[3].

Ainsi, si beaucoup de programmeurs pensent qu'en C++ il faut impérativement tout faire soi-même, c'est qu'ils ignorent que la véritable force de ce langage réside dans sa gestion de la mémoire, et plus généralement des ressources.

Le concept le plus fondamental et intéressant du langage C++ est que les destructeurs seront toujours appelés à la fin du bloc courant, dans l'ordre inverse de déclaration.

Exemples:

int foo()
{
    Object a;
    Object b;

    return 0; //< Les destructeurs de b et a sont appelés après le return
}
int foo()
{
    Object a;
    if (a.WeirdProperty())
        return -1; //< Le destructeur de a est appelé après le return

    Object b;
    return 0; //< Les destructeurs de b et a sont appelés après le return
}

Ce concept, communément appelé RAII (Resource Acquisition Is Initialization), symbolise le fait qu'une ressource est associée à un objet ce qui garantit sa libération.
Personnellement je lui préfère le terme RRID (Resource Reclamation Is Destruction) qui symbolise que la dernière étape liée à une ressource (libération, fermeture d'un handle, etc.) est déclenchée par le destructeur, et je n'utiliserai que lui à partir de maintenant[4].

Le gros intérêt de cette méthode est qu'elle suffit à gérer la durée de vie de tous nos objets sur la pile, c'est-à-dire nos variables traditionnelles (ou plus exactement, la mémoire pré-allouée du programme où sont situées les variables de travail déclarées au sein des fonctions).
Mais ce qui nous intéresse ici c'est la gestion de nos objets sur le tas, donc nos variables allouées dynamiquement.

Ainsi, il faudrait un moyen de pouvoir utiliser le RRID sur nos variables dynamiques pour que la destruction automatique de nos variables libère également la mémoire allouée sur le tas.

Et figurez-vous que c'est exactement ce qu'on fait.

Prenons un exemple concret, voici une petite fonction dessinant une image en copiant d'autres images par-dessus (en blittant), et gérant la mémoire manuellement :

unsigned char* DrawBoDes1(unsigned int width, unsigned int height)
{
    assert(width > 0);
    assert(height > 0);

    unsigned char* img = new unsigned char[width * height * 4];
    
    if (!FillImage(img, Color(135, 206, 235)))
    {
        delete[] img;
        
        return nullptr;
    }
    
    // Chargeons une image de soleil à mettre dans le coin haut-gauche
    unsigned char* sunImg = LoadImgFromFile("sun.png");
    if (!sunImg)
    {
        delete[] img;
        
        return nullptr;
    }
    
    if (!BlitImage(img, sunImg, 0, 0))
    {
        delete[] sunImg;
        delete[] img;
        
        return nullptr;
    }
    
    delete[] sunImg; //< Nous avons terminé avec le soleil, on le libère

    // Maintenant chargeons une image de maison
    unsigned char* houseImg = LoadImgFromFile("house.png");
    if (!houseImg)
    {
        delete[] img;
        
        return nullptr;
    }
    
    if (!BlitImage(img, houseImg, 300, 200))
    {
        delete[] houseImg;
        delete[] img;
        
        return nullptr;
    }
    
    delete[] houseImg; //< Nous avons fini avec la maison, on la libère

    // Autres opération similaires (dessin de nuages, colline, etc.)
    
    return img;
}

Voyons ensuite la même fonction, mais utilisant std::vector à la place :

std::vector<unsigned char> DrawBoDes1(unsigned int width, unsigned int height)
{
    assert(width > 0);
    assert(height > 0);

    std::vector<unsigned char> img(width * height * 4);
    if (!FillImage(img, Color(135, 206, 235)))
        return {};
    
    std::vector<unsigned char> fileImg;

    // Chargeons une image de soleil à mettre dans le coin haut-gauche
    if (!LoadImgFromFile("sun.png", fileImg))
        return {};
    
    if (!BlitImage(img, fileImg, 0, 0))
        return {};
    
    fileImg.clear();
    
    // Maintenant chargeons une image de maison
    if (!LoadImgFromFile("house.png", fileImg))
        return {};
    
    if (!BlitImage(img, fileImg, 300, 200))
        return {};

    // Autres opération similaires (dessin de nuages, colline, etc.)
    
    return img;
}

Que remarquons-nous ?

Tout d'abord, la fonction est plus courte, plus facile à relire et donc à maintenir.

Ensuite, la fonction ne comporte aucune instruction de libération en cas d'erreur : le compilateur se charge de détruire les objets à la fin de la fonction, peu importe où la fonction se termine en particulier.

Certaines personnes pensent que gérer la mémoire de cette façon est moins performant, cette idée est fausse, si vous observez une perte de performances c'est beaucoup plus souvent un problème d'implémentation (de la mémoire qui n'était pas libérée par exemple).

En réalité c'est même potentiellement plus performant : à chaque fois que nous chargeons une image nous passons le même std::vector à la fonction de chargement, et si celui-ci dispose d'une capacité suffisante (autrement dit, si la maison est plus petite que le soleil), la fonction n'aura pas besoin de réallouer de la mémoire. Nous pouvons même pousser encore plus loin en réservant assez de mémoire pour notre vector pour qu'il n'ait pas besoin d'allouer chaque image, nous donnant donc des performances plus élevées[5].
Ceci est possible car nous ne donnons pas qu'une plage mémoire à notre fonction de chargement d'image, mais bien le conteneur entier dont la fonction est libre d'en changer la taille. Quelque chose que nous pouvons difficilement faire avec un pointeur pré-alloué.

Mais il existe encore un avantage dont je n'ai pas parlé qui rend notre implémentation utilisant std::vector bien plus intéressante que l'implémentation traditionnelle : elle gère correctement les exceptions.

En effet, supposons qu'un appel à BlitImage déclenche une exception, ce à quoi nous devons être préparés car nous n'avons aucune garantie que ça ne sera pas le cas[6], que se passe-t-il ? L'exception va chercher à remonter la pile d'appels jusqu'au premier try { ...} catch compatible dans lequel elle serait englobée, interrompant toutes les fonctions au milieu.

Ceci provoquant donc une double fuite de mémoire dans notre première implémentation, mais pas dans la seconde, pourquoi ? Parce que le RRID possède une propriété extrêmement intéressante : il garantit la destruction des objets même en cas d'exception.

Exemple :

int foo()
{
    Object a;
    a.WeirdMethodWhichMayThrow(); //< En cas d'exception levée: a est détruit

    Object b;
    return 0; //< Les destructeurs de b et a sont appelés après le return
}

Et bien entendu, il est une réalité que tous les développeurs C++ doivent apprendre un jour : des exceptions, il peut s'en produire partout[7].

En guise de bonus, je vous donne le code de la première implémentation capable de gérer correctement la mémoire en cas d'exception : https://pastebin.com/PKvsN80x (n'hésitez pas à le comparer avec le code de l'implémentation à base de std::vector).

Les solutions

Bien, à ce stade de l'article j'espère vous avoir convaincu que ne pas gérer la mémoire à la main en C++ n'était pas qu'une matière de goûts, mais une nécessité cruciale.

Nous avons vu que les std::vector étaient une bonne solution pour remplacer l'allocation dynamique manuelle dans le cas des tableaux contigüs, et ce même quand la taille ne change pas après l'initialisation (ça ne vous coûte rien, et nous avons vu que cela pouvait même augmenter les performances de votre programme).

Mais quid des autres objets, ceux devant être alloués dynamiquement car leur propriétaire peut varier ?
La norme C++11 a répondu à ces problématiques avec l'introduction de std::unique_ptr et std::shared_ptr, qui gèrent respectivement des ressources à propriétaire unique/multiples.

Ainsi, vous pouvez charger une ressource et transférer sa propriété si tout se passe bien et laisser le RRID s'occuper de la libération :

std::unique_ptr<Player> PlayerUtils::LoadPlayer(std::string nickname)
{
    auto player = std::make_unique<Player>(std::move(nickname));
    if (!player->LoadFromDatabase())
        return {}; //< Pas besoin de se préoccuper de la libération du joueur

    if (!player->Initialize())
        return {}; //< Pareil

    return player; //< Mouvement implicite ou NRVO
}

Encore une fois, nous n'avons ni à nous soucier de la libération des ressources en cas d'erreur, ni des potentielles exceptions qui pourraient se produire.

Bien sûr, ces deux classes aussi utiles soient-elles, ne couvrent pas tous les cas d'utilisations, et si c'est le cas je vous invite à concevoir votre propre classe de gestion de ressources répondant à vos besoins.

En résumé

Ce que vous devez retenir de cet article est que le C++ dispose d'un mécanisme de gestion des ressources dont il faut se servir. Cela peut se résumer à implémenter et/ou utiliser des classes possédant un destructeur.

Il faut cependant faire attention, ce mécanisme qui peut être assimilé à un Garbage Collector fonctionne très différemment. Il possède les avantages d'être léger et déterministe mais ne fonctionne pas avec les pointeurs nus et ne peut pas gérer par exemple les références circulaires (ce qu'il faudra donc gérer manuellement).

Une petite liste des solutions standards du C++ et de leurs cas d'utilisation :

  • std::vector<T> (doc) : Lorsque vous souhaitez allouer un tableau d'éléments dont la taille est inconnue à la compilation (que vous souhaitiez redimensionner ou non).
  • std::unique_ptr<T> (doc) : Lorsque votre ressource ne possède qu'un seul propriétaire, même si celui-ci vient à changer, il s'agit du cas le plus fréquent de très loin, faites-en votre solution par défaut.
  • std::shared_ptr<T> (doc) : Lorsque votre ressource possède plusieurs propriétaires, il s'agit d'un cas assez rare et potentiellement dangereux, pouvant produire des cycles de dépendances et possédant un léger impact sur les performances à la copie. (Voir aussi std::weak_ptr).
  • std::optional<T> (doc) : Permet d'initialiser/détruire un objet de manière différée/ou pas du tout, sans allouer de mémoire, peut être utile lorsque vous avez un membre que vous souhaitez initialiser plus tard, ou pour gérer les erreurs en retour de fonction.

Voici qui conclut mon deuxième article sur le C++ moderne, n'hésitez pas à le critiquer/commenter juste ici en dessous, et à le partager à tous les programmeurs pensant que gérer les ressources manuellement est nécessaire/souhaitable.

Quelques liens supplémentaires :


  1. By U.S. Navy photo [Public domain], via Wikimedia Commons ↩︎

  2. À l'heure où j'écris ces lignes, soit le 26 avril 2018 à 12h49 (je fais donc référence à la norme du C++17 publiée en décembre 2017). ↩︎

  3. Tout en restant malheureusement truffé d'idées et de concepts vieillots datants d'avant même la création du langage (le fameux héritage du C). ↩︎

  4. Évidemment vous pouvez utiliser ce que bon vous semble, comme par exemple DIRR (Destruction Is Resource Reclamation) mais qui je trouve à moins de sens car il implique que le destructeur se limite à cela ce qui n'est pas une vérité générale. ↩︎

  5. Évidemment parler de performances au sujet d'une fonction chargeant des fichiers depuis le disque n'a aucun sens, l'accès au disque dur étant beaucoup plus lent que l'allocateur mémoire. ↩︎

  6. Le C++ dispose du mot-clé noexcept pour indiquer qu'une fonction ne fera pas sortir d'exception, mais il est peu utile/utilisé et il est préférable de partir du principe que toutes les fonctions C++ peuvent déclencher une exception. ↩︎

  7. https://youtu.be/ynjmWX9rw7Q?t=26s. ↩︎