La mémoire en C++

 

En C++, un objet peut avoir 4 durées de vie différentes :

 

  • Durée de vie automatique : l’objet est détruit automatiquement à la fin du scope dans lequel il a été déclaré. Par exemple, si une variable a été déclarée au début d’une fonction, elle sera détruite et son destructeur sera appelé à la fin de la-dite fonction.
  • Durée de vie statique : l’objet est créé au début du programme et est détruit à la fin du programme. C’est le cas des variables globales ou bien des variables avec le mot clef static dans une fonction par exemple.
  • Durée de vie de thread : le principe est similaire à une variable statique, sauf que sa durée de vie n’est pas liée au programme mais à un thread. A la création d’un thread, l’objet est créé, et à la fin du même thread, elle est détruite. C’est à ça que sert le mot clef thread_local.
  • Durée de vie dynamique : c’est la mémoire qui est allouée et désallouée explicitement avec les opérateurs new et delete. C’est ce type de durée de vie qui va nous intéresser dans cet article.

On notera que le standard du C++ ne parle pas de heap et de stack, t que de toute manière il est possible d’avoir un objet avec une durée de vie dynamique allouée dans stack, donc ces termes sont hors-sujet dans le cadre de cet article.

 

Le problème de la durée de vie dynamique

 

Le gros problème de la durée de vie dynamique, c’est qu’il est très facile d’oublier de détruire l’objet : c’est pour ça qu’il est recommandé d’utiliser au maximum la durée de vie automatique, mais parfois il n’y a pas d’alternative. Dans ce cas, une des solutions est de gérer cet objet à durée de vie dynamique avec un objet à durée de vie automatique.

 

Dans la bibliothèque standard, c’est comme ça que sont gérés les containers. Prenons l’exemple de std::vector, on peut le créer avec une durée de vie statique : en interne, il utilise un tableau avec une durée de vie dynamique mais pourtant le développeur n’a pas besoin de gérer manuellement ce tableau interne, même si ça reste en partie possible. Lorsqu’un élément est ajouté, s’il y a besoin, une nouvelle allocation mémoire est faite; lors d’une copie, un nouveau tableau est créé; lorsque que l’objet est détruit, le tableau est correctement détruit. Comme autre exemple notable, on peut noter std::string pour gérer les chaines de caractères.

 

Pour des cas un peu plus bas niveau, les pointeurs intelligents permettent d’utiliser des pointeurs vers de la mémoire à durée dynamique de manière générique. Les 2 plus connus (apparus avec C++11) sont :

 

 

 

std::unique_ptr

 

Présentation

 

Comme dit précédemment, std::unique_ptr est ce qu’on appelle un pointeur intelligent. Concrètement, cela veut dire que c’est un objet qui simule le comportement d’un pointeur, c’est le cas grâce à la surcharge de l’opérateur ->, l’opérateur * et de l’opérateur [], mais qui ajoute des fonctionnalités en plus. Pour std::unique_ptr, ce qu’il en offre c’est l’assurance que la ressource qu’il doit gérer sera détruite.

 

Dans son nom il y a le mot unique car il est le seul à posséder la ressource. Comprenez par là que même si d’autres objets peuvent avoir accès à la ressource, le std::unique_ptr est l’unique responsable de la durée de vie de la ressource qu’on lui a assigné.

 

Par conséquent, un std::unique_ptr n’est pas copiable, il n’a pas d’opérateur ou de constructeur par copie. Donc si vous voulez faire une copie, il faut copier soit-même la ressource sous-jacente.

 

Un std::unique_ptr est compatible avec les conteneurs de la bibliothèque standard et peut donc être stocké de manière efficace et sûre dans un std::vector par exemple.

 

Comment s’en servir

Rien ne vaut des exemples pour comprendre :

 

👉 Création

1

2

3

4

5

6

7

8
// The C++11 way of doing it

std::unique_ptr<int> pointer_to_integer(new int(5));


// The C++14 way of doing it

std::unique_ptr<double> pointer_to_double = std::make_unique<double>(5.3);


// It also works with auto

auto pointer_to_string = std::make_unique<std::string>("Hello, World !");

 

 

👉 L’utiliser comme un pointeur

 

1

2

3

4

5

6

7

8

9

10

11

12

13
auto pointer_to_string = std::make_unique<std::string>("Hello, World !");

// Compare with nullptr to see if there is a value

if (pointer_to_string != nullptr) // if (pointer_to_string) would also work

{

        // Use the operator -> as a normal pointer

        std::cout << "Lenght: " << pointer_to_string->size() << std::endl;

        // Use the * operator as a normal pointer

        std::cout << "Content: " << *pointer_to_string << std::endl;

}

else

{

        std::cout << nullptr << std::endl;

}

 

 

👉 Ajouter dans un conteneur

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16
std::vector<std::unique_ptr<int>> ints;


// Add it directly in the container

ints.emplace_back(std::make_unique<int>(3));

ints.push_back(std::make_unique<int>(3));


// Or create it before

auto ptr = std::make_unique<int>(4);

// Then move it in the container

ints.push_back(std::move(ptr));


for (const auto& p: ints)

{

        int i = (p) ? *p : 0;

        std::cout << i << std::endl;

}

 

 

👉 Changer la possession de la ressource

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15
// first_ptr own the ressource

auto first_ptr = std::make_unique<int>(3);


// Now second_ptr own the ressource and first_ptr is empty

auto second_ptr = std::move(first_ptr);


// Create an unique_ptr that own nothing

std::unique_ptr<int> third_ptr = nullptr;

// Now a swap, third_ptr own the ressource and second ptr is empty

third_ptr.swap(second_ptr);


// third_ptr is now empty again, be carefull, fourth_ptr is not an unique_ptr

int* fourth_ptr = third_ptr.release();

// Because fourth_ptr is not a unique_ptr we have to destroy the ressource manually

delete fourth_ptr;

 

 

👉 Envoyer à une autre fonction

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24
// You just want to use the ressource, not to own it

// So you just need a raw pointer

void use_ptr(int* ptr)

{

    // Process stuff

}


// You want to posess the ressource, so you want a std::unique_ptr

void get_ptr_ownership(std::unique_ptr<int> ptr)

{

    // The ressource will be destroyed at the end of the fonction

}


void create_ptr()

{

    auto ptr = std::make_unique<int>(-4);

   

    // If an other function just need to use the pointer

    // Pass it this way with the method std::unique_ptr::get

    use_ptr(ptr.get());

   

    // If you want to pass the ownership somewhere else, use std::move

    get_ptr_ownership(std::move(ptr));

}

 

 

👉 Détruire la ressource explicitement

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23
std::cout << "Begin" << std::endl;


// Create the pointer normally

auto first_ptr = std::make_unique<Ressource>();


// It will create a new unique_ptr, then replace the content of first ptr by the newly create ptr

// In the process, the old ressource is destroyed

first_ptr = std::make_unique<Ressource>();


// The ressource is explicitly destroyed

first_ptr.reset();


std::cout << "End" << std::endl;


/*

        The output of the program is:

               - Begin

               - Constructor

               - Constructor

               - Destructor

               - Destructor

                - End

*/

 

 

Deleter customisé

 

Tout d’abord, un deleter dans le contexte de std::unique_ptr, c’est quoi ? C’est la fonction ou le fonctor qui est appelée lorsqu’on veut détruire la ressource qui est gérée par le std::unique_ptr, donc cela arrive quand il contient un pointeur non nul et que l’un des cas suivant arrive :

 

 

Le deleter par défaut fait simplement un delete ou un delete[], mais il est possible de faire autre chose à la place. Le 2ème argument template d’un std::unique_ptr est justement ce deleter et c’est en changeant cet argument par autre chose que le deleter par défaut qu’on peut le modifier.

 

Mais dans quel cas ça pourrait être utile ? Imaginons que vous devez appeler une fonction pour ouvrir un fichier spécifique, mais qui, au lieu de vous retourner un std::ifstream, vous retourne un FILE*. ela vous oblige donc à appeler la fonction std::fclose pour fermer le fichier. On a donc un code comme ça :

 

1

2

3

4

5

6
void process()

{

        std::FILE* file = open_important_file();

        // Processing [...]

        std::fclose(file);

}

 

 

Le problème avec cet exemple, c’est que si pendant le processing entre l’appel à open_important_file et std::fclose(file), une exception est levée sans être catchée et le fichier ne sera donc pas fermé.

 

Pour régler ce problème, la solution basique serait de créer sa propre classe pour encapsuler le FILE*. Ça donnerait une classe comme ceci :

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20
struct FileHandle

{

        FileHandle(std::FILE* f): handle(f) {}


        FileHandle(const FileHandle&) = delete;

        FileHandle& operator=(const FileHandle&) = delete;


        FileHandle(FileHandle&&) = default;

        FileHandle& operator=(FileHandle&&) = default;


        ~FileHandle() { std::fclose(handle); }


        std::FILE* handle;

};


void process()

{

        FileHandle file(open_important_file());

        // Processing [...]

}

 

 

Effectivement cela règle le problème, mais il y a plus court et plus simple : utiliser un std::unique_ptr avec un deleter customisé.

 

👉 Voici la 1ère manière de le faire avec un fonctor :

 

1

2

3

4

5

6

7

8

9

10

11

12

13
struct FileClose

{

        void operator()(std::FILE* file)

        {

               std::fclose(file);

        }

};


void process()

{

        std::unique_ptr<std::FILE, FileClose> file(open_important_file());

        // Processing [...]

}

 

 

👉 Et la 2nd avec une fonction :

 

1

2

3

4

5

6

7

8

9

10
void close_file(std::FILE* file)

{

        std::fclose(file);

}


void process()

{

        std::unique_ptr<std::FILE, decltype(&close_file)> file(open_important_file(), &close_file);

        // Processing [...]

}

 

On notera qu’on n’a pas utilisé directement std::fclose comme deleter, car utiliser l’adresse du fonction de la bibliothèque standard est un comportement indéfini d’après le standard.

 

 

Conclusion

 

On a vu à quoi servait std::unique_ptr, comment s’en servir de manière simple pour gérer simplement des objets à durée dynamique, mais aussi comment faire des choses un peu plus complexes avec un deleter. Vous connaissez donc l’essentiel, et vous êtes prêts à l’utiliser dans vos projets. En utilisant tous les outils à disposition en C++, les fuites mémoires et autres problèmes similaires ne devraient plus vous faire peur !

 

Sources