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

Puntatori, ownership e RAII

Impariamo alcuni concetti alla base di una delle innovazioni più significative dello standard C++11, nota come smart pointers: puntatori, ownership e RAII.
Impariamo alcuni concetti alla base di una delle innovazioni più significative dello standard C++11, nota come smart pointers: puntatori, ownership e RAII.
Link copiato negli appunti

La gestione della memoria allocata dinamicamente (heap) è uno degli aspetti più critici della programmazione in C++. Spesso, gli errori di programmazione più subdoli sono imputabili a politiche di allocazione e rilascio della memoria imperfette, che si traducono in un uso eccessivo o insostenibile di risorse (memory leak), oppure sfociano in condizioni di errore irreversibili che minano la sicurezza e la stabilità del programma (access violation).

In questa lezione, saranno brevemente introdotti alcuni concetti teorici che costituiscono una delle innovazioni più significative dello standard C++11, nota come smart pointers.

Questi ultimi sono classi di utilità della libreria standard che fungono da wrapper per i puntatori tradizionali o raw. Essi offrono varie politiche di rilascio della memoria trasparenti al programmatore, che si adattano a molteplici casi d'uso.

Puntatori e ownership in C++

L'uso di puntatori in C++ è un fattore abilitante ed imprescindibile per la gestione efficiente della memoria e l'implementazione del polimorfismo. Tuttavia, l'allocazione e la deallocazione della memoria sullo heap è un onere che ricade interamente sul programmatore.

La regola di base per una corretta gestione della memoria consiste nel verificare che ad ogni allocazione dinamica della memoria fatta con new corrisponda una istruzione delete (o delete[] per array), che venga eseguita quando la nostra variabile puntatore non è più referenziata in nessuno dei possibili percorsi del flusso del nostro programma.

Può anche essere relativamente semplice applicare questa regola nel caso di programmi non troppo complessi. Tuttavia, nella maggior parte dei casi, non è così.

In effetti, bisogna sempre tenere in mente che un programma in esecuzione è un flusso continuo di istruzioni regolato da salti. Istruzione di branching, chiamate a funzione e altri cambi di contesto, sono tutti eventi che deviano il flusso dal suo corso attuale e lo reindirizzano verso una porzione del programma differente, in modi non sempre conoscibili a priori.

A differenza delle variabili allocate a stack, il ciclo di vita delle variabili allocate a heap non è automaticamente regolato dai salti del programma mediante unwinding. Ne consegue che una porzione di memoria allocata dinamicamente può essere acceduta in contesti differenti da quello della sua definizione.

Ad esempio, un puntatore può essere passato come argomento di una funzione o restituito come valore di ritorno. Si pone quindi il problema di capire in quale contesto è opportuno gestire la sua deallocazione.

Per gestire queste contingenze, si fa generalmente affidamento al concetto astratto di ownership o proprietà di un oggetto puntato. In pratica, si assume che il ciclo di vita di una variabile allocata dinamicamente sia responsabilità di una entità ben definita, cui spetta il compito di deallocare la memoria occupata quando essa non è più necessaria nè accessibile da nessuno dei possibili percorsi del flusso di istruzioni.

Nell'era pre C++11, la resa sul piano pratico del concetto di ownership era affidata all'uso di librerie di terze parti, come quelle del progetto boost, o alle linee guida tracciate dalla Standard C++ foundation in collaborazione con Microsoft e incorporate nella libreria accessoria GSL (Guidelines Support Library).

Nel primo caso, si tratta di una implementazione primigenia di classi smart pointer, che oggi trovano un omologo nella libreria STL (Standard Template Library), di cui parleremo nel seguito.

Nel secondo, di un insieme di buone pratiche e convenzioni atte alla corretta attribuzione della ownership di un puntatore. Secondo questo approccio, le variabili puntatore possono essere decorate mediante l'uso di alias che indicano su chi ricade l'onere di distruzione dell'oggetto puntato.

La convenzione adottata dalla libreria GSL è la seguente: un puntatore T* è tipicamente considerato non proprietario dell'oggetto puntato, ma un semplice strumento di referenziazione per operazioni di lettura e scrittura.

Un puntatore decorato con l'alias gsl::owner<T*> è invece proprietario dell'oggetto puntato. Pertanto, esaurito il suo ambito di visibilità, esso deve essere distrutto invocando l'istruzione delete. In alternativa, la proprietà dell'oggetto puntato deve essere trasferita ad un altro puntatore di tipo gsl::owner<T*>.

Il frammento seguente è estratto dal documento che descrive il modello di gestione delle risorse di memoria adottato dal linguaggio C++ e le relative linee guida, disponibile al seguente link.

Esso illustra la dichiarazione dell'alias e le modalità di impiego per l'attribuzione della proprietà di un oggetto allocato dinamicamente.

// dichiarazione dello alias: il tipo owner<T> equivale di fatto a T
template<typename T> using owner = T;
 
 
void f(owner<int*> p, int* q)
{
    // Errore: q non è proprietario dell'oggetto puntato, quindi distruggerlo in questo contesto
    // può compromettere la dereferenziazione all'oggetto puntato in altre parti del codice.
    delete q;
     
    // Errore: manca l'istruzione "delete p;"
}

La funzione f() ha come argomento due puntatori a intero, p e q. Il primo è decorato con l'alias owner<int*>, indicando il fatto che la proprietà dell'oggetto puntato è vincolata all'ambito di visibilità di p. Il secondo è un puntatore tradizionale, la cui variabile puntata va intesa come di proprietà di qualcun altro, ad esempio del contesto del chiamante.

Un analizzatore statico di codice può facilmente rilevare e notificare le violazioni alle direttive GSL presenti nel corpo della funzione f().

Questo approccio ha il notevole vantaggio di non comportare differenze sostanziali tra un eseguibile compilato a partire da sorgenti conformi alle direttive GSL e sorgenti non conformi. Esso si presta quindi alla revisione di codebase legacy, per le quali un adeguamento allo standard C++ 11 o superiore non è possibile a causa dei vincoli imposti dalla compatibilità binaria o ABI (Application Binary Interface).

Tuttavia, sono anche evidenti i limiti di questa filosofia. Essa infatti vincola all'uso di strumenti di terze parti (analizzatori statici) e al rigido rispetto di una convenzione.

In definitiva, l'adozione delle direttive GSL non è sufficiente a supportare la transizione del linguaggio dallo standard 98 a quelli di concezione moderna. Come spesso è accaduto nella storia dello standard, per superare le limitazioni sintattiche del linguaggio, le revisioni successive hanno fatto uso di un approccio su base idiomatica, derivando nuove API per libreria STL.

Gli smart pointer sono un ulteriore esempio di tale strategia evolutiva.

RAII

RAII (Resource Acquisition Is Initialization) è un acronimo che si associa al funzionamento di base delle classi smart pointer in C++. Nel rispetto della ormai consolidata tradizione del linguaggio C++ in fatto di scelte infelici per la denominazione di costrutti idiomatici, esso cela un meccanismo di gestione della memoria estremamente semplice, efficiente ed elegante.

Applicato al caso degli smart pointer, RAII lega l'inizializzazione ed il rilascio delle memoria all'ambito di visibilità di una variabile automatica che incapsula un puntatore tradizionale, sfruttando le funzionalità del costruttore e distruttore di classe di C++.

Il principio di base è illustrato nel listato seguente in cui la classe NaivePointer è una versione esemplificata di smart pointer. Essa è una classe template che incapsula un puntatore di tipo generico. L'assegnazione del puntatore è fatta nel costruttore, mentre il distruttore rilascia la memoria associata al puntatore.

#include <iostream>
 
// Una implementazione semplicistica di uno smart pointer
template <typename T>
class NaivePointer {
public:
    NaivePointer(T* p) {
        ptr = p;
    }
     
    ~NaivePointer() {
        std::cout << "Deleting...\n";
        delete ptr;
    }
     
    T* operator-> () const {
        return ptr;
    }
     
    T& operator* () const {
        return *ptr;
    }
 
private:
    T* ptr;
};
 
// Una classe di esempio
class Dummy {
public:
    Dummy(int v) {
        value = v;
    }
     
    int value;
};
 
int main() {
    // naive pointer che incapsula un tipo primitivo
    NaivePointer<int> naive1(new int{123});
    // naive pointer che incapsula un tipo complesso
    NaivePointer<Dummy> naive2(new Dummy(321));
     
    // uso dimostrativo degli operatori di dereferenziazione
    std::cout << *naive1 + naive2->value << std::endl;
     
    return 0;
    // all'uscita dallo scope di visibilità naive1 e naive2 vengono distrutti automaticamente
}

Grazie alla presenza degli operatori sovraccaricati * e ->, una istanza di NaivePointer può essere usata alla stregua di un puntatore tradizionale per accedere alla struttura puntata, come mostrato nel corpo della funzione main().

Il meccanismo di unwinding dello stack invoca automaticamente il distruttore delle variabili naive1 e naive2, quindi non è necessario provvedere al rilascio della memoria allocata dinamicamente con new dopo la definizione di queste variabili.

L'implementazione fornita in questo esempio non tiene conto di numerosi scenari e casi d'uso che sono invece contemplati nelle implementazioni della libreria STL. Le varie tipologie di puntatori smart previsti dallo standard, le loro caratteristiche ed i loro casi d'uso saranno oggetto delle lezioni successive.

Ti consigliamo anche