Salta al contenuto
Note per Studenti C++ - costruttori, inizializzazione, copia e spostamento

C++ - costruttori, inizializzazione, copia e spostamento

In questa pagina 9

Il ciclo di vita di un oggetto che possiede risorse: come nasce (costruttori, inizializzazione), come si copia, come si sposta, come muore (distruttore, vedi C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →). Il filo conduttore è la classe vector di double costruita sopra un buffer; la sua versione completa è l'esercizio Esercizio - MyVector 2.0 con buffer ridimensionabile (laboratorio 4), quella generica Esercizio - MyVector 2.0 con template (laboratorio 5). Presupposti: C++ - classi, membri e invariantiuna classe è un tipo definito dal programmatore che rappresenta un concetto con una rappresentazione (dati membro) e delle operazioni (funzioni membro); l'interfaccia pubblica separa chi usa la classe da chi la implementa e i dati privati sono modificabili solo tramite l'interfaccia; struct è una class con membri pubblici di default; l'invariante è la regola che definisce gli stati validi, e il costruttore deve stabilirlo e ogni funzione membro conservarlo; il costruttore (stesso nome della classe) inizializza con una lista di inizializzazione e, se i dati non sono validi, lancia un'eccezione, l'unico modo per far fallire un costruttore; le funzioni membro si definiscono fuori dalla classe (solo le brevissime dentro, dove sono inline); le funzioni che non modificano l'oggetto sono dichiarate const; le funzioni che si possono scrivere con la sola interfaccia pubblica (operator==, next_sunday) sono helper function esterne, spesso nello stesso namespace.C++ - classi, membri e invarianti → e C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →. La stessa distinzione fra copia di riferimenti e copia di oggetti si ritrova in Python (Variabili, oggetti e riferimentiIn Python una variabile è un nome legato a un oggetto; tipo, identità e valore; assegnamento, aliasing, oggetti mutabili e immutabili, None.Variabili, oggetti e riferimenti →); la gestione manuale in C è in Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →; gli smart pointer che evitano tutta questa gestione sono in C++ - smart pointerGli smart pointer (classi template di ) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer →.

Una buona interfaccia

L'interfaccia di una classe va progettata. Principi generali:

  1. completa (permette di fare ciò che serve) e minimale (nessuna funzione ridondante);
  2. fornire i costruttori;
  3. la copia deve essere supportata o proibita (non lasciata al caso);
  4. usare tipi adeguati per controllare gli argomenti (un Month invece di un int);
  5. identificare le funzioni membro costanti;
  6. liberare tutte le risorse nel distruttore.

Le prime sezioni riguardano la costruzione.

Costruttore di default e valori di default

Con Date(int y, Month m, int d) come unico costruttore, queste definizioni sono illegali:

cpp
Date d0;                    // errore: nessuna inizializzazione
Date d1 {};                 // errore: inizializzatore vuoto
Date d2 {1998};             // errore: argomenti insufficienti
Date d3 {1, 2, 3, 4};       // errore: troppi argomenti
Date d4 {1, "jan", 2};      // errore: tipi sbagliati
Date d5 {1, Month::jan, 2}; // ok
Date d6 {d5};               // ok: costruttore di copia

Definizione. Il costruttore di default è un costruttore che si può chiamare senza argomenti. Ha senso quando esistono valori di default significativi: string è vuota, vector è vuoto. Per i tipi built-in vale 0 (int{} è 0, double{} è 0.0).

Ha una forte influenza sull'interfaccia: se esiste, si può scrivere Date d;, se manca no. Due modi per dare i valori di default:

strumento esempio note
valori di default nei membri (in-class initializer) int y {2001}; Month m {Month::jan}; int d {1}; valgono per tutti i costruttori che non li inizializzano esplicitamente
argomenti di default Date(int y = 2001, Month m = Month::jan, int d = 1); un solo costruttore gestisce zero, uno, due o tre argomenti

Con gli argomenti di default, una volta forniti per un parametro, tutti i successivi devono averli (non si può dare il default a y senza darlo a m e d). Si possono usare per qualsiasi funzione, non solo per i costruttori. Se si sceglie questa strada non si deve anche dichiarare un costruttore senza argomenti: la chiamata Date d; sarebbe ambigua.

ctor_default.cppcpp
#include <iostream>
using namespace std;

enum class Month { jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };

// Variante A: valori di default nei membri (in-class initializers) + piu' costruttori
class DateA {
public:
    DateA() {}                                   // usa i valori di default dei membri
    DateA(int yy) : y{yy} {}                     // m e d prendono il default
    DateA(int yy, Month mm, int dd) : y{yy}, m{mm}, d{dd} {}
    void stampa() const { cout << y << '-' << int(m) << '-' << d << '\n'; }
private:
    int y {2001};
    Month m {Month::jan};
    int d {1};
};

// Variante B: UN solo costruttore con argomenti di default
class DateB {
public:
    DateB(int yy = 2001, Month mm = Month::jan, int dd = 1) : y{yy}, m{mm}, d{dd} {}
    void stampa() const { cout << y << '-' << int(m) << '-' << d << '\n'; }
private:
    int y;
    Month m;
    int d;
};

int main()
{
    DateA a1;                       // costruttore di default
    DateA a2 {1998};                // un argomento
    DateA a3 {2010, Month::mar, 15};
    a1.stampa(); a2.stampa(); a3.stampa();

    DateB b1;                       // stesso effetto, un solo costruttore
    DateB b2 {1998};
    DateB b3 {2010, Month::mar};    // d prende il default 1
    b1.stampa(); b2.stampa(); b3.stampa();

    // tipi built-in e standard: hanno un "costruttore di default" che azzera/svuota
    int zero = int{};               // 0
    double dz = double{};           // 0.0
    cout << zero << ' ' << dz << '\n';
    return 0;
}
2001-1-1
1998-1-1
2010-3-15
2001-1-1
1998-1-1
2010-3-1
0 0

Costruttori a un argomento: explicit

Un costruttore che si può chiamare con un solo argomento definisce anche una conversione implicita da quel tipo alla classe. Con Records(int n):

cpp
Records m = 5;      // compila: l'int 5 diventa un Records
usa(7);             // compila: void usa(const Records&) con un temporaneo Records(7)

Spesso non è voluto: un intero non è un Records. La keyword explicit, nella dichiarazione del costruttore, impedisce le conversioni implicite: il costruttore può essere chiamato solo in modo esplicito (Records m {5};, Records(5)).

ctor_explicit.cppcpp
#include <iostream>
#include <vector>
#include <string>
using namespace std;

class Records {
public:
    Records() {}
    Records(int n) { dati.reserve(n); }          // costruttore IMPLICITO da int
    size_t capienza() const { return dati.capacity(); }
private:
    vector<string> dati;
};

class RecordsE {
public:
    RecordsE() {}
    explicit RecordsE(int n) { dati.reserve(n); }   // explicit: niente conversione implicita
    size_t capienza() const { return dati.capacity(); }
private:
    vector<string> dati;
};

void usa(const Records& r) { cout << "usa: capienza >= " << (r.capienza() >= 5) << '\n'; }
void usaE(const RecordsE& r) { cout << "usaE: capienza >= " << (r.capienza() >= 5) << '\n'; }

int main()
{
    Records m = 5;            // compila: l'int 5 diventa un Records tramite il costruttore
    usa(7);                   // compila: 7 -> temporaneo Records(7)
    Records m2 {5};           // inizializzazione diretta: compila sempre
    usa(m);

    RecordsE e {5};           // ok: costruttore chiamato esplicitamente
    RecordsE e2 = RecordsE(5);// ok
    usaE(e);
    // RecordsE e3 = 5;       // errore: la conversione implicita e' vietata da explicit
    // usaE(7);               // errore: idem
    (void)m2; (void)e2;
    return 0;
}
usa: capienza >= 1
usa: capienza >= 1
usaE: capienza >= 1

Togliendo i commenti alle righe con 3 e 7, il compilatore risponde no viable conversion from 'int' to 'RecordsE' e no matching function for call to 'usaE'. Regola pratica: ogni costruttore a un argomento è explicit, salvo che la conversione sia davvero naturale. La keyword si scrive solo nella dichiarazione, non nella definizione fuori dalla classe.

Costruttore con lista: initializer_list

Con vector si vuole poter scrivere vector v1 = {1.2, 7.89, 12.34}; invece di tre push_back. Una lista fra graffe fornisce un oggetto della libreria standard di tipo initializer_list<T>, e basta aggiungere un costruttore che lo accetta:

cpp
class vector {
    // ...
    vector(initializer_list<double> lst)
        : sz{static_cast<int>(lst.size())}, elem{new double[lst.size()]}
    {
        copy(lst.begin(), lst.end(), elem);   // algoritmo della libreria: copia da una sequenza
    }
};

Il static_cast<int> è necessario: lst.size() è un size_t e sz{lst.size()} con sz di tipo int è un narrowing che le graffe rifiutano (non-constant-expression cannot be narrowed from type 'size_t' to 'int'); le slide scrivono sz{lst.size()}, che con un int non compila.

copy copia la sequenza delimitata dai suoi primi due argomenti (due iteratori: STL - sequenze, iteratori e container (vector, list, string)La STL (Standard Template Library) fornisce contenitori (vector, list, map, ...) e algoritmi generici (sort, find, accumulate, ...) tenuti insieme da una interfaccia unificata: la sequenza, un intervallo [begin, end) di elementi gestito tramite iteratori (oggetti che identificano un elemento: * dereferenzia, ++ avanza, == e != confrontano; end è il primo elemento dopo l'ultimo, quindi una sequenza vuota ha begin == end). Gli algoritmi conoscono solo gli iteratori e i contenitori espongono solo iteratori, così i due mondi sono disaccoppiati e cambiare contenitore non cambia l'algoritmo. Un iteratore di lista è una classe annidata con un puntatore al nodo; auto deduce il tipo dell'iteratore. vector (contiguo, [] e at(), insert/erase costosi e che invalidano gli iteratori), string (un vector di char con + e +=), list (nodi concatenati, insert/erase senza spostare gli altri elementi, niente []) e char[] (array C: dimensione fissa, niente iteratori, == confronta i puntatori) a confronto.STL - sequenze, iteratori e container (vector, list, string) →) in quella a cui punta il terzo. L'initializer_list è passato per copia: è un handle a elementi allocati altrove, quindi la copia è economica; è il linguaggio a richiederlo.

Ambiguità {} contro (). Con i due costruttori, le graffe preferiscono quello con la lista:

scrittura risultato
vector v1 {3}; un elemento che vale 3 (lista)
vector v2(3); tre elementi a 0.0 (costruttore a int)
vector v11 = {1, 2, 3}; e vector v12 {1, 2, 3}; equivalenti: tre elementi 1, 2, 3
init_list.cppcpp
#include <algorithm>
#include <initializer_list>
#include <iostream>
using namespace std;

class Vec {
public:
    explicit Vec(int s = 0)                       // Vec(3): TRE elementi a zero
        : sz{s}, elem{s > 0 ? new double[s] : nullptr}
    {
        for (int i = 0; i < sz; ++i) elem[i] = 0;
    }
    Vec(initializer_list<double> lst)             // Vec{1.2, 7.89}: gli elementi sono quelli della lista
        : sz{static_cast<int>(lst.size())}, elem{new double[lst.size()]}
    {
        copy(lst.begin(), lst.end(), elem);       // copia dalla lista al buffer
    }
    ~Vec() { delete[] elem; }
    Vec(const Vec&) = delete;                     // per questo esempio: niente copia
    Vec& operator=(const Vec&) = delete;
    int size() const { return sz; }
    double operator[](int i) const { return elem[i]; }
private:
    int sz;
    double* elem;
};

int main()
{
    Vec v1 = {1.2, 7.89, 12.34};     // initializer_list: 3 elementi
    cout << v1.size() << ": " << v1[0] << ' ' << v1[1] << ' ' << v1[2] << '\n';

    Vec v2 {3};                      // graffa -> preferisce initializer_list: UN elemento che vale 3
    Vec v3(3);                       // parentesi -> Vec(int): TRE elementi a zero
    cout << v2.size() << " (valore " << v2[0] << "), " << v3.size() << " (valore " << v3[0] << ")\n";

    Vec v4 {1, 2, 3};                // equivalenti a v1: la forma con = e quella senza
    Vec v5 = {1, 2, 3};
    cout << v4.size() << ' ' << v5.size() << '\n';
    return 0;
}
3: 1.2 7.89 12.34
1 (valore 3), 3 (valore 0)
3 3

Copia di oggetti

Che cosa significa copiare un oggetto? In che modo la copia dipende dalla sorgente? Quali operazioni di copia esistono, e che rapporto hanno con l'inizializzazione? Si riprende vector con sz e il puntatore elem a un buffer:

cpp
void f(int n)
{
    vector v(3);
    v.set(2, 2.2);
    vector v2 = v;           // che cosa succede?
}

Il comportamento di default è la copia membro a membro: si copiano sz e il puntatore elem, non il contenuto del buffer. Il risultato è una shallow copy (copia superficiale): v e v2 condividono lo stesso buffer.

v :   sz=3  elem ----+
                     +---->  [ 0 | 0 | 2.2 ]
v2:   sz=3  elem ----+

Crea problemi? Sì: modificare un elemento di v2 cambia v, e soprattutto all'uscita da f vengono chiamati i distruttori di v e di v2, che fanno delete[] sullo stesso buffer: doppia cancellazione.

copy_shallow.cppcpp
#include <iostream>
#include <map>
using namespace std;

map<double*, int> liberazioni;       // quante volte ogni buffer verrebbe liberato

// Classe con la copia di DEFAULT (membro a membro): shallow copy
class Shallow {
public:
    Shallow(int s) : sz{s}, elem{new double[s]} { for (int i = 0; i < s; ++i) elem[i] = 0; }
    ~Shallow() { ++liberazioni[elem]; }      // al posto di delete[] elem: conta soltanto
    void set(int i, double x) { elem[i] = x; }
    double get(int i) const { return elem[i]; }
    double* data() const { return elem; }
    int size() const { return sz; }
private:
    int sz;
    double* elem;
};

// Stessa classe con il costruttore di copia e l'assegnamento di copia scritti a mano: deep copy
class Deep {
public:
    Deep(int s) : sz{s}, elem{new double[s]} { for (int i = 0; i < s; ++i) elem[i] = 0; }
    Deep(const Deep& a) : sz{a.sz}, elem{new double[a.sz]}
    {
        for (int i = 0; i < sz; ++i) elem[i] = a.elem[i];   // copia gli ELEMENTI, non il puntatore
    }
    ~Deep() { ++liberazioni[elem]; }
    void set(int i, double x) { elem[i] = x; }
    double get(int i) const { return elem[i]; }
    double* data() const { return elem; }
private:
    int sz;
    double* elem;
};

int main()
{
    double* b1;
    double* b2;
    {
        Shallow v(3);
        v.set(2, 2.2);
        Shallow v2 = v;                      // copia di default: copia i due membri, sz e elem
        cout << "stesso buffer? " << (v.data() == v2.data()) << '\n';
        v2.set(2, 9.9);
        cout << "v.get(2) = " << v.get(2) << " (v2 ha cambiato anche v)\n";
        b1 = v.data();
    }   // qui vengono distrutti v2 e v: due delete[] sullo stesso buffer
    cout << "il buffer di Shallow sarebbe liberato " << liberazioni[b1] << " volte\n";

    {
        Deep w(3);
        w.set(2, 2.2);
        Deep w2 = w;                         // costruttore di copia
        cout << "stesso buffer? " << (w.data() == w2.data()) << '\n';
        w2.set(2, 9.9);
        cout << "w.get(2) = " << w.get(2) << ", w2.get(2) = " << w2.get(2) << '\n';
        b2 = w.data();
    }
    cout << "il buffer di Deep e' liberato " << liberazioni[b2] << " volta\n";
    return 0;
}
stesso buffer? 1
v.get(2) = 9.9 (v2 ha cambiato anche v)
il buffer di Shallow sarebbe liberato 2 volte
stesso buffer? 0
w.get(2) = 2.2, w2.get(2) = 9.9
il buffer di Deep e' liberato 1 volta

Per non far crashare l'esempio, il distruttore conta soltanto i delete[] che farebbe: la copia di default ne farebbe due sullo stesso buffer; con il costruttore di copia scritto a mano, uno per ciascuno dei due buffer separati.

Costruttore di copia

Si definisce un costruttore che accetta una reference costante a un oggetto della stessa classe:

cpp
class vector {
    int sz;
    double* elem;
public:
    vector(const vector&);
    // ...
};

vector::vector(const vector& arg)
    : sz{arg.sz}, elem{new double[arg.sz]}
{
    copy(arg.elem, arg.elem + sz, elem);
}
vector v2 = v;       // e equivalente: vector v2 {v};

Ora il comportamento è una deep copy: v2 ha un buffer proprio con una copia degli elementi.

Assegnamento di copia

Un problema analogo c'è con l'assegnamento (vector v2(4); v2 = v;): il comportamento di default è di nuovo membro a membro, e il buffer di 4 elementi di v2 si perderebbe (leak), mentre il buffer di v sarebbe condiviso.

cpp
class vector {
    int sz;
    double* elem;
public:
    vector& operator=(const vector& a);
};

vector& vector::operator=(const vector& a)
{
    double* p = new double[a.sz];     // 1. alloca nuovo spazio
    copy(a.elem, a.elem + a.sz, p);   // 2. copia gli elementi
    delete[] elem;                    // 3. libera i vecchi dati
    elem = p;                         // 4. fa puntare al nuovo buffer
    sz = a.sz;
    return *this;                     // 5. restituisce una self-reference
}

L'assegnamento è più delicato del costruttore perché deve gestire i dati vecchi. L'ordine proposto è: creare una copia dei dati sorgente, eliminare i vecchi, far puntare elem ai nuovi. Perché non liberare i vecchi prima, per risparmiare un puntatore? Perché non è una buona idea eliminare dei dati prima di essere sicuri di poterli rimpiazzare: se new fallisce, l'oggetto resta intatto (garanzia forte: C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) →).

C'è un caso particolare, l'auto-assegnamento v = v;. Pur non essendo molto utile, deve essere gestito correttamente: con "libera prima e poi copia" si distruggerebbe il buffer che si vuole copiare. Con l'ordine di sopra funziona (alloca, copia da sé stesso, libera, riassegna).

Terminologia

shallow copy deep copy
copia di puntatori o reference, senza copiare i dati dei dati
semantica pointer semantics (o reference semantics) value semantics
come si ottiene copia di default membro a membro si definiscono costruttore e assegnamento di copia
esempi Java (variabili di tipo classe), Python std::vector, std::string
costo basso più lento (proporzionale ai dati)

In C++ i tipi della libreria standard hanno value semantics: copiare un vector copia gli elementi. Un tipo con deep copy che usa allocazione dinamica deve definire esplicitamente costruttore di copia e assegnamento di copia (o proibirli).

Il puntatore this

Nell'assegnamento compare return *this;. this è un puntatore all'oggetto stesso, un parametro implicito di ogni funzione membro generato dal compilatore; serve quando si deve usare un riferimento all'oggetto corrente, per esempio nell'operator= per restituire una self-reference. È immutabile: this = p; è un errore.

cpp
int vector::size() const { return this->sz; }    // equivale a  return sz;  (questione di stile)
this_ptr.cppcpp
#include <iostream>
using namespace std;

class Contatore {
public:
    Contatore& incrementa(int n = 1)      // restituisce una self-reference: permette la catena
    {
        this->valore += n;                // this->valore equivale a valore
        return *this;                     // *this e' l'oggetto su cui e' stata chiamata la funzione
    }
    int leggi() const { return this->valore; }
    const Contatore* indirizzo() const { return this; }
private:
    int valore = 0;
};

int main()
{
    Contatore c;
    c.incrementa().incrementa(5).incrementa(10);      // c.incrementa() restituisce c stesso
    cout << c.leggi() << '\n';
    cout << (c.indirizzo() == &c) << '\n';            // this contiene l'indirizzo dell'oggetto
    // this = nullptr;                                // errore: this non si puo' modificare
    return 0;
}
16
1

incrementa() restituisce *this, cioè una reference a c stesso, e perciò si possono concatenare le chiamate: 1+5+10=161 + 5 + 10 = 16.

Spostamento di oggetti

Una funzione che restituisce un vector costruito dentro di sé:

cpp
vector fill(istream& is)
{
    vector res;
    for (double x; is >> x; ) res.push_back(x);
    return res;
}
void use() { vector vec = fill(cin); /* uso di vec */ }

con la sola copia, il return copierebbe tutti gli elementi di res, che sta per sparire. "We don't want a copy!" Non possiamo usare res dopo il return, quindi non serve conservarlo: si vuole rubare la sua rappresentazione per vec. Serve un'operazione di spostamento (move), che completa le operazioni di copia: sposta i dati invalidando la sorgente.

La notazione && è una rvalue reference, un riferimento a un temporaneo o a un oggetto che si può "svuotare". Gli argomenti non sono const (non possono esserlo: la sorgente viene modificata), e le operazioni di spostamento tendono a essere più semplici della copia: non c'è niente da duplicare, solo da cedere.

cpp
vector::vector(vector&& a)               // costruttore di spostamento
    : sz{a.sz}, elem{a.elem}             // 1. prende puntatore e dimensione
{
    a.sz = 0;                            // 2. annulla la sorgente
    a.elem = nullptr;                    //    (sicura da distruggere: delete[] nullptr non fa nulla)
}
vector& vector::operator=(vector&& a)    // assegnamento di spostamento
{
    delete[] elem;                       // libera i propri dati
    elem = a.elem;  sz = a.sz;           // ruba quelli della sorgente
    a.elem = nullptr;  a.sz = 0;
    return *this;
}

Con questi, return res; in fill è implementato dal costruttore di spostamento. È il compilatore a scegliere fra copia e spostamento, riconoscendo le condizioni (temporanei, valori di ritorno, std::move): il progettista fornisce lo strumento, il compilatore decide quando usarlo. Se non c'è il costruttore di spostamento si usano il costruttore di copia e il distruttore.

L'alternativa pre-C++11 era allocare dinamicamente e restituire un puntatore (vector* fill2(...) con delete vec; nel chiamante): più verbosa e soggetta a errori.

Spostamento e copy elision

In certe circostanze il costruttore di spostamento non viene nemmeno chiamato: il compilatore applica la copy elision, un'ottimizzazione per cui l'oggetto è costruito direttamente nella funzione chiamante. Si può chiedere di non applicarla con -fno-elide-constructors.

vector_cinque.cppcpp
#include <algorithm>
#include <iostream>
#include <utility>
using namespace std;

class Vec {
public:
    explicit Vec(int s = 0) : sz{s}, elem{s > 0 ? new double[s] : nullptr}
    {
        for (int i = 0; i < sz; ++i) elem[i] = 0;
        cout << "  Vec(" << sz << ")\n";
    }
    ~Vec() { cout << "  ~Vec(" << sz << ")\n"; delete[] elem; }

    Vec(const Vec& a)                              // costruttore di COPIA: deep copy
        : sz{a.sz}, elem{a.sz > 0 ? new double[a.sz] : nullptr}
    {
        copy(a.elem, a.elem + sz, elem);
        cout << "  copia-costruzione (" << sz << ")\n";
    }
    Vec& operator=(const Vec& a)                   // assegnamento di COPIA
    {
        cout << "  assegnamento di copia\n";
        double* p = a.sz > 0 ? new double[a.sz] : nullptr;   // 1. nuovo spazio
        copy(a.elem, a.elem + a.sz, p);                       // 2. copia gli elementi
        delete[] elem;                                        // 3. libera i vecchi
        elem = p;                                             // 4. cambia i membri
        sz = a.sz;
        return *this;                                         // 5. self-reference
    }

    Vec(Vec&& a) noexcept : sz{a.sz}, elem{a.elem}  // costruttore di SPOSTAMENTO: ruba il buffer
    {
        a.sz = 0;
        a.elem = nullptr;                          // la sorgente resta valida ma vuota
        cout << "  spostamento-costruzione (" << sz << ")\n";
    }
    Vec& operator=(Vec&& a) noexcept               // assegnamento di SPOSTAMENTO
    {
        cout << "  assegnamento di spostamento\n";
        if (this != &a) {
            delete[] elem;
            elem = a.elem;
            sz = a.sz;
            a.elem = nullptr;
            a.sz = 0;
        }
        return *this;
    }

    int size() const { return sz; }
    double& operator[](int i) { return elem[i]; }
    double operator[](int i) const { return elem[i]; }

private:
    int sz;
    double* elem;
};

Vec costruisci(int n)
{
    Vec res(n);                  // oggetto locale
    res[0] = 1;
    return res;                  // sparisce alla }: il compilatore lo ELIDE o lo SPOSTA, non lo copia
}

int main()
{
    cout << "1) Vec v(3);\n";
    Vec v(3);
    v[2] = 2.2;
    cout << "2) Vec v2 = v;      (copia)\n";
    Vec v2 = v;
    cout << "3) Vec v3(4); v3 = v;   (assegnamento di copia)\n";
    Vec v3(4);
    v3 = v;
    cout << "4) v3 = alias di v3;   (auto-assegnamento)\n";
    Vec& alias = v3;
    v3 = alias;
    cout << "   v3[2] = " << v3[2] << " (ancora corretto)\n";
    cout << "5) Vec v4 = costruisci(2);\n";
    Vec v4 = costruisci(2);
    cout << "6) Vec v5 = std::move(v);   (spostamento)\n";
    Vec v5 = std::move(v);
    cout << "   v.size() = " << v.size() << ", v5.size() = " << v5.size() << '\n';
    cout << "7) v4 = std::move(v5);      (assegnamento di spostamento)\n";
    v4 = std::move(v5);
    cout << "fine main\n";
    return 0;
}

Uscita normale (con copy elision):

1) Vec v(3);
  Vec(3)
2) Vec v2 = v;      (copia)
  copia-costruzione (3)
3) Vec v3(4); v3 = v;   (assegnamento di copia)
  Vec(4)
  assegnamento di copia
4) v3 = alias di v3;   (auto-assegnamento)
  assegnamento di copia
   v3[2] = 2.2 (ancora corretto)
5) Vec v4 = costruisci(2);
  Vec(2)
6) Vec v5 = std::move(v);   (spostamento)
  spostamento-costruzione (3)
   v.size() = 0, v5.size() = 3
7) v4 = std::move(v5);      (assegnamento di spostamento)
  assegnamento di spostamento
fine main
  ~Vec(0)
  ~Vec(3)
  ~Vec(3)
  ~Vec(3)
  ~Vec(0)

Guardare il punto 5: Vec v4 = costruisci(2); stampa solo Vec(2): l'oggetto res è costruito direttamente in v4. Compilato con -fno-elide-constructors, la stessa riga stampa tre cose:

5) Vec v4 = costruisci(2);
  Vec(2)
  spostamento-costruzione (2)
  ~Vec(0)

cioè: crea res, sposta il suo buffer nel risultato, distrugge res (ormai vuoto, sz = 0). Lo spostamento (punto 6) lascia la sorgente v vuota ma valida (v.size() = 0); l'assegnamento di spostamento (punto 7) scambia i buffer senza copiare. A fine main i distruttori sono chiamati in ordine inverso alla dichiarazione: v5 (già svuotato, ~Vec(0)), v4, v3, v2, v (svuotato).

Una nota di stile: le funzioni di spostamento si dichiarano noexcept, perché std::vector e i contenitori della STL le usano per spostare gli elementi quando si riallocano solo se non possono lanciare.

I cinque: copia, spostamento, distruttore

Una classe che possiede una risorsa ha di solito bisogno di tutte e cinque le funzioni insieme (regola del cinque; fino al C++03 erano tre):

funzione scopo
distruttore ~T() rilascia la risorsa
costruttore di copia T(const T&) deep copy alla creazione
assegnamento di copia T& operator=(const T&) deep copy su oggetto esistente
costruttore di spostamento T(T&&) noexcept cede la risorsa
assegnamento di spostamento T& operator=(T&&) noexcept cede la risorsa su oggetto esistente

Il compilatore genera le versioni di default (membro a membro) di quelle che non scriviamo, con qualche eccezione: dichiarare il costruttore di copia o il distruttore sopprime la generazione automatica delle operazioni di spostamento. Due forme esplicite:

cpp
T(const T&) = delete;              // vieta la copia: errore di compilazione se qualcuno copia
T& operator=(const T&) = delete;
T(T&&) noexcept = default;         // chiede esplicitamente la versione generata

Il tentativo di copiare un oggetto non copiabile dà call to deleted constructor of 'Unico'. È la scelta per i tipi che non devono essere copiati, come uno unique_ptr (C++ - smart pointerGli smart pointer (classi template di ) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer →). Quando la risorsa è gestita da membri che hanno già le cinque funzioni giuste (std::vector, std::string, std::unique_ptr), le versioni generate vanno bene: non si scrive niente (regola dello zero).

Domande d'esame

1. Cosa sono costruttore di default, di copia e di spostamento? Differenze, classe con un puntatore a un buffer di int, esempi di chiamata. Traccia. Default: senza argomenti, stato iniziale (buf = nullptr/zeri); copia: T(const T&), crea una deep copy (alloca e copia gli elementi), chiamato da T b = a;, T b {a};, passaggio per valore; spostamento: T(T&&) noexcept, ruba il puntatore e lascia la sorgente vuota, chiamato da T b = std::move(a); o dal ritorno di una funzione. Soluzione: Esercizio - Costruttore di default, di copia e di spostamento (terzo appello 2021-22, teoria).

2. Shallow copy e deep copy: differenza ed esempi. Traccia. Shallow = copia dei puntatori (pointer semantics), pericolosa con delete; deep = copia dei dati (value semantics), richiede costruttore e assegnamento di copia. Soluzione: Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria).

3. Perché l'assegnamento di copia alloca prima e libera dopo? E il caso v = v? Traccia. Per non perdere i dati prima di essere sicuri di poterli rimpiazzare (e per il self-assignment, dove liberare prima distruggerebbe la sorgente).

4. Il costruttore di spostamento ha argomenti const? Che cosa significa &&? Il compilatore lo chiama da solo? Traccia. No: la sorgente va modificata. && è una rvalue reference. Sì, il compilatore lo sceglie quando la sorgente è un temporaneo o std::move(x); se manca si usa la copia.

5. Cosa fa explicit? Traccia. Vieta la conversione implicita da un argomento al tipo della classe; si usa nella dichiarazione del costruttore.

Versione ripasso

  • Buona interfaccia: completa e minimale; costruttori; copia supportata o proibita; tipi adeguati; funzioni const; libera tutte le risorse nel distruttore.
  • Costruttore di default (chiamabile senza argomenti): se manca, Date d; non compila (Date d0; Date d1 {}; Date d2 {1998}; errori). Valori di default: in-class initializer (int y {2001};) oppure argomenti di default (Date(int y = 2001, Month m = Month::jan, int d = 1); dopo il primo default tutti i successivi). Non mescolare con un costruttore senza argomenti (ambiguo). Built-in: int{} = 0.
  • explicit sul costruttore a un argomento: vieta Records m = 5; e usa(7) (no viable conversion); sì Records m {5}. Si scrive solo nella dichiarazione.
  • initializer_list<double>: vector(initializer_list<double> lst) : sz{static_cast<int>(lst.size())}, elem{new double[lst.size()]} { copy(lst.begin(), lst.end(), elem); } (il static_cast<int> evita il narrowing size_t →\to int vietato dalle graffe). vector v1 {3} = 1 elemento (3); vector v2(3) = 3 elementi (0.0); v11 = {1,2,3} ≡\equiv v12 {1,2,3}.
  • Copia di default = membro a membro →\to shallow copy: v e v2 condividono elem; modifica visibile in entrambi; all'uscita doppio delete[].
  • Costruttore di copia (deep): vector::vector(const vector& arg) : sz{arg.sz}, elem{new double[arg.sz]} { copy(arg.elem, arg.elem + sz, elem); }.
  • Assegnamento di copia (deep): double* p = new double[a.sz]; copy(a.elem, a.elem + a.sz, p); delete[] elem; elem = p; sz = a.sz; return *this;. Prima alloca/copia, poi libera i vecchi: non si cancella finché non si è sicuri; gestisce v = v.
  • Terminologia: shallow = pointer/reference semantics; deep = value semantics (std::vector, std::string).
  • this = puntatore all'oggetto corrente, parametro implicito, immutabile (this = p errore); return *this; self-reference; incrementa().incrementa(5).incrementa(10) = 16.
  • Spostamento: vector(vector&& a) noexcept : sz{a.sz}, elem{a.elem} { a.sz = 0; a.elem = nullptr; }; operator=(vector&&) libera i propri dati, ruba quelli della sorgente, la annulla. && = rvalue reference; argomento non const. Il compilatore sceglie copia o spostamento; senza move si usa la copia. Copy elision: a volte l'oggetto è costruito direttamente nel chiamante (-fno-elide-constructors per disattivarla): Vec v4 = costruisci(2); stampa Vec(2), senza elision Vec(2), spostamento-costruzione, ~Vec(0).
  • I cinque: distruttore, costruttore e assegnamento di copia, costruttore e assegnamento di spostamento; = delete vieta (call to deleted constructor), = default chiede quella generata. Se i membri gestiscono già le risorse (vector, string, unique_ptr) non si scrive niente.
  • La classe completa in breve (tutte le operazioni insieme):
cpp
class Vec {
public:
    explicit Vec(int s = 0) : sz{s}, elem{s > 0 ? new double[s] : nullptr} { /* zeri */ }
    ~Vec() { delete[] elem; }
    Vec(const Vec& a) : sz{a.sz}, elem{a.sz > 0 ? new double[a.sz] : nullptr}
    { copy(a.elem, a.elem + sz, elem); }
    Vec& operator=(const Vec& a)                    // alloca, copia, libera, cambia, ritorna
    { double* p = new double[a.sz]; copy(a.elem, a.elem + a.sz, p);
      delete[] elem; elem = p; sz = a.sz; return *this; }
    Vec(Vec&& a) noexcept : sz{a.sz}, elem{a.elem} { a.sz = 0; a.elem = nullptr; }
    Vec& operator=(Vec&& a) noexcept
    { if (this != &a) { delete[] elem; elem = a.elem; sz = a.sz; a.elem = nullptr; a.sz = 0; }
      return *this; }
private:
    int sz;
    double* elem;
};
  • Traccia del programma di prova (ogni riga è una chiamata): Vec v(3) →\to Vec(3); Vec v2 = v; →\to copia-costruzione; v3 = v; →\to assegnamento di copia; auto-assegnamento (via alias) →\to resta corretto (v3[2] = 2.2); Vec v5 = std::move(v); →\to spostamento, poi v.size() = 0, v5.size() = 3; v4 = std::move(v5); →\to assegnamento di spostamento; i distruttori, in ordine inverso: ~Vec(0), ~Vec(3), ~Vec(3), ~Vec(3), ~Vec(0).
  • Condivisione di buffer: con la copia di default, v2.set(2, 9.9) cambia anche v (v.get(2) = 9.9) e il buffer sarebbe liberato 2 volte; con la deep copy w.get(2) = 2.2, w2.get(2) = 9.9, buffer liberato una volta per oggetto.
  • Domande tipiche: (1) tre costruttori e loro differenze (default = senza argomenti; copia = const T&, duplica i dati; spostamento = T&&, cede i dati); (2) shallow/deep con due classi d'esempio; (3) perché prima si alloca e poi si libera; (4) && non è const perché la sorgente si modifica; (5) explicit vieta la conversione implicita.
  • Quale funzione è chiamata, riga per riga (con Vec v(3); già dichiarato):
scrittura chiama
Vec a; costruttore di default (se esiste)
Vec b = v;, Vec b {v};, Vec b(v); costruttore di copia
Vec c; c = v; assegnamento di copia (c esisteva già)
Vec d = std::move(v); costruttore di spostamento
d = std::move(c); assegnamento di spostamento
void f(Vec x); con f(v) costruttore di copia (parametro per valore)
f(Vec(2)); costruttore a un argomento, e nessuna copia (elisione garantita da C++17)
Vec g() { Vec r(2); return r; } elisione, oppure spostamento (mai copia se c'è il move)
vector<Vec> w; w.push_back(v); copia; con std::move(v) o un temporaneo: spostamento
  • Perché noexcept: quando vector<Vec> rialloca, sposta gli elementi solo se lo spostamento è noexcept (altrimenti, per mantenere la garanzia forte, li copia).
  • Inizializzazione degli oggetti membro: nella lista : sz{s}, elem{new double[s]} i membri sono inizializzati nell'ordine di dichiarazione nella classe (non nell'ordine della lista); membri const e reference si possono inizializzare solo lì.
  • Ordine di distruzione: inverso a quello di costruzione; delete[] elem nel distruttore, poi i membri.
  • Tracce d'esame: default/copia/spostamento: chi li chiama (T b = a;, T b = std::move(a);, ritorno di funzione); shallow contro deep; perché alloca prima e libera dopo; && e noexcept.
  • Errori tipici: copiare un puntatore invece del contenuto; dimenticare il distruttore o l'assegnamento (regola del cinque); liberare prima di copiare (rovina v = v); spostamento che non annulla la sorgente (doppia delete); const negli argomenti di move; dimenticare explicit; usare vector v(3) credendo di creare un solo elemento.
  • Sintesi della regola del cinque: se una classe possiede una risorsa (puntatore a memoria dinamica, file, lock) servono insieme distruttore, costruttore e assegnamento di copia, costruttore e assegnamento di spostamento. Se non la possiede (solo int, string, vector, smart pointer) non si scrive nessuno dei cinque (regola dello zero): quelli generati sono corretti. Se non si vuole la copia: = delete (come unique_ptr e le basi di gerarchie).
  • Domande d'esame ricorrenti su questa nota: (a) differenze fra costruttore di default, copia e spostamento, con una classe a un puntatore: Esercizio - Costruttore di default, di copia e di spostamento (terzo appello 2021-22, teoria); (b) shallow e deep copy: Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria); (c) distruttore e le quattro operazioni di una classe con buffer e vector: Esercizio - Distruttore, copia e spostamento della classe Elements (secondo appello 2021-22, programmazione).
  • Quiz tipici: il costruttore di copia di default copia il contenuto di un vector membro (vero) ma non quello puntato da un puntatore nudo (falso); dichiarare un costruttore di spostamento disabilita la copia implicita; explicit si scrive nella dichiarazione, non nella definizione.

Esercizi su questo argomento

Teoria collegata