Tools4ever Tech Blog

Smart Pointers in C++11

Ralph Langendam - Sr. Software Engineer | 22 november 2016

Sinds C++11 is de Standard Template Library (STL) uitgebreid met meerdere zogenaamde smart pointers. Deze kunnen de ontwikkelaar helpen om het lekken van dynamisch gealloceerd geheugen te voorkomen. In deze blogpost stippen we enkele hoogtepunten van deze technologieën aan.

Smart Pointers

std::auto_ptr

De eerste incarnatie van dit principe vinden we reeds voor C++11, in de vorm van std::auto_ptr. Deze template container neemt bezit (ownership) van een stuk geheugen door het aanroepen van delete op de pointer te koppelen aan de aanroep van diens destructor.


Het op de stack instantiëren, bijvoorbeeld als


garandeert dat, zodra p uit scope raakt, het dynamische gealloceerde geheugen wordt opgeruimd door de bijbehorende aanroep van delete. Het interfacen met legacy (niet smart) code is mogelijk door de ownership vrij te geven middels de release methode.


Alle smart pointers hebben operator overloads voor de-referentie (*) en indirectie (->), zodat de gebruikelijk pointer syntax behouden kan blijven.

Het overdragen van de ownership tussen smart pointers voelt onnatuurlijk en is bovendien niet beschermd tegen excepties. Immers, zelfs zoiets onschuldigs als


kan lekken wanneer de allocatie plaatsvind voor de aanroep van f. Onder meer deze problemen kunnen worden opgelost door gebruik te maken van std::unique_ptr.

Ofschoon moderne smart pointers ook ondersteuning bieden voor arrays met diens bijbehorende new[] en delete[] operatoren, valt hun gebruik evenmin aan te raden. Voor iedere concrete use case bieden std::array of een van de vele andere STL-containers een beter alternatief.

std::unique_ptr

Eveneens in de <memory> header, vinden we deze opvolger van std::auto_ptr. Deze class definieert een move constructor, waarmee het overdragen van ownership duidelijker en veiliger wordt. Daarnaast is er een hulpfunctie std::make_unique beschikbaar om de allocatie zelf uit te besteden.


Deze hulpfunctie retourneert een r-value referentie naar een std::unique_ptr<int>. De r-value wordt gebruikt in de move constructor van p. De allocatie vindt plaats in std::make_unique, terwijl de-allocatie nog steeds plaatsvindt in de destructor van std::unique_ptr.

In tegenstelling tot std::auto_ptr kan een std::unique_ptr niet worden gekopieerd. De overdracht van ownership moet daardoor expliciet gemaakt worden.

std::shared_ptr

Smart Pointers C++Vaak echter moet data gedeeld worden tussen meerdere objecten. In veel gevallen kan er volstaan worden met referenties. Echter, in sommige gevallen dient de ownership gedeeld te worden tussen onafhankelijke componenten en mag een object niet gedealloceerd worden tenzij beide componenten hun referentie opgeven. Deze use cases worden afgedekt door std::shared_ptr.

De gedeelde verantwoordelijkheid wordt intern bijgehouden door een reference counter. Met iedere kopie van een std::shared_ptr wordt deze teller opgehoogd, terwijl deze door iedere destructie juist wordt verlaagd. Wanneer een destructor bemerkt de laatste referentie vast te houden draagt deze ook zorg voor de de-allocatie.

Net als std::unique_ptr, heeft std::shared_ptr een hulpfunctie std::make_shared die zorg draagt voor de allocatie en constructie van het gedeelde object. Daarnaast kan een std::shared_ptr move-constructed worden uit een std::unique_ptr. Omdat een std::unique_ptr geen reference counter bij hoeft te houden zal de std::shared_ptr constructor deze alsnog moeten aanmaken. Aangezien std::make_shared hier wel rekening mee houdt is deze derhalve efficiënter.

std::enable_shared_from_this

Ofschoon een object geen ownership over zichzelf mag hebben, kan het uitdelen van ownership wel gedelegeerd worden aan het object zelf. Ongeacht de component die de ownership consumeert kan de productie van ownership op een plek worden vastgelegd. Wanneer een object uitsluitend als std::shared_ptr gebruikt wordt kan delegatie plaatsvinden door af te leiden van std::enable_shared_from_this.

smart pointers c++ - weak_ptr

std::weak_ptr

We beschouwen nu de volgende code:


Omdat parent de gedeelde ownership van zichzelf bezit, zal de reference count van deze Node altijd positief blijven. Hierdoor wordt de Node nooit gedealloceerd en hebben we een duidelijk geheugenlek te pakken. Smart pointers op zich geven dus geen garantie op het voorkomen van geheugenlekken. Ze bemoeilijken dit louter.

Cyclische afhankelijkheden, zoals hierboven, kunnen theoretisch gezien altijd omzeild worden, maar dit leidt meestal tot meer complexe code door een extra abstractie laag. In het geval van std::shared_ptr instanties is de oplossing eenvoudig: verzwak de ownership tot die van een std::weak_ptr.

Net als std::shared_ptr is std::weak_ptr een smart pointer. Beide classes hebben notie van een strong en weak reference count. Daar waar een strong reference count destructie van de resource verhinderd is dit niet het geval voor weak references. Indien nodig kunnen std::weak_ptr instanties gepromoveerd worden tot std::shared_ptr instanties om de ownership te delen.


std::weak_ptr kan worden gebruikt om cyclische afhankelijkheden, zoals die voor Node, open te breken.


De weak referentie kan nu niet langer verhinderen dat de Node wordt opgeruimd zodra parent uit scope raakt. In het onderhavige geval is het probleem evident en op slag duidelijk. In de praktijk echter, is het herkennen van dergelijke cyclische afhankelijkheden een notoire moeilijkheid, omdat zij meestal verspreid zijn over meerdere modules. Ofschoon std::weak_ptr altijd een eenvoudige lokale oplossing voor de problematiek kan bieden is het niet onverstandig om af en toe ook eens kritisch naar de architectuur te kijken.

Smart pointer casting

Smart pointer castingVooropgesteld, casting is een code smell en dient zo veel mogelijk vermeden te worden door het verbeteren van de architectuur. Echter, om direct en makkelijk gebruik te kunnen maken van smart pointers, ook in legacy code, zijn drie van de vier casting operatoren ook voor std::shared_ptr geïmplementeerd.

std::dynamic_pointer_cast, std::const_pointer_cast en std::static_pointer_cast zijn het std::shared_ptr equivalent van dynamic_cast, const_cast en static_cast. Diens signatuur noopt tot het volgende voor de hand liggend gebruik.


Het moge duidelijk zijn dat de noexcept garantie alleen afgegeven kan worden indien de pointer waarden nog valide zijn. Het is daarom onzinnig casting operatoren te specificeren voor std::weak_ptr instanties.

Voor std::unique_ptr is de situatie subtieler. Aangezien er ten aller tijde slechts een object ownership over de resource heeft is de semantiek van casting ambigu. Immers, wanneer een dynamic_cast mislukt wordt normaal gesproken een nullptr geretourneerd. De moved pointer zou derhalve gedealloceerd moeten worden; in veel gevallen zou dit een buitengewoon onplezierig neveneffect zijn. In algemene zin kunnen we stellen dat dergelijke casting alleen zinvol is voor kopieerbare types.

Er is nog een bijkomende subtiele complexiteit die het casten van std::unique_ptr's onmogelijk maakt. De custom deleter specificatie staat namelijk toe dat een std::unique_ptr geen ownership hoeft te hebben over het gerefereerde type. Later meer hierover.

 

aliasling constructorsAliasing constructors

Een van de constructors van std::shared_ptr heeft een wat eigenaardige signatuur.


Deze constructor laat de nieuwe instantie participeren in de ownership van owned, maar retourneert daarentegen de onbeheerde pointer stored. De meest voorkomende use case van deze constructie doet zich voor wanneer stored een onderdeel van de beheerde owned pointer is en daarmee van diens bestaan afhankelijk is.


Bovenstaande constructie zorgt ervoor dat de std::set<int> niet eerder wordt gedealloceerd dan dat ook alle referenties naar het element 9 zijn opgeruimd. value kan hiermee voorkomen worden een dangling pointer te worden.

Er doen zich nu twee interessante randgevallen voor, ieder met hun eigen use cases.

owned = nullptr

Net als een default geconstrueerde std::shared_ptr zal deze instantie geen reference counter bijhouden, maar wel refereren naar een raw pointer. Dit is bijvoorbeeld handig als een interface gedeelde ownership vereist maar ervan buitenaf bezien geen reden is om het te delen object dynamisch te alloceren.


De scope van values is (kleiner of) gelijk aan de scope van value en dus is reference counting niet nodig. De overhead van het hebben van reference counting kan in sommige gevallen significant zijn; zeker voor de meeste overloads van std::atomic_…<std::shared_ptr>.

stored = nullptr

Deze constructie staat symbool voor een situatie waarin een resource moet worden vastgehouden, maar er verder geen interface naar buiten toe zichtbaar hoeft te zijn of juist verborgen moet worden.


De interfaces van std::mutex en std::lock_guard instanties zijn afgeschermd door lock.

Meer algemeen kunnen we stellen dat std::shared_ptr niets met de stored pointer doet, anders dan deze derefereren of teruggeven via std::shared_ptr::get. De C++ standaard verbiedt void&, dus het is niet mogelijk om een std::shared_ptr<void> te derefereren. Dit geeft ons de mogelijkheid om de stored pointer te gebruiken voor andere doeleinden. Met een platform afhankelijke breedte van std::size_t geeft dit diverse mogelijkheden.

Bijvoorbeeld, gegeven een klok waarvan de interne durations een representatie hebben die hoogstens zo groot is als std::size_t, dan kunnen we als volgt eenvoudig een heterogene FIFO-cache maken.


De elementen in cache_ zijn chronologische geordend en daardoor eenvoudig van retentiebeleid te voorzien.

Custom deleters

Vanuit onder meer het welbekende Pointer to Implementation (PImpl) idioom weten we dat 'pointer naar T'-fields in een class definitie slechts een forward declaratie van het type T behoeven. Dit stelt ons in niet alleen in staat om afhankelijkheden tussen header files te reduceren, maar ook om de implementatie details van een class te verbergen voor de buitenwereld door deze naar een source file te verplaatsen.

In combinatie met smart pointers kan ons dit een hoop boilerplate code schelen. Hiervoor is het echter wel essentieel dat alle operaties die een complete definitie van het type behoeven niet door de header file sijpelen. Voor de std::unique_ptr template impliceert dit een vereist uitstellen van destructor definitie tot het complete type beschikbaar is.


De reden hiervoor is simpel. Iedere translation unit waarin de definitie van Complete benodigd is, wordt de destructor impliciet ge-inlined en is een definitie noodzakelijk. De definitie van de destructor van std::unique_ptr<Incomplete> vereist echter een definitie van std::default_delete<Incomplete>. Op zijn beurt behoeft std::default_delete<T> een compile-time constante waarde voor sizeof(T). Zonder definitie van Incomplete is ook sizeof(Incomplete) onbekend.

Door de implementatie van de Complete destructor naar de source file te verplaatsen wordt PImpl'en weer mogelijk als daar ook een definitie van Incomplete zichtbaar is. Voor std::shared_ptr instanties vormt een incompleet type geen probleem, omdat de custom deleter (net als de reference counters) onderdeel is van een PImpl idioom toegepast binnen std::shared_ptr zelf.

std::unique_ptr heeft twee template argumenten. Het tweede argument heeft een default waarde wiens instanties een unaire operator voor een delete statement voor het onderhavige type specificeren.


Een std::unique_ptr<T, D> verwijst naar een remove_reference<D>::type::pointer. Alleen wanneer deze niet bestaat wordt er gebruik gemaakt van T*. Dit gebeurd bijvoorbeeld voor std::default_delete. De custom deleter heeft de volledige vrijheid om de pointer semantiek van std::unique_ptr aan te passen. Dit is bijvoorbeeld handig bij implementatie van het Façade design pattern.

Scoped Pointers

Om naar de lezer van code uit te dragen dat een resource niet bedoeld is om een scope te verlaten kan men gebruik maken van boost::scoped_ptr. Deze ontbeert niet alleen de move semantiek van std::unique_ptr, maar staat bovendien ook niet toe dat er een custom deleter wordt gespecificeerd. De-allocatie aan het einde van de scope is derhalve gegarandeerd.

Echter, aangezien de custom deleter duidelijk zichtbaar is aan de type instantie van std::unique_ptr en de move semantiek kan worden uitgeschakeld door constness te introduceren verliest boost::scoped_ptr zijn toegevoegde waarde. Het advies is dus om std::unique_ptr<T> const als alternatief voor boost::scoped_ptr<T> te gebruiken. Eventueel kan een alias template hierin de leesbaarheid verhogen.

Resource Acquisition is Initialization

resouce acquisitionAlle smart pointers zijn voorbeelden van Scope Based Resource Management (SBRM). Ofwel, allocatie bij constructie en de-allocatie bij destructie. In algemene zin is SBRM een voorbeeld van het Resource Acquisition Is Initialization (RAII) idioom, waarbij het niet langer alleen maar draait om dynamische allocaties, maar meer algemeen om 'resources'.

Het locken en unlocken van een mutex is dikwijls aan scope gebonden. Wanneer er een exceptie gegooid wordt terwijl een mutex gelocked is moet er zorg voor gedragen worden dat de lock weer los wordt gelaten alvorens de scope te verlaten. Net als bij new / delete, leent ook lock / unlock zich voor delegatie van verantwoordelijkheid naar een toegewijd stack object: een RAII container.

Voor classes die het BasicLockable concept implementeren, zoals de STL mutexen (std::mutex, std::recursive_mutex, std::timed_mutex en std::recursive_timed_mutex), zijn std::lock_guard en std::unique_lock de bijbehorende standaard RAII containers voor dit doel. N.B. Alleen indien de variadische vorm van std::lock_guard gebruikt wordt is het Lockable concept vereist.

Op hoger niveau wordt momenteel discussie gevoerd over uitbreiding van de C++ standaard met een abstract RAII-container type in STL. Zie ook "N3949 - Scoped Resource - Generic RAII Wrapper for the Standard Library". Tot die tijd echter kan een constructie als de volgende soelaas bieden in alle situaties.


Deze container kan nu bijvoorbeeld gebruikt worden om een database connectie te beheren.