En C++, un objet peut avoir 4 durées de vie différentes :
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 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 :
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.
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 */ |
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.
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 !