Geschiedenis: In C++98 volgde het resource management de Regel van Drie: als een klasse een aangepaste destructor, copy constructor of copy assignment operator nodig had, had deze waarschijnlijk alle drie nodig. Toen C++11 verplaatsingssemantiek introduceerde, werd dit de Regel van Vijf, met daar aan toegevoegd de move constructor en move assignment. De standaardcommissie koos een conservatieve benadering: het declareren van een destructor (zelfs triviale) verhindert de impliciete generatie van verplaatsingsoperaties om ongepaste ondiepe verplaatsingen van resources beheerd door destructors te voorkomen.
Probleem: Wanneer je ~MyClass() = default; binnen de klasse-definitie schrijft, creëer je een "door de gebruiker gedeclareerde" destructor. Volgens de C++ standaard ([class.copy.ctor]/3) onderdrukt deze aanwezigheid de impliciete declaratie van zowel de move constructor als de move assignment operator. Bijgevolg beschouwt de compiler de klasse als alleen-copy, en valt deze stilletjes terug op kostbare copy semantiek tijdens std::vector herallocaties of return-by-value optimalisaties, ook al voert de destructor geen daadwerkelijk werk uit.
Oplossing: Om impliciete verplaatsingsgeneratie te behouden, declareer je de destructor alleen binnen de klasse en bied je de standaarddefinitie buiten aan:
class Optimized { public: ~Optimized(); // Alleen hier gedeclareerd std::array<char, 4096> buffer; }; Optimized::~Optimized() = default; // Buiten gedefinieerd
Dit maakt de destructor "door de gebruiker provided" maar niet "door de gebruiker gedeclareerd" op het moment dat de compiler beslist om verplaatsingen te genereren. Als alternatief kun je expliciet alle vijf speciale leden standaardiseren, of bij voorkeur de Regel van Nul volgen door ruwe resources te vervangen door std::unique_ptr of containers.
We kwamen dit tegen in een high-frequency trading engine die MarketDataPacket objecten verwerkte. De klasse had een vaste 4KB buffer voor netwerkinformatie:
class MarketDataPacket { public: ~MarketDataPacket() = default; // Geschreven in header voor "helderheid" char buffer[4096]; };
Na de migratie naar C++11 onthulde latency-profiler dat 40% van de CPU-cycli werd besteed aan memcpy ondanks het retourneren van pakketten bij waarde. De schuldige was de binnen-klas standaard destructor, die per ongeluk impliciete verplaatsingen had verwijderd en gedwongen kopieën tijdens std::vector groei en functie-retouren.
Oplossing 1: Verklaar bewust noexcept move constructor en assignment. Dit lost het prestatieprobleem onmiddellijk op door verplaatsingen mogelijk te maken. Het vereist echter handmatige onderhoud van deze functies bij het toevoegen van leden, brengt risico's van uitzondering specificatie mismatches met zich mee als ruwe pointers betrokken zijn, en voegt boilerplate toe die de Regel van Nul schendt.
Oplossing 2: Verplaats de destructor definitie naar het .cpp bestand met MarketDataPacket::~MarketDataPacket() = default;. Dit herstelt de door de compiler gegenereerde verplaatsingen terwijl de destructor triviaal blijft. Het behoudt nul-overhead abstractie en stelt compileroptimalisaties mogelijk zoals het weglaten van destructor-aanroepen voor ongebruikte objecten. De enige nadeel is dat een aparte compilatie-eenheid vereist is, wat acceptabel was.
Oplossing 3: Vervang de ruwe buffer door std::vector<uint8_t> of std::unique_ptrstd::byte[]. Dit bereikt perfecte naleving van de Regel van Nul. Echter, dit introduceert indirectie of heap allocatie overhead die onbevredigend is in microseconde gevoelige handelsroutes waar cache localiteit cruciaal is.
Wij hebben Oplossing 2 gekozen. Door de standaardisatie naar buiten de klasse te verplaatsen, herstelden we impliciete verplaatsingen, verminderden we de verwerking van pakkettenlatentie van 12μs naar 3μs en behielden we triviale destructibiliteit, wat agressieve compileroptimalisaties mogelijk maakte.
Waarom maakt de compiler onderscheid tussen in-class en out-of-class standaardisatie wanneer de semantiek identiek is?
Het verschil is syntactisch, niet semantisch. C++ gebruikt een enkelvoudige parsingmodel voor klasse-definities. Wanneer de compiler de sluitingshaak van de klasse bereikt, moet hij beslissen of hij impliciete verplaatsingsoperaties genereert. Als hij = default binnenin ziet, is de destructor op dat moment "door de gebruiker gedeclareerd", wat de onderdrukkingregels activeert volgens [class.copy]/7. De compiler kan niet "vooruit kijken" naar de buitenste definitie om deze beslissing te veranderen. Dit is een fundamentele beperking van het compilatiemodel van C++.
Herstelt het markeren van de destructor als noexcept de impliciete verplaatsingen?
Nee. De onderdrukking van impliciete verplaatsingsgeneratie is uitsluitend afhankelijk van of de destructor door de gebruiker is gedeclareerd, niet op basis van de exception specificatie. Hoewel het markeren van verplaatsingen als noexcept cruciaal is voor gebruik in std::vector herallocaties, brengt het enkel toevoegen van noexcept aan een standaard destructor binnen de klasse de verwijderde verplaatsingsoperaties niet terug. Je moet ofwel de definitie naar buiten verplaatsen of de verplaatsingen expliciet standaardiseren.
Hoe beïnvloedt een door de gebruiker gedeclareerde destructor aggregeert initialisatie?
Een klasse met een door de gebruiker gedeclareerde destructor verliest zijn status als aggregate. Dit is vaak verstorender dan het verliezen van verplaatsingen. Dit betekent het verlies van aangewezen initializers (C++20) en het vermogen om accolade-ingesloten initialisatielijsten te gebruiken zonder expliciete constructors. Veel ontwikkelaars verwachten dat aggregaatinitialisatie werkt en zijn verrast wanneer het mislukt:
struct Config { ~Config() = default; // Breekt aggregatie int value; }; // Config c{42}; // Fout: geen overeenkomstige constructor
Dit gebeurt omdat de aanwezigheid van een door de gebruiker gedeclareerde destructor de klasse dwingt om niet-triviale vernietigingssemantiek in het typesysteem te hebben, waardoor deze niet voldoet aan de status van aggregate ongeacht de werkelijke complexiteit.