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
Una buona interfaccia
L'interfaccia di una classe va progettata. Principi generali:
- completa (permette di fare ciò che serve) e minimale (nessuna funzione ridondante);
- fornire i costruttori;
- la copia deve essere supportata o proibita (non lasciata al caso);
- usare tipi adeguati per controllare gli argomenti (un
Monthinvece di unint); - identificare le funzioni membro costanti;
- 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:
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 copiaDefinizione. 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.
#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 0Costruttori 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):
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)).
#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 >= 1Togliendo 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:
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 |
#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 3Copia 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:
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.
#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 voltaPer 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:
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.
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.
int vector::size() const { return this->sz; } // equivale a return sz; (questione di stile)#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
1incrementa() restituisce *this, cioè una reference a c stesso, e perciò si possono concatenare le chiamate: .
Spostamento di oggetti
Una funzione che restituisce un vector costruito dentro di sé:
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.
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.
#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:
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 generataIl 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 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. explicitsul costruttore a un argomento: vietaRecords m = 5;eusa(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); }(ilstatic_cast<int>evita il narrowingsize_tintvietato dalle graffe).vector v1 {3}= 1 elemento (3);vector v2(3)= 3 elementi (0.0);v11 = {1,2,3}v12 {1,2,3}.- Copia di default = membro a membro shallow copy:
vev2condividonoelem; modifica visibile in entrambi; all'uscita doppiodelete[]. - 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; gestiscev = v. - Terminologia: shallow = pointer/reference semantics; deep = value semantics (
std::vector,std::string). this= puntatore all'oggetto corrente, parametro implicito, immutabile (this = perrore);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 nonconst. Il compilatore sceglie copia o spostamento; senza move si usa la copia. Copy elision: a volte l'oggetto è costruito direttamente nel chiamante (-fno-elide-constructorsper disattivarla):Vec v4 = costruisci(2);stampaVec(2), senza elisionVec(2),spostamento-costruzione,~Vec(0). - I cinque: distruttore, costruttore e assegnamento di copia, costruttore e assegnamento di spostamento;
= deletevieta (call to deleted constructor),= defaultchiede 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):
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)Vec(3);Vec v2 = v;copia-costruzione;v3 = v;assegnamento di copia; auto-assegnamento (via alias) resta corretto (v3[2] = 2.2);Vec v5 = std::move(v);spostamento, poiv.size() = 0,v5.size() = 3;v4 = std::move(v5);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 anchev(v.get(2) = 9.9) e il buffer sarebbe liberato 2 volte; con la deep copyw.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 èconstperché la sorgente si modifica; (5)explicitvieta 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: quandovector<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); membriconste reference si possono inizializzare solo lì. - Ordine di distruzione: inverso a quello di costruzione;
delete[] elemnel 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;&&enoexcept. - 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 (doppiadelete);constnegli argomenti di move; dimenticareexplicit; usarevector 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(comeunique_ptre 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
vectormembro (vero) ma non quello puntato da un puntatore nudo (falso); dichiarare un costruttore di spostamento disabilita la copia implicita;explicitsi scrive nella dichiarazione, non nella definizione.
Esercizi su questo argomento
- Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)
- Esercizio - Array dinamico ordinato come classe template (quarto appello 2022-23, programmazione)
- Esercizio - Classe Image con buffer dinamico (terzo appello 2023-24, programmazione)
- Esercizio - ConvertToArray da un container STL (secondo appello 2023-24, programmazione)
- Esercizio - Costruttore di default, di copia e di spostamento (quarto appello 2022-23, teoria)
- Esercizio - Costruttore di default, di copia e di spostamento (terzo appello 2021-22, teoria)
- Esercizio - Distruttore, copia e spostamento della classe Elements (secondo appello 2021-22, programmazione)
- Esercizio - Gerarchia MyPointer, MyUniquePointer e MySharedPointer (terzo appello 2021-22, programmazione)
- Esercizio - InertialDriver con buffer circolare (prova intermedia 2025-26)
- Esercizio - La classe template DynamicStorage (secondo appello 2022-23, programmazione)
- Esercizio - MyVector 2.0 con buffer ridimensionabile (laboratorio 4)
- Esercizio - MyVector con memoria dinamica (laboratorio 3)
- Esercizio - Numeri razionali con classe e overloading (laboratorio 2)
- Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)
- Esercizio - Quiz e domande su smart pointer, function object e aritmetica dei puntatori (terzo appello 2023-24, teoria)
- Esercizio - Quiz e domande su spostamento, passaggio dei parametri e contenitori STL (primo appello 2023-24, teoria)
- Esercizio - Quiz su include guard, librerie dinamiche, explicit e function object (primo appello 2021-22, quiz)
- Esercizio - Quiz su inizializzazione, free store, polimorfismo e passaggio di argomenti (quarto appello 2022-23, quiz)
- Esercizio - Quiz su operatore =, dangling pointer, enumerazioni e at() (quarto appello 2021-22, quiz)
- Esercizio - Quiz su scope, riferimenti, conversioni e puntatori (secondo appello 2022-23, quiz)
- Esercizio - Shallow copy e deep copy (quarto appello 2021-22, teoria)
Teoria collegata
- C++ - allocazione dinamica della memoria con new e delete
- C++ - buona interfaccia e progetti su più file
- C++ - classi, membri e invarianti
- C++ - enumerazioni e overloading degli operatori
- C++ - funzioni, reference e passaggio dei parametri
- C++ - template e programmazione generica
- C++ - tipi, variabili, espressioni e dichiarazioni