C++ProgrammationDéveloppeur C++

Pourquoi la déclaration par défaut d'un destructeur dans la classe supprime-t-elle les opérations de déplacement implicites, même si le destructeur lui-même est trivial ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique : Dans C++98, la gestion des ressources suivait la Règle des Trois : si une classe avait besoin d'un destructeur personnalisé, d'un constructeur de copie ou d'un opérateur d'assignation par copie, il était probable qu'elle ait besoin des trois. Lorsque C++11 a introduit les sémantiques de déplacement, cela est devenu la Règle des Cinq, ajoutant le constructeur de déplacement et l'opérateur d'assignation par déplacement. Le comité de normalisation a choisi une approche conservatrice : déclarer un destructeur (même trivial) inhibe la génération implicite des opérations de déplacement pour éviter les mouvements accidentels de ressources gérées par des destructeurs.

Problème : Lorsque vous écrivez ~MyClass() = default; à l'intérieur de la définition de la classe, vous créez un destructeur "déclaré par l'utilisateur". Selon la norme C++ ([class.copy.ctor]/3), cette présence supprime la déclaration implicite du constructeur de mouvement et de l'opérateur d'assignation par mouvement. En conséquence, le compilateur traite la classe comme étant uniquement copiée, revenant silencieusement aux sémantiques de copie coûteuses lors des réallocations de std::vector ou des optimisations de retour par valeur, même si le destructeur ne fait aucun travail réel.

Solution : Pour maintenir la génération implicite de déplacement, déclarez le destructeur uniquement à l'intérieur de la classe et fournissez la définition par défaut à l'extérieur :

class Optimized { public: ~Optimized(); // Seulement déclaré ici std::array<char, 4096> buffer; }; Optimized::~Optimized() = default; // Défini à l'extérieur

Cela rend le destructeur "fourni par l'utilisateur" mais pas "déclaré par l'utilisateur" au moment où le compilateur décide de générer des mouvements. Alternativement, vous pouvez explicitement définir par défaut les cinq membres spéciaux, ou de préférence suivre la Règle des Zéro en remplaçant les ressources brutes par std::unique_ptr ou des conteneurs.

Situation vécue

Nous avons rencontré cela dans un moteur de trading haute fréquence traitant des objets MarketDataPacket. La classe contenait un tampon fixe de 4 Ko pour les données réseau :

class MarketDataPacket { public: ~MarketDataPacket() = default; // Écrit dans l'en-tête pour "clarté" char buffer[4096]; };

Après être passé à C++11, le profilage de latence a révélé que 40 % des cycles CPU étaient dépensés dans memcpy malgré le retour des paquets par valeur. Le coupable était le destructeur par défaut dans la classe, qui a involontairement supprimé les mouvements implicites et forcé des copies lors de la croissance de std::vector et des retours de fonctions.

Solution 1 : Déclarez explicitement le constructeur de mouvement et l'assignation comme noexcept. Cela résout immédiatement le problème de performance en permettant des mouvements. Cependant, cela nécessite de maintenir manuellement ces fonctions lors de l'ajout de membres, risque des incompatibilités de spécification d'exception si des pointeurs bruts sont impliqués, et ajoute du code encombrant qui viole la Règle des Zéro.

Solution 2 : Déplacez la définition du destructeur dans le fichier .cpp avec MarketDataPacket::~MarketDataPacket() = default;. Cela restaure les mouvements générés par le compilateur tout en gardant le destructeur trivial. Cela maintient une abstraction sans surcharge et permet des optimisations du compilateur comme l'élimination des appels de destructeur pour les objets inutilisés. Le seul inconvénient est le besoin d'une unité de compilation séparée, ce qui était acceptable.

Solution 3 : Remplacez le tampon brut par std::vector<uint8_t> ou std::unique_ptrstd::byte[]. Cela atteint une conformité parfaite à la Règle des Zéro. Cependant, cela introduit une indirection ou une surcharge d'allocation sur le tas inacceptable dans les chemins de trading sensibles aux microsecondes où la localité de cache est cruciale.

Nous avons sélectionné Solution 2. En déplaçant le paramétrage en dehors de la classe, nous avons restauré les mouvements implicites, réduit la latence de traitement des paquets de 12μs à 3μs, et maintenu une destructibilité triviale permettant des optimisations agressives du compilateur.

Ce que les candidats oublient souvent

Pourquoi le compilateur distingue-t-il entre une déclaration interne et externe par défaut alors que la sémantique est identique ?

La différence est syntaxique, pas sémantique. C++ utilise un modèle d'analyse à passage unique pour les définitions de classes. Lorsque le compilateur atteint la accolade fermante de la classe, il doit décider s'il génère ou non des opérations de déplacement implicites. S'il voit = default à l'intérieur, le destructeur est "déclaré par l'utilisateur" à ce moment-là, déclenchant les règles de suppression selon [class.copy]/7. Le compilateur ne peut pas "regarder en avant" vers la définition extérieure pour changer cette décision. C'est une contrainte fondamentale du modèle de compilation de C++.

Le fait de marquer le destructeur comme noexcept restaure-t-il les mouvements implicites ?

Non. La suppression de la génération de mouvements implicites dépend uniquement du fait que le destructeur soit déclaré par l'utilisateur, et non de sa spécification d'exception. Bien que marquer les mouvements comme noexcept soit crucial pour qu'ils soient utilisés dans les réallocations de std::vector, ajouter simplement noexcept à un destructeur par défaut à l'intérieur de la classe ne restaure pas les opérations de déplacement supprimées. Vous devez soit déplacer la définition à l'extérieur, soit définir explicitement les mouvements par défaut.

Comment un destructeur déclaré par l'utilisateur affecte-t-il l'initialisation agrégée ?

Une classe avec un quelconque destructeur déclaré par l'utilisateur cesse d'être un agrégat. Cela est souvent plus perturbant que de perdre des mouvements. Cela signifie perdre les initialisateurs désignés (C++20) et la capacité d'utiliser des listes d'initialisation entre accolades sans constructeurs explicites. De nombreux développeurs s'attendent à ce que l'initialisation agrégée fonctionne et sont surpris quand elle échoue :

struct Config { ~Config() = default; // Enfreint l'agrégation int value; }; // Config c{42}; // Erreur : aucun constructeur correspondant

Cela se produit parce que la présence d'un destructeur déclaré par l'utilisateur force la classe à avoir des sémantiques de destruction non triviales dans le système de types, la disqualifiant du statut d'agrégat indépendamment de la complexité réelle.