Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial
  • Lezione 80 di 93
  • livello avanzato
Indice lezioni

Ownership esclusiva: unique_ptr

La classe unique_ptr permette di sostituire l'uso di puntatori tradizionali quando è possibile definire la proprietà esclusiva dell'oggetto puntato.
La classe unique_ptr permette di sostituire l'uso di puntatori tradizionali quando è possibile definire la proprietà esclusiva dell'oggetto puntato.
Link copiato negli appunti

La classe unique_ptr è uno degli smart pointer presenti nella Standard Template Library (STL). Essa si presta a sostituire l'uso di puntatori tradizionali in tutti gli scenari in cui è possibile definire la proprietà esclusiva dell'oggetto puntato.

Con proprietà esclusiva si intende, come anticipato nella lezione precedente, che l'onere del rilascio della memoria puntata è delegata all'estinzione del ciclo di vita di un'istanza di unique_ptr, secondo la strategia derivata dall'idioma RAII.

Tuttavia, la caratteristica saliente di questo puntatore intelligente non consiste tanto nell'uso di RAII ma, come suggerisce il nome, nel fatto che esso non può essere copiato nè condiviso, ma solo trasferito da un contesto all'altro.

In effetti, non è un caso che la classe unique_ptr sia stata introdotta proprio con lo standard C++11. Il suo funzionamento, come vedremo negli esempi seguenti, si basa su uno dei principali capisaldi del C++ moderno: la semantica dello spostamento.

Il listato seguente mostra l'impiego di unique_ptr in un caso d'uso molto semplice. Due istanze della classe Dummy sono associate a due istanze di unique_ptr, unique1 e unique2, che risiedono in contesti annidati.

#include <iostream>
#include <cstring>
#include <memory> // std::unique_ptr
 
class Dummy {
public:
    Dummy(std::string name) {
        Name = name;
    }
     
    ~Dummy() {
        std::cout << "Invoking destructor of " << Name << std::endl;
    }
     
    std::string Name;
};
 
int main() {
    std::unique_ptr<Dummy> unique1 = std::make_unique<Dummy>("A");
    std::cout << unique1->Name << std::endl;
    {
        // contesto annidato
        std::unique_ptr<Dummy> unique2 = std::make_unique<Dummy>("B");
        std::cout << unique2->Name << std::endl;
    }
    return 0;
}

In base ai vincoli imposti dall'approccio basato su RAII, l'ordine di distruzione delle variabili unique1 e unique2 è inverso a quello di costruzione, come mostra l'output prodotto dall'esecuzione del listato.

A
B
Invoking destructor of B
Invoking destructor of A

Inoltre, come per la classe NaivePointer mostrata nella lezione precedente, la presenza di operatori di dereferenziazione sovraccaricati consente di accedere ai membri della classe Dummy in maniera trasparente.

Inizializzazione di unique_ptr

L'esempio precedente offre uno spunto per analizzare le modalità di inizializzazione di unique_ptr. In questo listato si osservi che l'inizializzazione di unique1 e unique2 avviene per mezzo della funzione generica make_unique, invece che mediante l'uso del costruttore della classe unique_ptr.

La API make_unique è stata introdotta dallo standard C++14 allo scopo di gestire problemi di rilascio della memoria dinamica che possono insorgere quando l'istruzione new è applicata a variabili temporanee senza nome, nel caso in cui si verifichino eccezioni nel medesimo contesto. Inoltre, l'uso di make_unique è preferibile anche perchè, assieme allo specificatore auto rende la definizione di unique_ptr meno prolissa, come mostrato nel frammento seguente:

// inizializzazione mediante costruttore di unique_ptr
std::unique_ptr<Dummy> unique1<Dummy>(new Dummy("A"));
 
// inizializzazione equivalente con make_unique e auto. Si osservi che il tipo Dummy appare solo una volta.
auto unique1 = std::make_unique<Dummy>("A");

Una eccezione all'uso di make_unique è quella costituita da tutti gli scenari in cui il rilascio delle risorse associate alla struttura puntata richieda l'uso di API diverse dal distruttore della classe. In questi casi, è necessario ricorrere al costruttore sovraccaricato di unique_ptr che accetta un puntatore ad un oggetto invocabile, detto deleter, che funge da distruttore per il tipo incapsulato.

Il frammento seguente mostra le modalità di istanziazione di unique_ptr per questa eventualità. Il secondo argomento template del costruttore di unique_ptr è il tipo dell'oggetto deleter che, in questo caso, viene definito mediante l'uso delle API di utilità contenute nello header functional della libreria standard per la gestione di puntatori a funzione.

#include <functional> // std::function
...
// API deleter
void dummyDeleter(Dummy* ptr) {
    std::cout << "Custom deleter invoked on " << ptr->Name << std::endl;
    // istruzioni per il rilascio delle risorse associate a ptr (mutex, I/O handlers etc.)
}
...
// inizializzazione di unique_ptr con deleter custom
std::unique_ptr<Dummy, std::function<void(Dummy*)>> unique1(new Dummy("A"), dummyDeleter);

Semantica dello spostamento e unique_ptr

La semantica dello spostamento ricopre un ruolo fondamentale per la classe unique_ptr, e la rende differente rispetto altre implementazioni antecedenti lo standard C++11.

Come anticipato, da specifica un'istanza di unique_ptr non può essere copiata perchè ciò violerebbe il vincolo di esclusività di possesso.

In effetti, se per assurdo si potesse copiare un'istanza di unique_ptr, ad esempio per passarla come argomento ad una funzione, l'uscita dal corpo di istruzioni della stessa determinerebbe la distruzione della copia e innescherrebbe il rilascio della memoria associata, come prescritto da RAII. Ciò avrebbe l'effetto di invalidare anche l'istanza originale di cui è stata fatta la copia, che ancora esiste nel contesto del chiamante e potrebbe essere ulteriormente referenziata.

Al fine di evitare questo problema, la copia di istanze unique_ptr è inibita mediante l'uso dello specificatore = delete, applicato al costruttore ad all'operatore di assegnamento di copia. In ragione di ciò, il compilatore segnalerà come errore ogni tentativo di costruire o assegnare una copia di un'istanza di unique_ptr.

Ne deriva che l'unico modo per impiegare praticamente unique_ptr come argomento o valore di ritorno di una funzione è ricorrere al passaggio di reference o allo spostamento (passaggio di rvalue reference), come mostrano i tre esempi che seguono.

Nel primo esempio, si mostra una funzione che accetta come argomento un riferimento ad un oggetto unique_ptr specializzato per l'uso della classe Dummy definita nel listato precedente. Si noti che l'istanza unique1 non cede mai la sua ownership al contesto della funzione pass_by_reference() perchè in questo caso non vi è spostamento.

// Esempio 1: passare unique_ptr per reference
void pass_by_reference(std::unique_ptr<Dummy>& p) {
    std::cout << "Pass by reference " << p->Name << std::endl;
}
...
auto unique1 = std::make_unique<Dummy>("A");
pass_by_reference(unique1);

Il secondo esempio si differenzia dal primo solo in virtù del fatto che l'uso del qualificatore const consente alla funzione pass_by_const_reference() di accettare anche dei valori temporanei oltre che variabili con nome (lvalue). Anche in questo caso, non si verifica uno spostamento, ma solo dereferenziazione.

// Esempio 2: passare unique_ptr come const reference (accetta anche valori temporanei)
void pass_by_const_reference(const std::unique_ptr<Dummy>& p) {
    std::cout << "Pass by const reference " << p->Name << std::endl;
}
...
auto unique1 = std::make_unique<Dummy>("A");
pass_by_const_reference(unique1);
pass_by_const_reference(std::make_unique<Dummy>("B")); // temporaneo

Il terzo esempio infine, propone il caso di una funzione che accetta l'oggetto unique_ptr per valore.

Si osservi però che in questo caso, la dicitura per valore, tradizionalmente associata al concetto di copia, è invece riferita allo spostamento.

La funzione pass_by_rvalue_reference può infatti accettare come argomenti valori temporanei e variabili convertite esplicitamente a rvalue reference mediante l'uso della funzione std::move.

// Esempio 3: spostare unique_ptr per usarlo come argomento di funzione.
// N.B.: sposta due volte, esempio di idioma source-and-sink
std::unique_ptr<Dummy> pass_by_rvalue_reference(std::unique_ptr<Dummy> p /*1*/)
{
    std::cout << "Pass by rvalue reference " << p->Name << std::endl;
    return p; /*2*/
}
...
auto unique3 = std::make_unique<Dummy>("D");
auto unique4 = pass_by_rvalue_reference(std::move(unique3));
// stampa "D"; unique3 è nullo dopo lo spostamento
std::cout << unique4->Name << std::endl;

Quest'ultimo caso racchiude in nuce i principi dell'idioma source-and-sink (sorgente e pozzo) che, nell'ambito della semantica dello spostamento esprime la modalità di trasferimento della ownership tra i livelli annidati dello stack di chiamate a funzione.

La funzione pass_by_rvalue_reference è un "pozzo" perchè la ownership dell'oggetto Dummy incapsulato in unique3 viene assorbita dal suo argomento. Essa è anche una sorgente perchè la ownership del valore di ritorno, a sua volta, viene trasferita al contesto del chiamante nella variabile unique4. Entrambi i passaggi avvengono per spostamento e unique3, coerentemente con i precetti della semantica dello spostamento, è lasciato in uno stato equivalente a quello di un puntatore nullo.

Si osservi che in ogni caso, la semantica dello spostamento coinvolge la sola classe unique_ptr. Non è infatti richiesto che l'oggetto incapsulato in unique_ptr supporti a sua volta la semantica dello spostamento.

Uso in classi contenitore e algoritmi standard

L'implementazione della semantica dello spostamento è il fattore abilitante che consente di impiegare unique_ptr nelle classi contenitore e negli algoritmi della libreria standard.

Molti algoritmi fanno uso nelle loro implementazioni di operazioni logicamente riconducibili alla copia dei valori contenuti. Ad esempio, si pensi alle operazioni di swap previste da algoritmi di ordinamento in place. In tutti questi scenari, la semantica dello spostamento sostituisce in maniera trasparente quella della copia, senza le complicazioni dovute a RAII.

Nel listato seguente si mostra l'uso di unique_ptr con una delle classi contenitore della libreria standard ed alcuni algoritmi che non effettuano copie di oggetti, ma solo spostamenti.

#include <iostream>
#include <cstring>
#include <memory>
#include <vector>
#include <algorithm>
 
class Dummy {
    // per la definizione vedi listati precedenti
};
 
int main()
{
    std::vector<std::unique_ptr<Dummy>> v;
     
    v.push_back(std::make_unique<Dummy>("C"));
    v.push_back(std::make_unique<Dummy>("B"));
    v.push_back(std::make_unique<Dummy>("X"));
    v.push_back(std::make_unique<Dummy>("A"));
     
    std::cout << "\nReverse and print:\n";
    std::reverse(v.begin(), v.end());
    std::for_each(v.begin(), v.end(), [] (const std::unique_ptr<Dummy>& item) {
        std::cout << item->Name << " ";
    });
     
    std::cout << "\nRemove X ...\n";
    v.erase(std::remove_if(v.begin(), v.end(), [] (const std::unique_ptr<Dummy>& item) {
        return (item->Name.compare("X") == 0);
    }));
     
    std::cout << "\nPrint again:\n";
    std::for_each(v.begin(), v.end(), [] (const std::unique_ptr<Dummy>& item) {
        std::cout << item->Name << " ";
    });
     
    std::cout << "\n\nUnwinding...\n";
    return 0;
}

L'output del programma è riportato di seguito. Da esso si evince sia l'effettivo funzionamento degli algoritmi reverse e remove_if ed erase, sia l'effetto del meccanismo RAII applicato agli elementi del vettore quando su di essi viene invocato il distruttore sia in modo esplicito come nel caso di erase, sia in caso di stack unwinding all'uscita dal loro contesto di visibilità.

Reverse and print:
A X B C 
Remove X ...
Invoking destructor of X
Print again:
A B C 
Unwinding...
Invoking destructor of A
Invoking destructor of B
Invoking destructor of C

Per le ragioni discusse in precedenza, l'uso di unique_ptr è sostanzialmente incompatibile con tutti gli algoritmi che prevedono la copia di oggetti. Ad esempio, nel frammento seguente l'invocazione dell'algoritmo copy su una classe contenitore che contiene istanze unique_ptr darebbe luogo ad un errore di compilazione.

std::vector<std::unique_ptr<Dummy>> v;
    ...
    std::vector<std::unique_ptr<Dummy>> vCopy;
    std::copy(v.begin(), v.end(), std::back_inserter(vCopy));  // errore!

Integrazione con API basate su puntatori tradizionali: get, release e reset

L'uso di unique_ptr è consigliato in sostituzione dei puntatori tradizionali poichè definisce in modo chiaro la ownership dell'istanza puntata. Tuttavia vi sono alcuni scenari in cui può essere necessario accedere al puntatore raw incapsulato dalla classe.

A tale scopo, i metodi di classe get e release consentono di manipolare il riferimento all'istanza puntata al di fuori dei vincoli di ownership previsti dalla classe unique_ptr. Tali metodi possono essere usati quando è necessario interfacciarsi con API non compatibili con argomenti di tipo unique_ptr, ponendo particolare cautela nella gestione della risorsa puntata, come mostrato nel listato seguente:

#include <iostream>
#include <memory>
 
void safeIncrement(int *num)
{
    if (num != nullptr)
        (*num)++;
}
 
int* evilCopy(int *num)
{
    int* numCopy = new int;
    *numCopy = *num;
    delete num;
     
    return numCopy;
}
 
int main ()
{
    std::unique_ptr<int> uPtr = std::make_unique<int>(1);
     
    // Ok: la funzione safeIncrement non dealloca la memoria puntata, quindi non
    // intacca la ownership di uPtr
    safeIncrement(uPtr.get());
     
    std::cout << *uPtr << '\n';
    // Ok: il metodo release cede la ownership della istanza puntata alla funzione evilCopy
    // ed il metodo reset la reastura
    uPtr.reset(evilCopy(uPtr.release()));
     
    std::cout << *uPtr << '\n';
     
    // Attenzione: la ownership di uPtr è stata violata!!
    evilCopy(uPtr.get());
     
    return 0;
}

Le funzioni safeIncrement e evilCopy in questo esempio hanno il ruolo di API legacy non compatibili con l'uso di unique_ptr. Il metodo get della classe unique_ptr restituisce il riferimento alla memoria allocata come puntatore tradizionale. Ciò espone al rischio di violare la ownership di uPtr, tuttavia, data la sua implementazione, l'uso della funzione safeIncrement non presenta problemi di sorta.

La funzione evilCopy invece, richiama al suo interno il distruttore dell'istanza passata come argomento e restituisce un nuovo oggetto allocato dinamicamente. Tale sequenza di istruzioni invaliderebbe la ownership di uPtr, esponendoci al rischio di violazioni di accesso.

Per adattare la funzione evilCopy all'uso di uPtr dobbiamo avere quindi l'accortezza di cedere la ownership dell'istanza puntata invocando il metodo release e ripristinarla con il metodo reset, come mostrato nel listato. Senza questa precauzione, l'invocazione della funzione evilCopy sul puntatore raw incapsulato da uPtr ne invaliderebbe il riferimento.


Ti consigliamo anche