Simplifier la gestion de la mémoire en C++ avec RAII

Ou comment s'épargner bien des maux de tête

Tutoriel publié à l’origine sur Progdupeupl

La gestion des ressources est un problème récurrent en informatique. En effet, on ne dispose que de ressources limitées : RAM, disques durs, nombre de calculs par seconde, etc. Et aujourd’hui, il faut admettre qu’on charge de plus en plus de ressources qui prennent de la place. Il faut donc les gérer efficacement. Certains langages, comme le C, obligent l’utilisateur à allouer et libérer de la mémoire pour les ressources et il faut dire que c’est contraignant.

Le C++, en raison de l’approche historique qui en est malheureusement faite dans beaucoup d’ouvrages, est utilisé par certains développeurs comme le C, en gérant les ressources de manière manuelle. Pourtant, il existe un idiome très simple et efficace que nous allons découvrir dans ce tutoriel. Alors oubliez vos new et delete et découvrez ce que C++ vous offre.

Un grand merci à Davidbrcz pour son aide à la validation et à l’amélioration de ce tutoriel, ainsi que tous ceux qui ont relevé des fautes (mention spéciale à Dominus Carnufex).

Gestion manuelle de la mémoire

Bien souvent, dès qu’on manipule des ressources externes, du type image à charger et afficher, connexion à une base de données, ou à un serveur, ou autres, il est inévitable de devoir réserver de la mémoire de façon dynamique. Pour ceux qui ont fait du C, vous pensez sans doute aux pointeurs et vous avez bien raison. Prenons donc un bête exemple : on se connecte à une base de données, on récupère un nombre fixé de noms de trains, on ouvre un fichier, on le verrouille, on travaille ensuite dessus avant de tout refermer comme il se doit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void get_infos_from_db()
{
    SGBD * sgbd = SGBD_Init("trains.db");
    const int nb_trains = 2;

    char** trains_name = malloc(nb_trains * sizeof(char*));
    for (int i = 0; i < nb_trains; ++i)
    {
        char buffer[256];

        trains_name[i] = malloc(42 * sizeof(char));
        sprintf(buffer, "SELECT name FROM trains WHERE id = %d", i);
        strcpy(trains_name[i], do_request(sgbd, buffer));
    }

    File * file = fopen("saved.txt");
    Lock * lock = lock_acquire();

    do_some_stuff(trains_name, file, lock);

    lock_release(lock);
    fclose(file), file = NULL;

    for (int i = 0; i < nb_trains; ++i)
    {
        free(trains_name[i]), trains_name[i] = NULL;
    }
    free(trains_name), trains_name = NULL;

    SGBD_release(sgbd);
}

Pourtant, ce code est juste une horreur à éviter. Pourquoi ? Parce qu’aucune vérification n’est faite. Si une seule opération échoue, on est bon pour un segfault. Alors, sécurisons ce code (merci à Taurre ).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
void darray_delete(void ** self, unsigned n)
{
    if (self != NULL)
    {
        unsigned i;
        for (i = 0; i < n; ++i)
        {
            free(self[i]);
        }
        free(self);
    }
}

void ** darray_create(unsigned n, unsigned m, size_t size)
{
    void ** self;
    unsigned i;

    assert(n != 0 && m != 0 && size != 0);
    assert(SIZE_MAX / sizeof * self >= n);
    assert(SIZE_MAX / m >= size);

    self = malloc(n * sizeof * self);
    if (self == NULL)
    {
        goto alloc_array_fail;
    }

    for (i = 0; i < n; ++i)
    {
        self[i] = malloc(m * size);
        if (self[i] == NULL)
        {
            goto alloc_element_fail;
        }
    }

    return self;

alloc_element_fail:
    darray_delete(self, i);
alloc_array_fail:
    return NULL;
}

#define NTRAINS 2
#define TRAIN_MAX 42
#define BUFFER_MAX 256

void get_infos_from_db(void)
{
    SGBD * sgbd;
    void ** trains_name;
    FILE * file;
    Lock * lock;
    unsigned i;

    sgbd = SGBD_Init("trains.db");
    if (sgbd == NULL)
    {
        write_error_log(SGBD_FAIL);
        goto sgbd_fail;
    }

    trains_name = darray_create(NTRAINS, 1, TRAIN_MAX);
    if (trains_name == NULL)
    {
        write_error_log(TRAIN_CREATE_FAIL);
        goto darray_create_fail;
    }

    for (i = 0; i < NTRAINS; ++i)
    {
        char buffer[BUFFER_MAX];
        int n;

        n = snrintf(buffer, sizeof buffer, "SELECT name FROM trains WHERE id = %d", i);
        if (n < 0 || n > sizeof buffer)
        {
            write_error_log(SNPRINTF_FAIL);
                goto snprintf_fail;
        }

        strcpy(trains_name[i], do_request(sgbd, buffer));
    }

    file = fopen("saved.txt");
    if (file == NULL)
    {
        write_error_log(FILE_FAIL);
        goto fopen_fail;
    }

    lock = lock_acquire();
    if (lock == NULL)
    {
        write_error_log(LOCK_FAIL);
        goto lock_acquire_fail;
    }

    /*
     * Stuff
     */

    lock_release(lock);

    lock_acquire_fail:
        fclose(file);

    fopen_fail:
    snprintf_fail:
        darray_delete(trains_name, NTRAINS);

    darray_create_fail:
        SGBD_release(sgbd);

    sgbd_fail:
        ;
}

#undef NTRAINS
#undef TRAIN_MAX
#undef BUFFER_MAX

Quelle plaie à écrire ! Non seulement c’est long, mais en plus, c’est plus complexe à comprendre, on peut avoir oublié certains cas, bref, un cauchemar. Et encore, on aurait pu avoir à initialiser plus de ressources encore.

Peut-être certains d’entre vous pensent que goto, c’est un héritage du C dépassé, et qu’en C++ on devrait plutôt utiliser les exceptions. Soit, essayons.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
void get_infos_from_db()
{
    const int nb_trains = 2;
    SGBD * sgbd;

    try
    {
        sgbd = SGBD_Init("trains.db");
    }
    catch (sgbd_exception const & e)
    {
        write_error_log(e);
        throw;
    }

    char** trains_name;
    try
    {
        trains_name = new char*[nb_trains];
    }
    catch (std::bad_alloc const & e)
    {
        SGBD_release(sgbd);
        write_error_log(e);
        throw;
    }

    int last_good_alloc_index = 0;
    for (int i = 0; i < nb_trains; ++i)
    {
        char buffer[256];

        try
        {
            trains_name[i] = new char[42];
        }
        catch (std::bad_alloc const & e)
        {
            for (int i = 0; i < last_good_alloc_index; ++i)
            {
                delete[] trains_name[i], trains_name[i] = NULL;
            }

            delete[] trains_name, trains_name = NULL;
            SGBD_release(sgbd);
            write_error_log(e);
            throw;
        }

        last_good_alloc_index = i;

        sprintf(buffer, "SELECT name FROM trains WHERE id = %d", i);
        strcpy(trains_name[i], do_request(sgbd, buffer));
    }

    File * file;
    try
    {
        file = fopen("saved.txt");
    }
    catch (file_exception const & e) 
    {
        for (int i = 0; i < nb_trains; ++i)
        {
            delete[] trains_name[i], trains_name[i] = NULL;
        }

        delete[] trains_name, trains_name = NULL;
        SGBD_release(sgbd);
        write_error_log(e);
        throw;
    }

    Lock * lock;
    try
    {
        lock = lock_acquire();
    }
    catch (lock_exception const & e)
    {
        fclose(file), file = NULL;

        for (int i = 0; i < nb_trains; ++i)
        {
            delete[] trains_name[i], trains_name[i] = NULL;
        }

        delete[] trains_name, trains_name = NULL;
        SGBD_release(sgbd);
        write_error_log(e);
        throw;
    }


    do_some_stuff(trains_name, file, lock);

    lock_release(lock);
    fclose(file), file = NULL;

    for (int i = 0; i < nb_trains; ++i)
    {
        free(trains_name[i]), trains_name[i] = NULL;
    }
    free(trains_name), trains_name = NULL;

    SGBD_release(sgbd);
}

Finalement, ce code ne nous apporte aucun avantage par rapport au précédent : toujours aussi gros, toujours aussi illisible, et nous ne sommes même pas sûrs de couvrir tous les chemins possibles : un oubli est possible, une fonction apparemment inoffensive peut lancer une exception, bref, toujours un cauchemar à maintenir.

Que retenir jusque là : que la détection d’erreurs par retour de fonctions et goto ou par le biais d’exceptions nécessite d’ajouter des if ou des try catch toutes les deux lignes. En fait, dans ces cas de figure, chaque ligne où l’on acquiert une ressource qui n’est pas suivie d’un if ou entourée d’un try catch est suspecte et peut potentiellement faire échouer l’exécution.

Le cœur du problème tient en une phrase : le développeur doit écrire du code spécifique pour la libération de la mémoire et la gestion des erreurs. Pour améliorer la situation, il faut obligatoirement libérer le développeur de cette tâche, qu’elle soit automatique. Or, contrairement au C# ou au Java qui disposent d’un mécanisme de libération de la mémoire transparent et automatique appelé garbage collector, il n’est rien de tel en C++1. Sommes-nous donc condamnés à devoir écrire des codes aussi lourds ? Non, car une solution existe déjà.


  1. Le C++ peut se voir doter d’un garbage collector. C’est quelque chose de prévu par la norme. Dans ce tutoriel, nous n’aborderons pas cette possibilité. 

L’idiome RAII à la rescousse

Le C++ propose un idiome particulier appelé RAII, pour Resource Acquisition Is Initialization, ce que l’on peut traduire par « acquisition de ressources lors de l’initialisation » en français. Comment fonctionne-t-il ? Chaque ressource sera manipulée par une variable locale qui va l’acquérir à la construction et la libérer à la destruction. Ainsi, l’utilisateur n’aura même plus à se soucier d’appeler les fonctions free, unlock et autres delete pour que la libération des ressources ait bien lieu.

Pour appliquer cet idiome en C++, nous allons utiliser les classes et en particulier le couple constructeur(s) / destructeur. On peut parler de capsules RAII.

  • Toutes les ressources seront acquises dans le constructeur ; si des ressources sont impossibles à acquérir, on lève une exception. Ainsi, il n’y a pas de risque de créer un objet incomplet (Ill formed en anglais) donc pas de risque de fuite de mémoire : la norme garantit en effet que si un constructeur lève une exception, toute la mémoire des membres déjà allouée est libérée.
  • Toutes les ressources seront libérées dans le destructeur. Celui-ci étant appelé automatiquement dès que l’objet est détruit, on y écrira tous les mécanismes de libération de la ressource acquise dans le constructeur.

Un constructeur ne peut acquérir, au maximum, qu’une seule ressource non encapsulée par un mécanisme RAII. La classe contenant pour ce constructeur devient alors une capsule RAII pour cette ressource.

Voyons sans plus tarder comment appliquer ce principe à notre code précédent. Commençons par encapsuler nos ressources dans des classes, en prennant par exemple le SGBD.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Sgbd_Capsule
{
    public:
        Sgbd_Capsule(const char * db)
        {
            /*
                Appels de méthodes, initialisations d'attributs, etc.
            */

            this->m_sgbd = SGBD_Init(db);
        }

        ~Sgbd_Capsule()
        {
            /*
                On libère les ressources allouées.
            */

            SGBD_release(this->m_sgbd);
        }

    private:
        SGBD * m_sgbd;
};

Maintenant, nous pouvons écrire du code aussi simple que celui ci-dessous (et nous verrons que nous pouvons faire encore plus simple dans la section suivante).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int foo()
{
    Sgbd_Capsule sgbd("trains.db");

    /*
        Des opérations diverses sur le SGBD.
    */

    return 42;
}

Les ressources sont libérées à la sortie du bloc dans lequel nous les avons acquises, c’est-à-dire ici en sortant de la fonction. Voyez par vous-mêmes l’exemple suivant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Test
{
    public:
        Test(int number)
        : m_number(number)
        {
            std::cout << "Acquisition de la ressource n°" << this->m_number << ".\n";
        }

        ~Test()
        {
            std::cout << "Libération de la ressource n°" << this->m_number << ".\n";
        }

    private:
        int m_number;
};

int main()
{
    Test a(1);
    {
        Test b(2);
        {
            Test c(3);
            Test d(4);
        }

        Test e(5);
    }
    return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Acquisition de la ressource n°1.
Acquisition de la ressource n°2.
Acquisition de la ressource n°3.
Acquisition de la ressource n°4.
Libération de la ressource n°4.
Libération de la ressource n°3.
Acquisition de la ressource n°5.
Libération de la ressource n°5.
Libération de la ressource n°2.
Libération de la ressource n°1.

Gestion des erreurs

Il reste néanmoins un problème que nous ne gérons pas encore : que fait-on si une erreur survient lors de l’acquisition ou de la libération des ressources ? Examinons chacun des cas.

Erreur lors de l’acquisition

Si on ne peut acquérir une ressource, alors l’objet ne peut être construit. Le mieux est donc de lancer une exception.

Lors de la construction d’un objet, si jamais le constructeur lance une exception, alors toute la mémoire réservée pour les membres sera libérée. Si jamais le constructeur a alloué de la mémoire dynamiquement de quelque manière que ce soit, alors cette dernière n’est pas libérée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Sgbd_Capsule
{
    public:
        Sgbd_Capsule(const char * db)
        {
            /*
                Appels de méthodes, initialisations d'attributs, etc.
            */

            this->m_sgbd = SGBD_Init(db);
            if (this->m_sgbd == nullptr)
            {
                throw sgbd_exception("Le SGBD ne peut être initialisé.");
            }
        }

        ~Sgbd_Capsule()
        {
            /*
                On libère les ressources allouées.
            */

            SGBD_release(this->m_sgbd);
        }

    private:
        SGBD * m_sgbd;
};

Enfin, un conseil important que je répète : si on a plusieurs ressources à acquérir dans un même constructeur, il vaut mieux que chaque ressource soit encapsulée dans sa propre capsule RAII ; ainsi, chaque ressource sera libérée par son propre destructeur et on s’évite bien des soucis.

Erreur lors de la destruction

Ces cas-là sont problématiques. En effet, il est impossible de lancer une exception. Pourquoi ? Nous savons que le destructeur d’un objet sera appellé si une exception est lancée dans le code ; or, si le destructeur lance lui aussi une exception, nous nous retrouvons avec deux exceptions sur les bras, ce qui provoque un appel à la fonction terminate() et donc l’arrêt brutal du programme. De même, n’appelez jamais de fonctions dans le destructeur qui sont susceptibles de lancer des exceptions.

On peut néanmoins utiliser un système de logs pour informer l’utilisateur qu’une erreur dans la libération des ressources est arrivée. Quant à savoir si l’on continue l’exécution ou s’il vaut mieux tout arrêter, c’est à vous de voir en fonction des situations.

Un mot sur le dispose pattern

Peut-être venez-vous d’un langage où il existe un mot-clef finally, utilisé à la suite d’un try catch et exécuté peu importe si une exception a été attrapée ou non ; ou bien existe-t-il des constructions similaires du type using (C#), with (Python) ou encore try-with-ressources (Java 7+). Dans tous les cas, le but est le même : empêcher des fuites de mémoire en libérant des ressources précédemment allouées. C’est ce qu’on appelle le dispose pattern.

Pourtant, C++ ne fournit pas de mot-clef ou de construction similaire à celles de Java ou C# pour la simple et bonne raison que RAII nous permet de faire la même chose de façon plus efficace. Qu’est-ce qui me permet de dire ça ? Je laisse le créateur du C++ répondre.

In a system, we need a "resource handle" class for each resource. However, we don’t have to have an "finally" clause for each acquisition of a resource. In realistic systems, there are far more resource acquisitions than kinds of resources, so the "resource acquisition is initialization" technique leads to less code than use of a "finally" construct.

Bjarne Stroustrup

Dans un système, il faut une "capsule RAII" pour chaque ressource. Cependant, nous n’avons pas besoin d’une clause "finally" pour chaque acquisition de ressource. Dans des systèmes réalistes, il y a beaucoup plus d’acquisitions de ressources que de types de ressources, donc le RAII conduit à écrire moins de code que l’utilisation d’une construction avec "finally".

Traduction libre.

Exemples d’application avec la bibliothèque standard

La bibliothèque standard utilise énormément cet idiome, à travers des noms qui vous sont certainement familliers : std::string, std::array, std::vector, std::ifstream, etc. Quand on y réfléchit, a-t-on déjà libéré manuellement un std::string ? Non, car c’est fait automatiquement pour nous. Et pour vous montrer à quel point la bibliothèque standard est infiniment supérieure à tout ce qu’on pourait faire manuellement, reprenons notre code de début en utilisant les mécanismes standards.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void get_infos_from_db()
{
    const int nb_trains = 2;
    Sgbd_Capsule sgbd("trains.db");

    std::vector<std::string> trains_names;
    for (int i = 0; i < nb_trains; ++i)
    {
        std::string buffer = "SELECT name FROM trains WHERE id = " + std::to_string(i);
        trains_names.push_back(do_request(sgbd, buffer));
    }

    std::ifstream file("saved.txt");
    Lock * lock = lock_acquire();

    do_some_stuff(trains_name, file, lock);

    lock_release(lock);
}

N’est-ce pas plus clair à lire et à comprendre ? Premier point à retenir : toujours utiliser au maximum la bibliothèque standard. Pourquoi se frustrer à faire un code comme on ferait en C quand on peut profiter de mécanismes éprouvés, performants et sûrs comme ceux proposés par la bibliothèque standard ? Donc faites-y appel le plus possible, ce sera du temps et du confort de gagnés.

Cas particulier des pointeurs

Notre code n’est pas encore tout à fait satisfaisant. En effet, il reste un pointeur. Or, les pointeurs nus sont source de beaucoup de problèmes en C++. Et si on pouvait ne pas avoir à écrire Sgbd_Capsule, ce serait encore mieux. Heureusement, la bibliothèque standard arrive encore une fois à notre secours en fournissant des pointeurs intelligents qui nous libèrent des contraintes de libération que l’on connait si bien en C.

La norme C++11 nous propose plusieurs types de pointeurs intelligents :

  • std::auto_ptr : déprécié, à ne plus utiliser ;
  • std::unique_ptr : comme son nom l’indique, à utiliser quand on ne veut avoir qu’un seul pointeur sur un objet ;
  • std::shared_ptr : utilise un système de comptage de références qui permet que plusieurs pointeurs pointent un même objet, ce dernier étant libéré quand le dernier pointeur pointant dessus est détruit ;
  • std::weak_ptr : si l’on n’y prend pas garde, les std::shared_ptr peuvent entrainer un problème de références circulaires (lisez donc cet article de Developpez qui illustre ce problème). Il sert également dans le cas d’une ressource avec plusieurs observateurs non propriétaire. Je vous invite à lire cet article pour des explications plus approfondies sur lequel choisir.

Nous avons également deux templates bien pratiques :

  • std::make_shared<T> : construit un objet T et le met dans un std::shared_ptr (disponible avec C++11) ;
  • std::make_unique<T> : construit un objet T et le met dans un std::unique_ptr (disponible avec C++14, voir ici pour une implémentation en C++11).

Ces templates sont à utiliser le plus possible car ils permettent d’écrire un code exception-safe. Lisez l’article de Herb Sutter à ce propos.

Et en plus, le mieux du mieux, on peut définir des deleters, c’est-à-dire définir comment le pointeur va libérer sa ressource. Il suffit simplement de créer une classe sur ce modèle que l’on passera ensuite en argument à notre pointeur intelligent.

1
2
3
4
5
6
7
8
9
class Deleter
{
    public:
        template <typename T>
        void operator()(T * ptr) const
        {
            /* Opérations diverses pour libérer la ressource. */
        }
};

Et comme un exemple vaut mille explications, utilisons ce principe avec notre SGBD et notre mécanisme de verrouillage qui se prêtent bien au jeu. Mais comme rien n’est parfait, les fonctions std::make_shared<T> et std::make_unique<T> ne prennent pas de deleter en argument. Il nous faut passer par la construction classique.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class SGBD_deleter
{
    public:
        void operator()(SGBD * sgbd) const
        {
            SGBD_release(sgbd);
        }
};

class Lock_deleter
{
    public:
        void operator()(Lock * lock) const
        {
            lock_release(lock);
        }
};

void get_infos_from_db()
{
    const int nb_trains = 2;
    std::unique_ptr<SGBD, SGBD_deleter> sgdb {SGBD_Init("train.db"), SGBD_deleter()};

    std::vector<std::string> trains_names;
    for (int i = 0; i < nb_trains; ++i)
    {
        std::string buffer = "SELECT name FROM trains WHERE id = " + std::to_string(i);
        trains_names.push_back(do_request(sgbd, buffer));
    }

    std::ifstream file("saved.txt");
    std::unique_ptr<Lock, Lock_deleter> lock {lock_acquire(), Lock_deleter()};

    do_some_stuff(trains_name, file, lock);
}

Les pointeurs intelligents nous permettent également d’éviter le problème du constructeur qui alloue lui-même de la mémoire que nous avons vu dans la section précédente. En effet, les pointeurs intelligents seront bien libérés même si l’on rencontre une exception. Donc utilisez-les dès que vous pouvez, quite à réécrire une version fonctionelle des pointeurs intelligents ou utiliser Boost si vous ne pouvez pas compiler en C++11 / C++14.

Deuxième point à retenir : chaque fois qu’il est nécessaire d’utiliser des pointeurs, utilisez des pointeurs intelligents. Les cas où vous devrez obligatoirement utiliser des pointeurs nus sont très rares, alors utilisez la solution la plus confortable.

Bonnes pratiques

L’idéal, quand on gère des ressources, est de les libérer dès que possible. Non seulement cela est obligatoire dans certains cas (afin de ne pas faire attendre un processus trop longtemps pour ouvrir un fichier par exemple), mais en plus cela permet de soulager le système. Comment traduire cette bonne pratique en utilisant l’idiome RAII ? Eh bien, il faut que l’on détruise nos objets s’occupant des ressources le plus vite possible, ce qui est possible en utilisant des blocs d’instructions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int value;

{ // Début du bloc d’instructions.
    std::ifstream f("test.txt");
    if (f.is_open()) {
        f >> value;
    }
} // Fin du bloc d’instruction : appel du destructeur du fichier.

value = value * 4;

Il s’agit d’une pratique courante que vous pourrez voir dans certains codes. Et bien entendu, le corolaire : ne déclarez vos objets que quand vous en avez besoin et pas avant. Alors oubliez les réflexes du C89 qui consistent à déclarer toutes les variables au début d’un bloc et ne le faites que pour un usage immédiat (sauf exception).

La const-correctness

Ce n’est pas une bonne pratique spécifique au RAII, mais dès qu’une ressource est censée être constante, alors il faut impérativement utiliser le mot-clef const. Cela donne des garanties à l’utilisateur et, couplé avec des références, permet un passage en argument plus rapide.

1
2
3
4
5
void bar(std::string const & data)
{
    // Ici, on est certain que data ne sera pas modifiée.
    // On utilise le passage par référence pour éviter une recopie inutile.
}

D’ailleurs, petite astuce (merci Herb Sutter), si l’on veut déclarer un objet constant alors que ses paramètres dépendent de conditions, on peut y arriver grâce aux lambdas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

class Test
{
    public:
        Test(int number)
        : m_number(number)
        {
        }

        int number() const
        {
            return this->m_number;
        }

    private:
        int m_number;
};

int main()
{
    const Test a(1);

    const Test f = [&]
    {
        Test f = Test(6);
        if (a.number() == 1)
        {
            f = Test(42);
        }

        return f;
    }();

    std::cout << "Valeur de f : " << f.number() << std::endl;

    return 0;
}
1
Valeur de f : 42

Et dans les autres langages ?

Bien que le C++ ait été le précurseur et le plus grand utilisateur de l’idiome RAII, aujourd’hui, il n’est plus le seul. D’autres langages permettent, par des moyens assez similaires, d’utiliser une sorte de RAII.

Avec C

Bien que cette possibilité soit offerte par une extension de GCC et donc non standard, elle mérite le détour et peut être intéressante pour ceux dont les applications ne seront compilées que par GCC. Il s’agit de l’attribut cleanup. Voici un exemple tiré de la page Wikipédia consacrée au RAII.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static inline void fclosep(FILE ** fp)
{
    if (*fp)
        fclose(*fp);
}
#define _cleanup_fclose_ __attribute__((cleanup(fclosep)))

void example_usage()
{
    _cleanup_fclose_ FILE * logfile = fopen("logfile.txt", "w+");
    fputs("hello logfile !", logfile);

    /* logfile est correctement fermé sans appel explicite à fclose */
}

Avec D

Le D fournit trois méthodes pour permettre la libération des ressources, dont une identique à celle utilisée en C++ : le couple constructeur / destructeur d’une classe. Les exemples suivants sont tirés du site officiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Lock
{
    Mutex m;

    this(Mutex m)
    {
        this.m = m;
        lock(m);
    }

    ~this()
    {
        unlock(m);
    }
}

void abc()
{
    Mutex m = new Mutex;
    auto l = scoped!Lock(new Lock(m));
    foo();
}

La seconde façon se rapproche de celle de Java avec un try finally.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void abc()
{
    Mutex m = new Mutex;
    lock(m);        // lock the mutex
    try
    {
        foo();      // do processing
    }
    finally
    {
        unlock(m);  // unlock the mutex
    }
}

Enfin, il existe une troisième méthode, originale par rapport aux deux autres : scope(exit). Tout le code qui sera placé après cette instruction sera exécuté peu importe si la fonction se termine normalement ou si une exception est lancée. Elle se décline également sous deux autres formes : scope(failure) où le code ne sera exécuté qu’en cas d’exception et scope(success) où le code sera exécuté en cas de déroulement normal. La documentation complètera mes explications.

1
2
3
4
5
6
7
8
9
void abc()
{
    Mutex m = new Mutex;

    lock(m);                // lock the mutex
    scope(exit) unlock(m);  // unlock on leaving the scope

    foo();                  // do processing
}

Avec Rust

Rust, langage développé par la fondation Mozilla, utilise le RAII de la même manière que C++. Et comme un code est plus parlant, voici celui tiré de la page consacrée au RAII avec Rust.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn create_box() {
    // Allocate an integer in the heap
    let _function_box = box 3i;

    // `_function_box` gets destroyed here, memory gets freed
}

fn main() {
    // Allocate an integer in the heap
    let _boxed_int = box 5i;

    // new (smaller) scope
    {
        // Another heap allocated integer
        let _short_lived_box = box 4i;

        // `_short_lived_box` gets destroyed here, memory gets freed
    }

    // Create lots of boxes
    for _ in range(0u, 1_000) {
        create_box();
    }

    // `_boxed_int` gets destroyed here, memory gets freed
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ rustc raii.rs && valgrind ./raii
==26873== Memcheck, a memory error detector
==26873== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==26873== Using Valgrind-3.9.0 and LibVEX; rerun with -h for copyright info
==26873== Command: ./raii
==26873==
==26873==
==26873== HEAP SUMMARY:
==26873==     in use at exit: 0 bytes in 0 blocks
==26873==   total heap usage: 1,013 allocs, 1,013 frees, 8,696 bytes allocated
==26873==
==26873== All heap blocks were freed -- no leaks are possible
==26873==
==26873== For counts of detected and suppressed errors, rerun with: -v
==26873== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 2 from 2)

Nous voilà arrivés à la fin de ce tutoriel qui, je l’espère, vous en aura appris un peu plus sur C++. Bien entendu, le RAII n’est pas parfait : le pire qui puisse arriver est une erreur dans le destructeur. Mais hormis ces cas critiques, c’est un idiome particulièrement pratique et puissant, alors usez-en et abusez-en !

9 commentaires

Dans le troisième code, avec les exceptions, je ne comprends pas bien l'intérêt de last_good_alloc_index : il est systématiquement égal à i, alors pourquoi gérer une deuxième variable ?

Pour le reste, je ne trouve pas que cela simplifie tant que ça le code : il y a toujours autant de merde (ou presque) à écrire, sauf qu'on la cache sous le tapis en la mettant dans les classes. Les codes d'exemple que vous donnez ne deviennent réellement simple qu'à partir du moment où vous utilisez les fonctions de la bibliothèque standard, et que la gestion d'erreurs devient donc « cachée ». :-)

C'est intéressant malgré tout, ça permet de comprendre comment ils s'y prennent.

+0 -3

@Dominus Carnufex: les mettre dans l'implémentation plutôt que dans l'utilisation, c'est quand même grandement avantageux quand on utilise (et je ne doute pas qu'on le fasse souvent) plusieurs fois une même classe : pas besoin de recopier le code de gestion d'erreur.

+1 -0

Ça, plus le fait qu'on a réellement à écrire ce travail de destruction que … quand il y a des choses à faire à la destruction. En l'occurrence, quand on n'alloue pas manuellement de ressources dans nos objets (ce qui est clairement le cas le plus commun quand on a des classes bien foutues à la base), on n'a rien à écrire.

Pourquoi ici, on a besoin de l'écrire ? Parce qu'on part d'éléments qui viennent du C et donc qu'il nous faut les wrapper. (D'ailleurs, ç'aurait pu être sympa de montrer comme tuner un unique_ptr pour ne même pas avoir à écrire le destructeur ^^ ).

Pour ce qui est de la gestion des erreurs, on sélectionne toujours celle qu'on veut faire, la différence tient dans le fait que cette gestion des erreurs n'est bien qu'une gestion des erreurs sans problématique de libération manuelle de ressources.

Oui j'avais compris, mais le tutoriel a l'air de mettre l'accent sur l'utilisation de la lib standard, donc je signale qu'on est pas obligé de passer par un std::unique_ptr avec destructeur perso pour utiliser un mutex, et qu'une enveloppe est déjà toute faite pour ça :)

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte