Programmazione Scientifica++: Lezione 10

Deep Copy, Regola del Tre e Overloading degli Operatori

Docente

Argomenti di Oggi

  • Membri Dati Allocati Dinamicamente
    • Ripasso: Costruttori e Distruttori
  • Il Disastro della Shallow Copy
    • L’assegnamento di default
    • Double Free
  • La Soluzione: Deep Copy
    • La Rule of Three
  • Il Puntatore Segreto: this
    • Auto-assegnamento
  • Overloading degli Operatori
    • Matematica elegante per le classi

Ripasso: Classi “Pesanti”

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();
};
1
La dimensione esatta dell’array non è nota a tempo di compilazione.

Gestione della Memoria (RAII)

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_];
}
1
Acquisizione.

Distruttore (Morte)

SequentialNetwork::~SequentialNetwork() {
    
    // Libero tutta la memoria dell'Heap
2    delete[] layers_;
}
2
Rilascio. Se non lo facessi, la RAM esploderebbe in pochi minuti!

Tutto sembra perfetto…

Nel nostro main.cpp creiamo una rete neurale e la usiamo. Non ci sono perdite di memoria. L’architettura è solida.

int main() {
    SequentialNetwork net1(5); // Crea una rete con 5 layer
    
    // ... addestro la rete ...
    
    return 0; // "net1" muore qui e il distruttore cancella i layer.
}

Ma cosa succede se un vostro utente cerca di copiare la rete?

L’Assegnamento di Default

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;
}
1
Il compilatore, dietro le quinte, copia bit-per-bit i valori di p1 dentro p2. Essendo formati solo da numeri (es. double), questa copia funziona perfettamente!

L’Inganno: Copiare Classi Pesanti

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;
}
1
L’operatore gratuito = interviene di nuovo. Fa la sua copia bit-per-bit di tutti gli attributi di net1 dentro net2. Ma cosa sta copiando esattamente?

L’Inganno della Shallow Copy

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).

Shallow Copy: Rappresentazione Visiva

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.

La Bomba a Orologeria

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;
}
1
L’ordine di distruzione è LIFO (Last-In, First-Out).

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.

Il Crash (Double Free)

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.

La Regola del Tre (Rule of Three)

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.

La Soluzione: Deep Copy

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!

Deep Copy: Rappresentazione Visiva

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!

Scrivere l’Operatore di Assegnamento

Per sovrascrivere l’operatore =, dobbiamo dichiarare un metodo speciale.

class SequentialNetwork {
public:
    // Questa è la firma obbligatoria!
1    const SequentialNetwork& operator=(const SequentialNetwork& rhs);
};
1
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).

Il puntatore magico this

Prima 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";
    }
};
1
L’oggetto conosce il proprio posto nel mondo.

Il puntatore this in azione

Il 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;
    }
};
1
Usando this-> (l’operatore freccia perché this è un puntatore), specifichiamo inequivocabilmente: “La variabile dell’oggetto = la variabile della funzione”.

Prevenire l’Auto-Assegnamento

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.

net1 = net1; 

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.

Deep Copy: Implementazione completa

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; 
}

Overloading degli Operatori

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.

Perché Overloadare gli Operatori?

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);
1
Brutto da leggere. E se volessi sommare tre vettori? 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;
2
Elegante, compatto, intuitivo! E v1 + v2 + v3 funziona perfettamente!

Sintassi dell’Overloading (Metodi)

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;
};
1
Notare il 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.

Esempio: La classe Vettore2D

Vediamo come si implementa l’Header.

Vettore2D.hpp

#pragma once

class Vettore2D {
private:
    double x_, y_;

public:
    Vettore2D(double x, double y) { x_ = x; y_ = y; }
    
    // Dichiaro la voglia di supportare il "+"
    Vettore2D operator+(const Vettore2D& rhs) const;
    
    double getX() const { return x_; }
    double getY() const { return y_; }
};

Esempio: Operatore + in azione

Implementiamolo 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);
}
1
Firma complessa ma logica.
2
Restituisco un pacchetto anonimo già pronto.

E se le Dimensioni non Tornano?

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.

1#include <stdexcept>

Matrix Matrix::operator+(const Matrix& other) const {
    if (rows_ != other.rows_ || cols_ != other.cols_) {
2        throw std::invalid_argument("Matrix::operator+: dimensioni incompatibili");
    }
    Matrix result(rows_, cols_);
    // ... somma elemento per elemento ...
    return result;
}
1
Qui vivono i tipi di errore standard: std::invalid_argument, std::out_of_range, std::runtime_error.
2
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.

Chi Riceve l’Eccezione?

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.

int main() {
    Matrix a(3, 3), b(2, 2);
    try {
1        Matrix c = a + b;
        std::cout << "Somma calcolata\n";       // mai raggiunto
2    } catch (const std::invalid_argument& e) {
        std::cerr << "Errore: " << e.what() << '\n';
    }
    return 0;
}
1
Il throw dentro operator+ fa saltare tutto il resto del blocco try.
2
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.

Le Regole del Gioco: Cosa è vietato

Cosa NON puoi fare con l’Overloading degli operatori:

  1. Non puoi creare nuovi simboli (es. non puoi creare un operatore @ o ** come in Python). Puoi solo usare quelli preesistenti.
  2. Non puoi cambiare il significato degli operatori per i tipi di base (non puoi fare in modo che 1 + 1 degli interi restituisca 3).
  3. Non puoi cambiare il numero di argomenti (il + 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.

Esercizio in Classe (20 min)

Espandiamo la classe Vettore2D!

  1. Prendi la classe Vettore2D appena descritta in due file separati.
  2. Abbiamo implementato l’operatore +. Ora implementa l’overloading dell’operatore * (prodotto per uno scalare).
  3. Questo operatore dovrà permetterti di scrivere nel 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!).
  4. Metti un semplice cout in un file main.cpp e verifica che la moltiplicazione per 5.0 abbia scalato sia x_ che y_!

Esercizio: Soluzione

Vettore2D.hpp

class Vettore2D {
    // ... codice precedente ...
    
    // Moltiplicazione per uno scalare!
1    Vettore2D operator*(double rhs) const;
};
1
L’argomento non è un riferimento a una classe, ma è solo un banale numero!

Vettore2D.cpp

// Implementazione
Vettore2D Vettore2D::operator*(double scalare) const {
    double nuovo_x = this->x_ * scalare;
    double nuovo_y = this->y_ * scalare;
    
    return Vettore2D(nuovo_x, nuovo_y);
}

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).