Programmazione Scientifica++: Lezione 9

Distruttori, RAII e Metodi Costanti

Docente

I Distruttori (~Classe)

Come gestiamo i delete dentro agli oggetti? Se il Costruttore prepara l’oggetto alla nascita, il Distruttore fa le pulizie al momento della morte!

  • Si chiama in automatico all’istante esatto in cui l’oggetto muore (es. esce dallo scope).
  • Ha lo stesso nome della classe preceduto da Tilde ~.
  • Non ha tipo di ritorno né argomenti.
class DatabaseConnection {
public:
    DatabaseConnection() { std::cout << "Aperta!\n"; }
    
1    ~DatabaseConnection() { std::cout << "Chiusa!\n"; }
};

int main() {
    { // Apro scope
        DatabaseConnection db; // Stampa: Aperta!
2    } // L'oggetto db MUORE. Stampa: Chiusa!
    
    return 0;
}
1
Il codice di pulizia.
2
Viene invocato invisibilmente.

L’Ordine di Distruzione (LIFO)

In che ordine muoiono gli oggetti allocati nello Stack? Nell’ordine esattamente inverso a quello in cui sono stati creati! (Last In, First Out).

class Elemento {
public:
    int id_;
    Elemento(int i) { id_ = i; std::cout << "Nato " << id_ << "\n"; }
    ~Elemento() { std::cout << "Morto " << id_ << "\n"; }
};

int main() {
    Elemento a(1);
    Elemento b(2);
    Elemento c(3);
    
1    return 0;
}
1
L’output a schermo sarà rigorosamente: Morto 3 Morto 2 Morto 1

Distruttori e Memoria Dinamica

Il Distruttore è il posto naturale in cui posizionare i delete per le variabili allocate internamente all’oggetto. Questo garantisce che l’oggetto non generi Memory Leak quando muore!

MyLayer.hpp

class MyLayer {
private:
    double* pesi_;
    
public:
    MyLayer(int n); // Costruttore
    ~MyLayer(); // Distruttore
};

MyLayer.cpp

MyLayer::MyLayer(int n) {
1    pesi_ = new double[n];
}

MyLayer::~MyLayer() {
2    delete[] pesi_;
}
1
Acquisizione memoria all’istanziazione.
2
Nessun Leak! L’oggetto si auto-ripulisce, anche in caso di return prematuro!

Il Paradigma RAII (1/2)

Quello che abbiamo appena visto è uno dei design pattern più importanti del mondo C++. Si chiama RAII (Resource Acquisition Is Initialization).

L’idea è semplice ma geniale: - L’acquisizione di una Risorsa (Memoria Heap, un File, un Lock) deve avvenire nel Costruttore. - Il rilascio della risorsa deve avvenire sempre e solo nel Distruttore.

In questo modo, siccome il C++ garantisce che i distruttori vengano chiamati sempre e infallibilmente alla distruzione dell’oggetto, è impossibile scordarsi di liberare le risorse!

Il Paradigma RAII (2/2)

Ecco un esempio senza memoria heap, ma con un file fisico!

#include <fstream>

class Logger {
private:
1    std::ofstream file_;

public:
    Logger(const std::string& nomefile) {
        file_.open(nomefile); // 1. Acquisizione nel Costruttore
    }
    
    ~Logger() {
2        file_.close(); // 2. Rilascio nel Distruttore
    }
    
    void scrivi(const std::string& msg) { file_ << msg; }
};
1
L’oggetto che gestisce l’accesso al disco.
2
Anche se il programma subisce un errore o ritorna prima del previsto, quando Logger esce dallo scope, il S.O. chiuderà il file in modo pulito e sicuro!

Metodi const: L’Incapsulamento Totale

Ripassiamo il principio di minor privilegio. Immagina un banale metodo Getter per leggere una variabile. Non dovrebbe mai poterla alterare, no?

class Datum {
private:
    double value_;
    
public:
1    double getValue() {
        value_ = 999.0; // Oops, ho alterato il dato!
        return value_;
    }
};
1
Il metodo non è protetto. Se per sbaglio l’autore ci inserisce una modifica, il compilatore lo lascia fare, e i bug si moltiplicano.

La protezione const sui metodi

Aggiungendo la parola chiave const alla fine della firma del metodo, promettiamo al compilatore che questo metodo agirà in “Sola Lettura” per l’intero oggetto!

class Datum {
private:
    double value_;
    
public:
    // SETTER: Può e deve modificare. NIENTE const.
    void setValue(double x) { 
        value_ = x; 
    }
    
    // GETTER: Sola lettura. SI const.
1    double getValue() const {
2        // value_ = 999.0; // ERRORE DI COMPILAZIONE!
        return value_; 
    }
};
1
Va messo sia nell’Header che nell’implementazione .cpp.
2
Ora il compilatore fa la guardia. Se provi a scrivere su una variabile membro, blocca la compilazione.

Metodi Const e Riferimenti Const

C’è un secondo motivo per dichiarare i metodi const. Se passi un oggetto tramite Costante per Reference (const Class&), il compilatore ti proibirà di chiamare qualsiasi metodo che non sia marchiato const!

1void stampa(const Datum& d) {
2    // std::cout << d.getValue(); // ERRORE SE getValue NON È CONST!
}
1
Ho promesso che non modificherò d.
2
Se getValue() non fosse marcato const, il compilatore direbbe: “Non so cosa faccia getValue(). Potrebbe alterare d! Per sicurezza, lo blocco”.

Important

Tutti i metodi che non alterano lo stato della classe devono terminare con la keyword const. Altrimenti i tuoi utenti non potranno mai passarli per const reference!

Esempio Pratico: Passaggio per Const Reference

Vediamo le implicazioni della regola aurea su un codice reale:

class Vettore {
private:
    double x_, y_;
public:
    Vettore(double x, double y) { x_ = x; y_ = y; }
    
    void scalo(double f) { x_ *= f; y_ *= f; } // NON CONST: Modifica l'oggetto
    double lunghezza() const { return std::sqrt(x_*x_ + y_*y_); } // CONST: Sola Lettura
};

1void analizzaVettore(const Vettore& v) {
2    // v.scalo(2.0); // ERRORE! v è const, scalo() non lo è.
    
3    std::cout << v.lunghezza(); // OK! lunghezza() è const.
}
1
Passiamo un riferimento veloce, ma in sola lettura.
2
Il compilatore protegge v da metodi invasivi.
3
Il compilatore accetta la chiamata, fiducioso della promessa fatta dalla keyword const.

Argomenti di Default nelle Classi

Gli argomenti di default li conoscete dalla Lezione 5. Ora che abbiamo separato interfaccia e implementazione (Lezione 8) si aggiunge una regola che in una funzione libera non c’era:

  1. Il default si dichiara solo nell’interfaccia (file .hpp), mai nel .cpp: ripeterlo è un errore di compilazione.
  2. Resta la regola di sempre: si omettono solo a partire da destra.
class Counter {
public:
    // step ha come default 1
1    void increment(int step = 1);
};

// Nel file .cpp (NON rimettere = 1 qui!)
void Counter::increment(int step) {
    count_ += step;
}

int main() {
    Counter c;
    c.increment();   // Usa il default: incrementa di 1
    c.increment(5);  // Ignora il default: incrementa di 5
}
1
Utile per ridurre il numero di costruttori e overloading simili.

Attenzione alle Ambiguità

L’abuso degli argomenti di default porta spesso a conflitti insidiosi.

class Datum {
public:
    Datum() { } // Costruttore 1: Nessun argomento
    
1    Datum(double x = 0.0) { } // Costruttore 2: Un argomento, con default
};

int main() {
2    // Datum d; // ERRORE DI COMPILAZIONE: AMBIGUITÀ!
    
    return 0;
}
1
Bello, un costruttore con default!
2
Il compilatore entra in crisi: “Vuole chiamare il Costruttore 1 senza argomenti, o vuole chiamare il Costruttore 2 usando il default? Non lo so!”. Esito: Error.

Overloading vs Argomenti di Default

Quando conviene usare gli argomenti di default e quando conviene scrivere una funzione Overloaded (due funzioni con lo stesso nome)?

Meglio il Default: Quando la logica interna è identica, ma varia solo un parametro di “comodità”.

void stampaMessaggio(std::string msg, bool a_capo = true) {
    std::cout << msg;
    if (a_capo) std::cout << "\n";
}

Meglio l’Overloading: Quando la logica interna cambia drasticamente a seconda degli argomenti!

class Vector {
public:
    Vector(double x, double y); // Inizializza da cartesiane
    
    Vector(double angolo);      // Inizializza da un angolo (logica diversa!)
};

Esercizio in Classe (20 min)

Costruiamo il nostro DynamicArray!

  1. Crea due file DynamicArray.hpp e DynamicArray.cpp.
  2. L’array dovrà contenere numeri double.
  3. Nel Costruttore, prendi in input un intero dimensione.
    • Salva la dimensione in un attributo privato.
    • Usa new[] per allocare l’array nello Heap e salvare il puntatore.
  4. Implementa il Distruttore! Stampa “Pulizia RAM” e usa delete[].
  5. Implementa un metodo setter imposta(int index, double valore). (Inserisci un check per evitare accessi out-of-bounds!).
  6. Implementa un metodo getter leggi(int index) const che restituisca il valore. (Nota il const!).

(Prova a testarlo dal main in un loop infinito: con il distruttore la RAM resterà stabile, senza il distruttore esploderà!)

Soluzione: L’Header .hpp

DynamicArray.hpp

#pragma once

class DynamicArray {
private:
    int size_;
1    double* data_; // Il puntatore all'Heap

public:
    DynamicArray(int size); // Costruttore
    ~DynamicArray();        // Distruttore
    
    void imposta(int index, double value);
2    double leggi(int index) const; // Getter rigorosamente const
};
1
Il puntatore che terrà traccia dell’enorme quantità di memoria.
2
Promettiamo che non altererà l’array.

Soluzione: Il Source .cpp

DynamicArray.cpp

#include "DynamicArray.hpp"
#include <iostream>

DynamicArray::DynamicArray(int size) {
    size_ = size;
1    data_ = new double[size_];
    std::cout << "Allocati " << size_ << " elementi.\n";
}

DynamicArray::~DynamicArray() {
2    delete[] data_;
    std::cout << "Memoria liberata correttamente!\n";
}

void DynamicArray::imposta(int index, double value) {
    if (index >= 0 && index < size_) { // Protezione boundaries
        data_[index] = value;
    }
}

double DynamicArray::leggi(int index) const {
    if (index >= 0 && index < size_) return data_[index];
    return 0.0;
}
1
L’acquisizione della memoria avviene alla nascita dell’oggetto.
2
Il rilascio avviene alla morte. Zero Memory Leaks! RAII in azione.