Smart Pointers in C++

S

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_ptre 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_ptre 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_threade probabilmente accederà alla memoria puntata da  dangling_ptrprima 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 è un oggetto molto complesso.
Capire le cause della 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 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.
Forniscono un modello di controllo della memoria dinamica efficace, senza dover pagare il costo di un garbage collector in esecuzione nel nostro programma.
L’idea vincente di uno smart pointer rispetto al classico raw pointer è l’aggiunta dell’informazione necessaria per capire quando la risorsa 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 programmi, sarà molto difficile, se non impossibile, coprire tutti i percorsi di accesso del nostro raw pointer.
Questa affermazione è particolamente vera nel momento in cui il nostro programma cresce in complessità; 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; aspetto non vero quando si utilizza la garbage collection.
In essi è contenuta l’informazione per capire che all’uscita da uno scope, nessun altro scope potrà più accedere alla memoria puntata.

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 questi tipi, la possibilità di essere copiati è stata del tutto vietata.

EXCLUSIVE OWNERSHIP

Un tipo move-only, guarda caso, è proprio uno smart pointerstd::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 uiptrche 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 returncopre anch’essa la chiamata al distruttore di uiptre 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.
In altre parole: non è compito del programmatore 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 freeo una unlockin un suo programma!
Gli unique pointer sono stati introdotti con lo standard 11 del C++; la loro potenza non si limita al fatto di essere oggetti RAII: possiedono il grande pregio di supportare completamente la semantica di move.

PALEO SMART POINTERS

Prima di std::unique_ptrlo standard 98 metteva a disposizione std::auto_ptrche 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_ptrsono 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_2prenda il possesso della risorsa e che uiptrsia 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 uiptrin uiptr_2dobbiamo 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::moveporti 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_ptrnei 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_ptral posto del raw pointer, rende immediatamente comprensibile al programmatore che vede per la prima volta il codice, cosa sia pimple 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 deletesu pimple non è detto che sia una cosa che faremo per tutte le nostre classi; se usiamo std::unique_ptril compilatore si ricorderà, tutte le volte, di chiamare il distruttore di pimplper 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, il solo fatto di invocare l’API, obbliga l’utente a doversi preoccupare della risorsa acquisita in ogni caso.
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_apici 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 resourcenel solo caso in cui, my_apirestituisca resultCode_OK.
Siamo costretti invece, a considerare anche i casi in cui my_apirestituisce resultCode_KOe 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_ptrin 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.
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_ptre muoversi verso un altro smart pointer ovvero: std::shared_ptrche sarà trattato nell’articolo: Smart Pointers in C++ – pt.2.
Stay Tuned!

 

.

 

 

 

 

 

 

 

 

 

 

A proposito di me

Giuseppe Baccini

Sin dalla tenera età è un grande appassionato di informatica e tecnologie.
Dopo gli studi universitari pensa che tutti i problemi possano essere risolti con il Java.
Successivamente, quando viene assunto in un'azienda che si occupa di finanza e il trading, tradisce Java e comincia a corteggiare il C++.
Ancora adesso non è convinto che il divorzio con il primo amore sia stata una buona idea: la seconda moglie è indubbiamente molto problematica.
Non esclude che in futuro possa esserci una seconda ex!

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti