Deep Copy, Regola del Tre e Overloading degli Operatori
this
Nelle scorse lezioni abbiamo visto come una classe possa usare l’Heap per gestire una quantità di dati non nota a priori. Queste classi “pesanti” avranno al loro interno dei Puntatori come attributi.
class SequentialNetwork {
private:
1 Layer** layers_; // Un array di puntatori a Layer
int num_layers_;
public:
SequentialNetwork(int n);
~SequentialNetwork();
};Per gestire questa classe senza causare Memory Leaks, applichiamo il pattern RAII: acquisiamo la memoria nel Costruttore e la rilasciamo nel Distruttore.
Costruttore (Nascita)
SequentialNetwork::SequentialNetwork(int n) {
num_layers_ = n;
// Alloco dinamicamente la memoria
1 layers_ = new Layer*[num_layers_];
}Distruttore (Morte)
SequentialNetwork::~SequentialNetwork() {
// Libero tutta la memoria dell'Heap
2 delete[] layers_;
}Nel nostro main.cpp creiamo una rete neurale e la usiamo. Non ci sono perdite di memoria. L’architettura è solida.
Ma cosa succede se un vostro utente cerca di copiare la rete?
In C++, non sei obbligato a scrivere una funzione speciale per dire come copiare un oggetto. Il compilatore ti regala l’operatore di assegnamento =.
int main() {
// 1. Creo una particella
Particella p1;
p1.massa = 2.0;
// 2. Creo una seconda particella
Particella p2;
// 3. Le copio!
1 p2 = p1;
return 0;
}p1 dentro p2. Essendo formati solo da numeri (es. double), questa copia funziona perfettamente!
Proviamo ad usare lo stesso identico assegnamento gratuito sulla nostra rete neurale “pesante” che alloca memoria dinamica.
int main() {
SequentialNetwork net1(5); // Crea una rete con 5 layer
SequentialNetwork net2(10); // Crea una rete con 10 layer
// Copiamo la rete 1 nella rete 2
1 net2 = net1;
return 0;
}= interviene di nuovo. Fa la sua copia bit-per-bit di tutti gli attributi di net1 dentro net2. Ma cosa sta copiando esattamente?
Il compilatore ha copiato il numero intero num_layers_ (5 al posto di 10). Ottimo. Poi ha copiato la variabile layers_ da net1 a net2.
Cosa c’era in layers_? Un Puntatore, cioè un indirizzo di memoria! (es. 0x1A2B3C).
Il compilatore non ha copiato i layer veri e propri. Ha copiato l’indirizzo! Ora net1 e net2 possiedono lo stesso identico indirizzo dell’Heap.
Questa tragedia informatica è chiamata Shallow Copy (Copia Superficiale).
Dopo aver scritto net2 = net1; la situazione in memoria è questa:
Stack (I nostri oggetti) - net1.layers_ punta a 0x1A2B3C - net2.layers_ punta a 0x1A2B3C
Heap (I Dati Reali) L’indirizzo 0x1A2B3C contiene l’array dei 5 Layer. L’array dei vecchi 10 Layer di net2 non è più puntato da nessuno! (Abbiamo appena generato un Memory Leak!).
Due oggetti si stanno contendendo la stessa fetta di torta.
Il codice continua la sua esecuzione illudendo il programmatore che tutto vada bene. Poi, arriviamo alla riga magica return 0;. Il programma sta per chiudersi e gli oggetti sullo stack muoiono.
int main() {
SequentialNetwork net1(5);
SequentialNetwork net2(10);
net2 = net1; // Errore invisibile (Shallow Copy)
1 return 0;
}Passo 1: Muore net2. Viene invocato il suo distruttore: delete[] layers_;. Il C++ va all’indirizzo 0x1A2B3C e cancella tutto l’array dei 5 Layer.
Subito dopo… Passo 2: Muore net1. Viene invocato il suo distruttore: delete[] layers_;.
Il C++ va all’indirizzo 0x1A2B3C… e cerca di cancellare i dati! Ma net2 li ha già polverizzati un microsecondo prima!
Stai cercando di de-allocare al S.O. una memoria che non ti appartiene più. Esito: Crash istantaneo, Abort, Double Free Corruption.
Esiste una regola famosissima nel C++ chiamata Rule of Three:
Important
Se la tua classe è così complessa da aver bisogno di un Distruttore esplicito (perché usi new nel costruttore), quasi certamente il C++ non sarà in grado di copiare gli oggetti da solo in modo sicuro. Devi sovrascrivere l’Operatore di Assegnamento.
Dobbiamo dire al C++ che, per la nostra classe, l’operatore = non deve fare una copia bit-per-bit.
Dobbiamo scrivere noi una funzione che insegni all’operatore = a fare una Deep Copy (Copia Profonda): Alloca nuova memoria nello Heap, e copia i veri valori uno ad uno!
Con una Deep Copy, ecco cosa succede quando scrivi net2 = net1;:
Stack (I nostri oggetti) - net1.layers_ punta a 0x1A2B3C - net2.layers_ punta a 0x8F9E7D // INDIRIZZO NUOVO!
Heap (I Dati Reali) - 0x1A2B3C contiene l’array dei 5 Layer originali. - 0x8F9E7D contiene l’array dei 5 Layer fotocopiati.
Alla fine del programma, entrambi i distruttori chiameranno delete sul proprio puntatore. Nessun Double Free, e il programma esce pulito!
Per sovrascrivere l’operatore =, dobbiamo dichiarare un metodo speciale.
class SequentialNetwork {
public:
// Questa è la firma obbligatoria!
1 const SequentialNetwork& operator=(const SequentialNetwork& rhs);
};rhs sta per Right Hand Side (il lato destro dell’uguaglianza). Quando scrivi net2 = net1, il C++ invoca il metodo operator= sull’oggetto net2, passandogli net1 come parametro (il lato destro).
thisPrima di implementare la Deep Copy, c’è un trucco da sapere. Dentro a qualsiasi metodo di una classe, il C++ inietta silenziosamente un puntatore speciale chiamato this.
this punta all’oggetto stesso che sta eseguendo il metodo!
class Esempio {
private:
int x_;
public:
void stampaIndirizzo() {
1 std::cout << "Il mio indirizzo in RAM è: " << this << "\n";
}
};this in azioneIl this è utilissimo per risolvere le ambiguità (quando un parametro della funzione ha lo stesso nome di una variabile membro).
class Esempio {
private:
int parametro;
public:
void setParametro(int parametro) {
// parametro = parametro; // NON HA SENSO!
1 this->parametro = parametro;
}
};this-> (l’operatore freccia perché this è un puntatore), specifichiamo inequivocabilmente: “La variabile dell’oggetto = la variabile della funzione”.
Il motivo principale per cui ci serve this nella Deep Copy, è prevenire una cosa stupida ma letale che i programmatori fanno spesso: l’auto-assegnamento.
Se la prima cosa che fa il nostro operatore = per fare spazio ai nuovi dati è un delete della propria memoria… ci distruggeremmo i dati prima ancora di averli potuti fotocopiare!
Dobbiamo inserire una clausola di salvaguardia usando this.
Ecco finalmente il codice salvavita per le classi pesanti!
const SequentialNetwork& SequentialNetwork::operator=(const SequentialNetwork& rhs) {
// 1. Clausola di auto-assegnamento (salvavita!)
if (this == &rhs) { // "Se il mio indirizzo è uguale a quello del lato destro"
return *this; // Restituisci l'oggetto intatto (deferenziando il this)
}
// 2. Libero la mia vecchia memoria! (Evito un Memory Leak)
delete[] this->layers_;
// 3. Copio le grandezze base
this->num_layers_ = rhs.num_layers_;
// 4. ALLoco NUOVA MEMORIA (Deep Copy!)
this->layers_ = new Layer*[this->num_layers_];
// 5. Fotocopio i dati uno ad uno!
for(int i = 0; i < num_layers_; ++i) {
this->layers_[i] = rhs.layers_[i];
}
// 6. Restituisco me stesso (permette roba come a = b = c;)
return *this;
}Ora che abbiamo “sovraccaricato” (Overload) l’operatore =, possiamo farlo anche con gli altri operatori matematici?
Sì! In C++ puoi sovraccaricare +, -, *, <<, == e molti altri, affinché funzionino in modo “naturale” con le tue classi personalizzate.
Immagina di avere una classe Vettore2D. Vuoi sommare due vettori.
Senza Overloading: Devi creare una funzione dal nome fittizio.
Vettore2D v1(1.0, 2.0);
Vettore2D v2(3.0, 4.0);
1Vettore2D v3 = v1.somma(v2);v1.somma(v2.somma(v3))! Orribile.
Con Overloading: Usiamo la matematica come natura comanda.
Vettore2D v1(1.0, 2.0);
Vettore2D v2(3.0, 4.0);
2Vettore2D v3 = v1 + v2;v1 + v2 + v3 funziona perfettamente!
Come si definisce? Esattamente come abbiamo fatto per l’operatore =, ma cambiando simbolo!
class Vettore2D {
public:
// Questa funzione definisce cosa fa il simbolo "+"
1 Vettore2D operator+(const Vettore2D& rhs) const;
};const finale! Sommare due vettori genera un nuovo vettore, ma NON altera i due vettori originali (principio di minor privilegio!). L’operatore = invece alterava l’oggetto, per cui non era marcato const alla fine.
Vediamo come si implementa l’Header.
Vettore2D.hpp
+ in azioneImplementiamolo nel Source file!
Vettore2D.cpp
#include "Vettore2D.hpp"
// Restituisco un nuovo Vettore2D
1Vettore2D Vettore2D::operator+(const Vettore2D& rhs) const {
// Sommo le componenti X e Y separatamente (regola del parallelogramma)
double nuovo_x = this->x_ + rhs.x_;
double nuovo_y = this->y_ + rhs.y_;
// Restituisco un nuovissimo oggetto nato da questi valori!
2 return Vettore2D(nuovo_x, nuovo_y);
}operator+ deve restituire una Matrix. Ma se a è 3×3 e b è 2×2, quale matrice restituiamo? In C si sarebbe restituito un codice d’errore, o stampato un messaggio e chiamato exit(). Nessuna delle due cose è possibile qui: il tipo di ritorno è già deciso, e uccidere il programma da dentro una libreria è inaccettabile.
C++ ha un meccanismo apposta: lanciare un’eccezione.
std::invalid_argument, std::out_of_range, std::runtime_error.
throw interrompe la funzione all’istante e consegna al chiamante un oggetto che descrive l’errore. Nessun valore di ritorno fasullo, nessun errore che passa inosservato.
Il chiamante può intercettarla con try/catch. Se non lo fa, l’eccezione risale la catena delle chiamate fino a main, e il programma termina stampando il messaggio: molto meglio di un risultato sbagliato in silenzio.
throw dentro operator+ fa saltare tutto il resto del blocco try.
e.what() restituisce la stringa passata a throw.
Note
Da oggi, nel Laboratorio 05, userete throw per proteggere costruttore e indicizzazione di Matrix. Questo è tutto ciò che vi serve per adesso. Il meccanismo completo — la gerarchia dei tipi di errore, cosa succede alla memoria mentre l’eccezione risale, quando non usarle — è materia delle Lezioni 19 e 20.
Cosa NON puoi fare con l’Overloading degli operatori:
@ o ** come in Python). Puoi solo usare quelli preesistenti.1 + 1 degli interi restituisca 3).+ deve rimanere binario, non può prendere tre oggetti alla volta).Warning
L’Abuso degli Operatori: Usate gli operatori solo se il significato logico è scontato. Un * tra due vettori è intuibile (prodotto scalare). Ma un * tra due classi “Studente” non significa niente! Creerete solo codice impossibile da debuggare.
Espandiamo la classe Vettore2D!
Vettore2D appena descritta in due file separati.+. Ora implementa l’overloading dell’operatore * (prodotto per uno scalare).main: Vettore2D v2 = v1 * 5.0;. (Suggerimento: il lato destro dell’operazione, la variabile rhs, non sarà un altro Vettore2D, ma sarà un semplice double!).cout in un file main.cpp e verifica che la moltiplicazione per 5.0 abbia scalato sia x_ che y_!Vettore2D.hpp
class Vettore2D {
// ... codice precedente ...
// Moltiplicazione per uno scalare!
1 Vettore2D operator*(double rhs) const;
};Vettore2D.cpp
Tutto molto logico ed elegante! (Nota: questo funzionerà per v1 * 5.0, ma NON funzionerà per 5.0 * v1. Lo vedremo in futuro con le funzioni Friend).