C++ProgrammationDéveloppeur C++ Senior

Qu'est-ce qui distingue le support de déléteur personnalisé de **std::unique_ptr** de celui de **std::shared_ptr** en termes d'effacement de type et d'implications sur la taille des objets ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

C++11 a introduit std::unique_ptr et std::shared_ptr pour remplacer l'insécurisé std::auto_ptr. Les deux prennent en charge des déléteurs personnalisés pour gérer des ressources non mémoire comme des poignées de fichiers ou des connexions à des bases de données. Cependant, leurs approches architecturales diffèrent fondamentalement en raison de leurs modèles de propriété et des exigences de performance.

std::unique_ptr implémente la propriété exclusive et stocke son déléteur comme une partie de son type (le deuxième paramètre de modèle). Si le déléteur a un état, il occupe de l'espace au sein de l'objet unique_ptr lui-même, aux côtés du pointeur géré. std::shared_ptr implémente une propriété partagée via un bloc de contrôle alloué sur le tas, où le déléteur est effacé et stocké séparément de l'objet shared_ptr.

Cette différence architecturale entraîne des caractéristiques de taille distinctes. Un std::unique_ptr avec un déléteur sans état occupe exactement le même espace qu'un pointeur brut grâce à l'Optimisation de Base Vide. En revanche, std::shared_ptr maintient une taille constante (typiquement deux pointeurs) indépendamment de la taille ou de la complexité du déléteur, car le déléteur réside dans le bloc de contrôle alloué séparément.

#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // unique_ptr avec déléteur sans état : taille == taille du pointeur (8 octets sur 64 bits) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr : taille constante (16 octets) quelle que soit la nature du déléteur std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "Unique (sans état) : " << sizeof(up) << " octets "; std::cout << "Partagé (n'importe quel déléteur) : " << sizeof(sp) << " octets "; // unique_ptr avec déléteur avec état : taille plus grande (16 octets : pointeur + int + remplissage) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "Unique (avec état) : " << sizeof(up2) << " octets "; std::cout << "Partagé (avec état) : " << sizeof(sp2) << " octets "; }

Situation de la vie réelle

Une équipe de développement devait gérer des poignées de connexion de base de données héritées (void*) retournées par une API C. Ces poignées nécessitaient un nettoyage spécifique via db_disconnect() plutôt que delete. L'application créait des milliers de poignées par seconde dans des boucles serrées, rendant l'empreinte mémoire et les performances d'allocation critiques.

La première approche envisagée était une classe d'enveloppe RAII personnalisée ConnectionGuard qui stockait la poignée et appelait db_disconnect() dans son destructeur. Les avantages comprenaient un contrôle total sur l'interface et la capacité d'ajouter des méthodes spécifiques à la connexion. Les inconvénients impliquaient un code de routine important pour chaque type de ressource, la réinvention de la sémantique des pointeurs, et l'incompatibilité avec les algorithmes de la bibliothèque standard conçus pour des pointeurs intelligents.

La deuxième solution a utilisé std::shared_ptr<void> avec un déléteur lambda capturant la fonction de déconnexion. Les avantages incluaient une disponibilité immédiate en utilisant des composants standard et la capacité de partager la propriété à l'avenir si nécessaire. Les inconvénients comprenaient l'allocation de tas obligatoire pour le bloc de contrôle, la surcharge de comptage de référence atomique inadaptée à une propriété exclusive à haute fréquence, et une taille d'objet fixe de 16 octets, quelle que soit la légèreté de la poignée.

La troisième approche a employé std::unique_ptr<void, decltype(&db_disconnect)> avec un déléteur pointeur de fonction, ou de préférence un foncteur sans état. Les avantages comprenaient une absence de surcharge lors de l'utilisation de foncteurs sans état grâce à l'Optimisation de Base Vide (correspondant à la taille d'un pointeur brut de 8 octets), aucune allocation de tas, et l'expression parfaite des sémantiques de propriété exclusive. Les inconvénients comprenaient la verbosité de la signature de type et l'incapacité à changer les déléteurs à l'exécution.

L'équipe a choisi la troisième solution avec un déléteur foncteur sans état. Ce choix a éliminé entièrement les allocations de tas, réduit la taille de l'enveloppe à 8 octets, et enlevé la surcharge d'opération atomique tout en maintenant un nettoyage automatique.

Le résultat a été une réduction de 40 % de l'utilisation de la mémoire et des améliorations significatives de latence dans le système de mise en commun de connexions, atteignant la sécurité des exceptions sans compromettre les performances.

Ce que les candidats oublient souvent


Pourquoi std::unique_ptr nécessite-t-il un type complet au moment de la destruction lorsqu'il utilise le déléteur par défaut, tandis que std::shared_ptr ne le fait pas ?

Réponse : std::unique_ptr avec le déléteur par défaut appelle delete sur le pointeur géré. La norme C++ exige que delete sur un pointeur vers T ait T défini comme un type complet pour invoquer le destructeur et calculer la taille pour la désallocation. Si le destructeur de unique_ptr est instancié où T est seulement déclarée en avant, la compilation échoue. std::shared_ptr capture le déléteur (qui sait comment détruire T) au moment de la construction dans le bloc de contrôle. Comme le déléteur est effacé et stocké séparément, shared_ptr peut ensuite être détruit où T est incomplet. Cette distinction est cruciale pour l'idiome Pimpl (Pointeur vers l'Implémentation) : shared_ptr permet de cacher les détails d'implémentation dans les fichiers source tandis que unique_ptr exige soit des types complets, soit des déléteurs personnalisés explicites définis où l'implémentation est visible.


Pourquoi std::make_unique ne prend-il pas en charge des déléteurs personnalisés, et quelle est l'alternative recommandée ?

Réponse : std::make_unique (introduit dans C++14) fournit une allocation sûre contre les exceptions mais renvoie uniquement std::unique_ptr<T> ou std::unique_ptr<T[]>, qui utilisent std::default_delete. La fonction ne peut pas déduire le type de déléteur à partir des arguments car le type de déléteur doit faire partie de la signature de modèle de unique_ptr, et les fonctions de fabrication ne peuvent pas déduire implicitement des types de déléteurs personnalisés sans paramètres de modèle explicites. L'alternative recommandée est la construction directe : std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...}). Cette approche spécifie explicitement le type de déléteur dans le modèle tout en permettant une logique de nettoyage de ressource personnalisée, bien qu'elle nécessite une gestion des exceptions manuelle ou un ordre de construction soigneux pour maintenir les garanties de sécurité des exceptions.


Comment l'Optimisation de Base Vide affecte-t-elle la disposition mémoire de std::unique_ptr lors de l'utilisation de déléteurs sans état, et pourquoi cela n'est-il pas disponible pour std::shared_ptr ?

Réponse : std::unique_ptr hérite de sa classe déléteur lorsque le déléteur est de type classe. Si le déléteur ne contient aucun membre de données (sans état), C++ applique l'Optimisation de Base Vide (EBO), permettant à la sous-objet vide de base d'occuper zéro octet. Par conséquent, sizeof(std::unique_ptr<T, StatelessDeleter>) est égal à sizeof(T*), réalisant une abstraction à coût zéro. std::shared_ptr ne peut pas utiliser EBO car il doit prendre en charge l'effacement de type : tout shared_ptr du même T doit avoir la même taille indépendamment du déléteur. Par conséquent, shared_ptr stocke le déléteur dans le bloc de contrôle alloué sur le tas plutôt que dans l'objet shared_ptr lui-même. Ce design permet le polymorphisme d'exécution des déléteurs mais oblige une allocation sur le tas et empêche l'optimisation de l'espace sur la pile dont profite unique_ptr.