
smart pointers.. MA, PERCHè?
Prima di vedere come sono fatti, cerchiamo di capire quale sia la ratio degli smart pointers, perchè esistono, a cosa servono e perchè possono aiutare enormemente a ridurre la complessità intrinseca della manipolazione diretta della memoria dinamica del C++.
Il C++, come noto, è un linguaggio concepito inizialmente per essere un’estensione del C dal quale pertanto, ne eredita gran parte della sintassi e dei comportamenti a runtime.
Una delle caratteristiche del C, portate in dote nel C++, è sicuramente la gestione esplicita della memoria dinamica o heap, con la conseguente grande responsabilità lasciata nelle mani dello sviluppatore.
HEAP CORRUPTION
A differenza per esempio di linguaggi come il Java o il GO che possiedono uno heap garbage collector, in C e C++ occorre esplicitamente rilasciare tutta la memoria acquisita dinamicamente ed occorre farlo nel momento giusto.
Una cosa da fare assolutamente è di evitare che i riferimenti a memoria precedentemente rilasciata restino accessibili da parti del programma ancora in esecuzione.
La non osservanza di una scrupolosa disciplina quando si manipola direttamente lo heap, induce quasi automaticamente a bugs che causano, nel migliore dei casi la terminazione del processo in esecuzione per memory violation oppure, nel peggiore dei casi la sopravvivenza del processo con la produzione di outputs casuali.
Il programmatore C e C++ smaliziato, sa bene quanto sia complicato e frustrante trovare la causa di una heap corruption nel suo programma.
La difficoltà principale quando si corrompe lo heap è che il processo può proseguire nella sua esecuzione e terminare in maniera anomala anche molto tempo dopo che la corruzione abbia avuto effettivamente luogo.
In questi casi, l’unico modo davvero efficace per trovare l’origine di un bug di heap corruption è quello di appoggiarsi a strumenti di code instrumentation come valgrind oppure Dr. Memory.
UN ESEMPIO DI HEAP CORRUPTION
Ma in che modo possiamo generare una heap corruption con del codice C++ ?
Diamo un’occhiata a questo esempio:
#include <iostream> #include <thread> #include <random> std::default_random_engine generator; std::uniform_int_distribution<int> distribution(1,10); int main() { //we allocate on the heap an integer, we are entirely responsible for this allocation, //if we forget to release this memory it will be lost forever. //There is no garbage collection in C++. int *dangling_ptr = new int(0); //we start an asynchronous thread of execution that accesses the allocated integer after //a random period of time. std::thread dumb_thread([&](){ int secs = distribution(generator); std::this_thread::sleep_for(std::chrono::seconds(secs)); *dangling_ptr = secs; }); //after spawning the child thread, main thread also wait a random period of time //before continuing. int secs = distribution(generator); std::cout << "main wait for:" << secs << std::endl; std::this_thread::sleep_for(std::chrono::seconds(secs)); //we check dangling_ptr, if the integer it points to is still zero, this means that dumb_thread //is still sleeping. if(*dangling_ptr){ std::cout << "child thread waited for:" << *dangling_ptr << "seconds" << std::endl; }else{ std::cout << "child thread still waiting" << std::endl; } //here we take care of the previuos allocated memory and we ask the //heap to release the resource. delete dangling_ptr; //we wait for dumb_thread to finish his execution. dumb_thread.join(); }
Il programma alloca un intero sullo heap accessibile mediante un puntatore: dangling_ptr
e subito dopo fa partire un thread: dumb_thread
che alla fine di un’attesa casuale tra 1 e 10 secondi accede a dangling_ptr
e ne modifica il valore puntato.
Nel frattempo, mentre dumb_thread
è fermo nella sleep, anche il main thread comincia ad attendere casualmente tra 1 e 10 secondi, per poi successivamente, controllare il valore puntato da dangling_ptr
e stampare qualcosa a video.
Alla fine, il main thread dealloca l’intero che aveva acquisito all’inizio e attende la terminazione del thread figlio prima di uscire.
RACE CONDITION
Il problema principale di questo codice è che non sappiamo a priori quanto tempo attenderanno nella sleep rispettivamente il main thread e dumb_thread
, se siamo molto fortunati il thread main attenderà più di dumb_thread
e probabilmente accederà alla memoria puntata da dangling_ptr
prima che questa sia rilasciata dal main mediante l’operatore delete
.
Tuttavia nello scenario in cui dumb_thread
attende più del main, l’istruzione*dangling_ptr = secs;
risulta invalida, perchè rappresenta un accesso ad una porzione di memoria non più disponibile nello spazio di indirizzamento (valido) del processo in esecuzione.
In questo esempio sappiamo per certo che l’istruzione di assegnamento causerà la terminazione del processo per memory violation; ma in un programma reale non possiamo essere certi che nel frattempo un’altra allocazione non sia avvenuta e che magari, l’allocatore abbia deciso di riusare proprio la memoria che nel frattempo avevamo rilasciato.
In questo caso, forse, non avremmo la terminazione del processo immediatamente, ma magari otterremmo qualche altro srampalato comportamento di cui non riusciremmo a comprendere realmente la causa fintanto che non avessimo capito davvero che c’è stato un accesso ad una porzione di memoria logicamente invalida.
CHE SI PUÒ FARE?
Esempi come quello sopra, nei quali cioè, è ancora abbastanza semplice capire il bug e cosa lo sta causando, non sono purtroppo la normalità nei programmi reali che spesso sono composti da migliaia di righe di codice che linkano le più svariate librerie.
INDUSTRIAL DESIGN
Consideriamo inoltre che in ambito industriale, la normalità, sono i programmi scritti e mantenuti da ben più di uno sviluppatore; un programma reale in ultima analisi è un oggetto molto complesso e capire le cause di un bug di heap corruption in questi casi può essere estremamente costoso sia in termini di risorse umane che di tempo impiegato.
A complicare le cose, inoltre, resta il fatto che una volta capita la causa, spesso la correzione di un bug di heap corruption mette in discussione l’intero disegno progetturale esistente di una soluzione software; quello che si fa davvero nella realtà è che si cerca di tamponare il problema, aggiungendo qualche controllo in più quà e là nel codice, con la speranza di mitigare la situazione.
Appurato che per i nostri programmi che ormai sopravivvono da anni in produzione non è banale introdurre nuove metodologie o stravolgere il progetto originale, quello che possiamo fare è cercare di guardare con più ottimismo al futuro e provare ad adottare, quando possiamo, qualche accorgimento che può renderci la vita più semplice e perchè no, anche più divertente.
sMART POINTERS!
Gli smart pointers rappresentano una evoluzione significativa rispetto ai classici raw pointers
in stile C e forniscono un modello di controllo della memoria dinamica realmente efficace, senza dover pagare il costo di un garbage collector in esecuzione nel nostro programma.
L’idea vincente di uno smart pointer è che rispetto al classico raw pointer contiene anche l’informazione necessaria per capire quando la risorsa in esso contenuta può essere rilasciata.
A prima vista può sembrare poco, ma in realtà la differenza è sostanziale.
UN CAMBIO DI PARADIGMA
Un raw pointer non possiede nessun’altra informazione se non il valore che denota un indirizzo nello spazio di indirizzamento riservato allo heap.
Qualunque scope all’interno del nostro programma che abbia accesso ad un raw pointer a memoria dinamica deve porsi la domanda se, una volta che la abbia utilizzata, debba o meno rilasciare tale memoria.
Non importa quanto bene possiamo scrivere i nostri programmi, sarà molto difficile, se non impossibile, coprire tutti i possibili percorsi di accesso del nostro raw pointer.
Questa affermazione è particolamente vera nel momento in cui il nostro programma cresce in complessità e sopratutto quando cominciamo a condividere memoria allocata tra più di un thread.
Gli smart pointer risolvono il problema della deallocazione della memoria ad un costo computazionalmente accettabile e sopratutto quantificabile (cosa non vera quando si utilizza la garbage collection), perchè contengono in essi l’informazione per capire che all’uscita da uno scope, nessun altro scope potrà più accedere alla memoria puntata e che quindi si rende necessario il rilascio della stessa.
MOVE AHEAD!
Nel precedente articolo: Uno sguardo alla semantica di move nello standard 11 del C++ abbiamo descritto brevemente che cosa si intende per semantica di move e perchè può essere interessante sapere quantomeno che esiste anche questa possibilità per il programmatore C++.
Dicevamo, in conclusione dell’articolo, che una delle conseguenze più interessanti della semantica di move del C++ standard 11 è la possibilità di definire tipi move-only, per i quali cioè, la possibilità di essere copiati è stata del tutto vietata.
EXCLUSIVE OWNERSHIP
Un tipo move-only, guarda caso, è proprio uno smart pointer: std::unique_ptr
che è stato pensato per risolvere il problema dell’exclusive ownership – in italiano: possesso esclusivo – di un puntatore a memoria dinamicamente acquisita.
Possedere un puntatore, implica che la responsabilità della chiamata al distruttore del tipo del puntatore e al conseguente rilascio delle risorse, qualunque esse siano, sia prerogativa esclusiva dello scope nel quale l’unique pointer vive.
Vediamo un esempio concreto:
{ //scope begin std::unique_ptr<int> uiptr(new int(0)); *uiptr = 21; std::cout << *uiptr << std::endl; //scope end }
Abbiamo uno scope: {..} ed in questo vive il nostro unique pointer, non dobbiamo preoccuparci di altro, il compilatore infatti, chiamerà per noi il distruttore dell’oggetto uiptr
che internamente chiamerà l’operatore di rilascio opportuno per il puntatore allocato, nell’esempio sopra l’operatore chiamato sarà ovviamente delete<int>
.
NON È LA RAII (O MEGLIO: NON SOLO!)
Questa tecnica in realtà non è poi così rara nella programmazione C++, infatti, la si trova indicata come Resource Acquisition Is Initialization o brevemente RAII ed è una tecnica utilizzabile in ogni versione del linguaggio sin dalle sue origini.
Si richiede ovviamente che l’oggetto wrapper che incapsula una risorsa acquisita, in questo caso memoria dinamica (mediante l’operatore new<int>
), sia costruito come una variabile automatica direttamente nello scope; solo così il compilatore ci garantisce che ad ogni possibile uscita dallo scope stesso, il distruttore dell’oggetto RAII (quindi il wrapper) sia effettivamente chiamato.
Per essere ancora più espliciti, utilizzando l’unique pointer, il codice che segue è ancora perfettamente sicuro:
{ //scope begin std::unique_ptr<int> uiptr(new int(0)); *uiptr = 21; if(*uiptr < 100) { return; } std::cout << *uiptr << std::endl; //scope end }
L’uscita forzata dallo scope mediante return
copre anch’essa la chiamata al distruttore di uiptr
e sarebbe lo stesso anche se l’uscita dallo scope fosse determinata da una throw
.
La potenza del pattern RAII in C++ è dovuta dalla copertura totale dello scope, garantita dal compilatore, senza il bisogno che sia il programmatore a dover coprire esplicitamente tutte le possibili uscite.
NON È UN MIO PROBLEMA.. o FORSE SI?
Se pensate che questo problema non vi appartenga perchè siete super scrupolosi, alzi la mano chi non si è mai dimenticato di fare una free
o una unlock
in un suo programma!
Gli unique pointer sono stati introdotti dallo standard 11 del C++, ma la loro potenza non si limita al fatto di essere oggetti RAII, possiedono infatti, il grande pregio di supportare completamente la semantica di move.
PALEO SMART POINTERS
Prima di std::unique_ptr
lo standard 98 metteva a disposizione std::auto_ptr
che però, fu disegnato in un linguaggio in cui la semantica di move ancora non esisteva e questo ha portato ad un difetto di forma (e di sostanza!) importante: le operazioni di move per std::auto_ptr
sono state implementate per cooptazione con l’operatore di copia.
La prima ed ovvia conseguenza di questa scelta forzosa è che copiare uno std::auto_ptr
significa impostarlo a null.
Questa deficienza ha anche il non trascurabile effetto di rendere std::auto_ptr
non utilizzabile nei containers della standard library ed è il motivo per cui a partire dallo standard 11 del linguaggio è stato deprecato in favore di std::unique_ptr
.
Va bene, dimentichiamoci del passato e diamo un’occhiata al prossimo esempio:
{ //scope begin std::unique_ptr<int> uiptr(new int(0)); std::unique_ptr<int> uiptr_2 = uiptr; //scope end }
Qual’è l’effetto del codice?
Si potrebbe pensare, come è naturale, che uiptr_2
prenda il possesso della risorsa e che uiptr
sia stato invalidato (impostato a null); ma se provassimo a compilarlo, ci accorgeremmo che il compilatore rifiuterebbe questo codice!
Ma, perchè?
Semplice, perchè uiptr
è un lvalue e come sappiamo dalla semantica di move, gli lvalue sono designati per le operazioni di copia, non per le operazioni di move e guarda caso, std::unique_ptr
è un tipo move-only!
Se vogliamo muovere uiptr
in uiptr_2
dobbiamo passare necessariamente un rvalue, non un lvalue e pertanto, l’unico modo che abbiamo per convincere il compilatore che vogliamo muovere un lvalue è forzarlo ad un rvalue mediante la funzione std::move
:
{ //scope begin std::unique_ptr<int> uiptr(new int(0)); std::unique_ptr<int> uiptr_2 = std::move(uiptr); //now it's ok, we want to move it! //scope end }
Perfetto, adesso abbiamo manifestato la nostra reale intenzione di muovere e non di copiare, e infatti, questa volta, il compilatore è felicissimo di accontentarci.
È bene, però, capire nel dettaglio perchè l’esempio senza la chiamata alla std::move
porti ad un errore di compilazione; per farlo, abbiamo bisogno di esaminare le firme degli operatori di copy e di move di std::unique_ptr
.
//move assignment unique_ptr& operator= (unique_ptr&& x) noexcept; //copy assignment (deleted!) unique_ptr& operator= (const unique_ptr&) = delete;
Come si può vedere, l’operatore di copy è stato marcato come deleted!
La possibilità di vietare esplicitamente la chiamata ad una determinata firma è una funzionalità aggiunta con lo standard 11 del linguaggio e si realizza con il suffisso = delete
.
Senza questa caratteristica sarebbe stato impossibile implementare correttamente la semantica di move.
L’UTILIZZO PRATICO DI UNIQUE POINTER
È ragionevole l’impiego di std::unique_ptr
nei contesti in cui nel nostro programma dobbiamo acquisire memoria dallo heap e vogliamo che un determinato scope ne assuma la ownership.
Potremmo, per esempio, voler implementare l’idioma PIMPL usando std::unique_ptr
:
// with std 98 //header class A { public: A(); ~A(); private: class impl; impl *pimpl; } //cpp class A::impl { .. } A::A() : pimpl(new impl()) {} A::~A() { if(pimpl) delete pimpl; } // with std 11+ class A { public: A(); ~A(); private: class impl; std::unique_ptr<impl> pimpl; } //cpp class A::impl { .. } A::A() : pimpl(new impl()) {} A::~A() = default;
Qualcuno potrebbe obiettare che si tratta solo di zucchero sintattico e forse, in parte lo è, ma l’uso di std::unique_ptr
al posto del raw pointer, rende immediatamente comprensibile al programmatore che vede per la prima volta il codice, cosa sia pimpl
e che cosa si deve aspettare una volta che un’istanza di A
sia stata distruttra.
C’è anche un aspetto più pratico: nel distruttore di A
, nello standard 98, dobbiamo ricordarci di chiamare delete
su pimpl
e non è detto che sia una cosa che faremo per tutte le nostre classi; se usiamo std::unique_ptr
il compilatore si ricorderà, tutte le volte, di chiamare il distruttore di pimpl
per noi.
OWNERSHIP TRANSFER
È altrettanto ragionevole l’utilizzo di std::unique_ptr
, nel caso in cui, dovessimo trasferire l’ownership da uno scope ad un altro.
Pensiamo, per esempio, all’interfaccia di una qualche API che stiamo disegnando: se la nostra API ci passa un puntatore a memoria allocata, dobbiamo anche informare l’utente che quel puntatore sta diventando una sua responsabilità.
Con i classici raw pointers inoltre, il solo fatto di invocare una API di questo genere, obbliga l’utente a doversi preoccupare della risorsa acquisita anche se, di quella risorsa, non ne avesse davvero bisogno.
Vediamo un esempio di una API senza l’uso di std::unique_ptr
per chiarire il concetto:
/** A result code for some API. */ enum resultCode{ resultCode_OK, resultCode_KO, .. resultCode_SOMETHING, }; /** A type representing the result of some API. */ struct heap_resource{ .. }; /** An api that returns a resultCode and sets *resource with an allocated heap_resource instance. If my_api returns resultCode_KO, then *resource is set to NULL. */ resultCode my_api(heap_resource **resource); /** An api that releases a previously aquired resource with my_api. */ void deallocate_heap_resource(heap_resource *resource); //an usage example void use_resource(const heap_resource *); int main(){ heap_resource *resource = NULL; resultCode result = my_api(&resource); switch(result) { case resultCode_OK: use_resource(resource); deallocate_heap_resource(resource); break; case resultCode_KO: break; default: deallocate_heap_resource(resource); } }
In questo esempio ci rendiamo conto che una chiamata a my_api
ci costringe ad un notevole lavoro extra per non introdurre memory leakages: dobbiamo gestire anche i casi per cui non saremmo interessati affatto a prendere in considerazione resource
, noi siamo interessati ad usare resource
nel solo caso in cui, my_api
restituisca resultCode_OK
.
Siamo costretti invece, a considerare anche i casi in cui my_api
restituisce resultCode_KO
e dobbiamo usare il default
nello switch per coprire tutti i return codes per cui, da specifica, resource
viene allocato.
LA STESSA API USANDO UNIQUE POINTER
/** A result code defined in the API. */ enum resultCode{ resultCode_OK, resultCode_KO, .. resultCode_SOMETHING, }; /** An type representing the result of some API. */ struct heap_resource{ .. }; /** An api that returns a resultCode and sets resource with an allocated heap_resource instance. If my_api returns resultCode_KO, then resource is not set. */ resultCode my_api(std::unique_ptr<heap_resource> &resource); //an usage example void use_resource(const heap_resource *); int main(){ std::unique_ptr<heap_resource> resource; resultCode result = my_api(resource); if(result == resultCode_OK){ use_resource(resource.get()); } }
La differenza è notevole: l’utente dell’API deve preoccuparsi di gestire solo il caso per cui è davvero interessato!
Inoltre, non occorre più preoccuparsi di chiamare la funzione preposta alla release di resource
perchè questo aspetto è coperto interamente da std::unique_ptr
in modo del tutto naturale.
Questo esempio mostra come la ownership transfer constd::unique_ptr
possa essere veramente potente e di come il codice che l’utente deve scrivere sia ben più chiaro e conciso rispetto alla controparte che usa un semplice raw pointer.
TIRIAMO LE SOMME
In questo articolo, abbiamo visto quali sono le complessità derivanti dal non avere una memoria dinamica automaticamente gestita dal linguaggio.
Abbiamo visto i pericoli ai quali siamo costantemente esposti se utilizziamo i raw pointer a memoria allocata e alle problematiche intrinseche di dover prevedere tutti i possibili percorsi di accesso ad un puntatore.
Abbiamo introdotto gli smart pointers e spiegato perchè rappresentano un cambio di paradigma rispetto alle classiche metodologie di controllo della memoria.
Quindi, abbiamo visto in quali casi può essere ragionevole, o molto ragionevole, impiegare std::unique_ptr
.
Tuttavia, in questo articolo abbiamo inizialmente fornito un esempio di cattiva progettazione che porta potenzialmente ad un accesso illegale a memoria dinamica; questo scenario entra nel merito della programmazione concorrente, quindi, con flussi di controllo multipli – multi-threads – ad accedere alle stesse porzioni di memoria condivisa.
Per questi scenari, occorre andare oltre a std::unique_ptr
e muoversi verso un altro smart pointer ovvero: std::shared_ptr
che sarà trattato nel prossimo articolo.
Stay Tuned!
.
https://italiancoders.it/smart-pointers-in-c/