Programmazione Scientifica++: Lezione 9

Distruttori, RAII e Metodi Costanti

Cristiano De Michele

Argomenti di oggi

  • Ripasso: puntatori e memoria, con i disegni della memoria
  • Il distruttore (~Classe): il metodo eseguito quando un oggetto viene distrutto
  • Il RAII (Resource Acquisition Is Initialization): una classe che libera da sola la memoria che ha allocato
  • Metodi const: metodi che non possono modificare l’oggetto
  • Argomenti di default nei metodi e nei costruttori
  • Esercizio in classe: un array dinamico

Gli argomenti di oggi nel framework

  • Distruttori e RAII. Nel framework non si scrive mai delete.
    • Le matrici sono matrici di Eigen (Matrix<ntype>): riservano la memoria quando vengono create e la liberano quando vengono distrutte, con il meccanismo che vediamo oggi.
    • I layer di Sequential sono creati nell’heap, ma li possiede la rete, che li libera quando viene distrutta.
  • Metodi const. I metodi che leggono soltanto sono const: get_type() const restituisce il nome del layer, size() const il numero di layer di Sequential.
  • Argomenti di default. Il costruttore è DenseLayer(int Nn, int Ni, int type = 0, int distro = 1): Nn neuroni, Ni ingressi; type e distro scelgono come inizializzare i pesi. Chi indica solo Nn e Ni ottiene type = 0 e distro = 1.

Ripasso: variabili e indirizzi

#include <iostream>

int main() {
    double x = 3.5;
1    double* p = &x;

    std::cout << x << "\n";    // 3.5
2    std::cout << p << "\n";    // un indirizzo
    std::cout << *p << "\n";   // 3.5

3    *p = 7.0;
    std::cout << x << "\n";    // 7
    return 0;
}
1
&x è l’indirizzo di x; p, di tipo double* (puntatore a double), lo contiene.
2
Un numero esadecimale, diverso a ogni esecuzione.
3
*p è il valore all’indirizzo contenuto in p, cioè x: scrivere in *p modifica x.

Ogni riquadro è una variabile: nome sopra, valore dentro, indirizzo (di esempio) sotto. La freccia: p contiene l’indirizzo di x.

Ripasso: lo stack e l’heap

La memoria di un programma ha due zone (Lezione 08).

  • Lo stack contiene le variabili locali, cioè quelle dichiarate dentro una funzione o dentro un blocco { }. Una variabile locale viene liberata da sola quando il programma esce dal blocco in cui è dichiarata.
  • L’heap contiene la memoria chiesta con new. Resta occupata finché il programma non la restituisce con delete (o delete[] per un array).

Una variabile sullo stack può contenere l’indirizzo di memoria nell’heap: è il caso dei prossimi esempi. Quando la variabile viene liberata si perde l’indirizzo, ma la memoria nell’heap resta occupata.

Ripasso: un array nell’heap

#include <iostream>

int main() {
    int n = 4;
1    double* v = new double[n];
    for (int i = 0; i < n; i++) {
2        v[i] = 0.5 * i;
    }
    std::cout << v[3] << "\n";     // 1.5
3    delete[] v;
    return 0;
}
1
new double[n] riserva nell’heap n valori double consecutivi e restituisce l’indirizzo del primo. v sta sullo stack e contiene quell’indirizzo.
2
v[i] è l’elemento i dell’array che comincia a quell’indirizzo, come in C.
3
L’array nell’heap non si libera da solo: serve delete[].

Ripasso: il disegno dell’array nell’heap

  • v è una variabile locale del main: sta sullo stack e contiene l’indirizzo 0x5600a0.
  • I quattro double stanno nell’heap, uno dopo l’altro, a partire da quell’indirizzo.
  • Alla fine del main v viene liberata da sola, perché è sullo stack. L’array no: se manca delete[] v;, i quattro double restano occupati e nessuna variabile ne conosce più l’indirizzo. È un memory leak (Lezione 08).

Ripasso: struct e puntatori

#include <iostream>

struct Buffer {
    int size;
    double* data;
};

int main() {
    Buffer b;
    b.size = 4;
1    b.data = new double[b.size];
    b.data[0] = 1.0;
    std::cout << b.data[0] << "\n";
2    delete[] b.data;
    return 0;
}
1
b sta sullo stack, i 4 double nell’heap: in b.data c’è solo il loro indirizzo.
2
Va scritto a mano, come per la CMatrix del Laboratorio 04.

Alla fine del main viene liberata b, con l’indirizzo contenuto in b.data, ma non l’array: senza delete[] b.data; resta occupato.

Con il distruttore il delete[] si scrive una volta sola, dentro la classe.

Il distruttore

#include <iostream>

class Risorsa {
public:
    Risorsa()  { std::cout << "costruttore\n"; }
    ~Risorsa() { std::cout << "distruttore\n"; }
};

int main() {
    {
        Risorsa r;
        std::cout << "dentro il blocco\n";
    }
    std::cout << "fine\n";
    return 0;
}

Il distruttore è il metodo eseguito quando l’oggetto viene distrutto:

  • si chiama come la classe, preceduto da ~ (riga 6);
  • non ha tipo di ritorno né argomenti;
  • lo chiama il compilatore.

r vive nel blocco delle righe 10–13 e viene distrutta alla graffa della riga 13. Output: costruttore, dentro il blocco, distruttore, fine.

L’ordine di distruzione

Le variabili locali vengono distrutte nell’ordine inverso a quello di creazione: l’ultima creata è la prima distrutta (LIFO, last in, first out).

#include <iostream>

class Elemento {
private:
    int id_;
public:
    Elemento(int id) : id_(id) { std::cout << "creato " << id_ << "\n"; }
    ~Elemento() { std::cout << "distrutto " << id_ << "\n"; }
};

int main() {
    Elemento a(1);
    Elemento b(2);
    Elemento c(3);
1    return 0;
}
1
L’output è creato 1, creato 2, creato 3, poi distrutto 3, distrutto 2, distrutto 1.

Il motivo: un oggetto creato dopo può usare quelli creati prima. Se viene distrutto per primo, durante il suo distruttore gli altri esistono ancora.

Il distruttore e la memoria dinamica

#include <iostream>

class Buffer {
private:
    int size_;
    double* data_;
public:
    Buffer(int size)
        : size_(size), data_(new double[size]) {}
    ~Buffer() { delete[] data_; }
    void azzera() {
        for (int i = 0; i < size_; i++) data_[i] = 0.0;
    }
};

int main() {
    Buffer b(1000);
    b.azzera();
    return 0;
}

La struct del ripasso diventa una classe:

  • righe 8–9: il costruttore alloca l’array (lista di inizializzazione, Lezione 07);
  • riga 10: il distruttore lo libera;
  • riga 19: b viene distrutto, e con lui l’array.

Nel main nessun delete; con -fsanitize=address nessun leak.

Il return anticipato senza leak

#include <iostream>

class Buffer {
private:
    double* data_;
public:
    Buffer(int size) : data_(new double[size]) {}
    ~Buffer() { delete[] data_; }
};

void leggiFile() {
    Buffer buffer(1000);
    bool errore_lettura = true;
    if (errore_lettura) {
        std::cerr << "Errore di lettura\n";
        return;
    }
}

int main() { leggiFile(); return 0; }

Nella Lezione 08 il delete[] mancava sul ramo d’errore. Qui l’array lo alloca il costruttore di buffer (riga 12), variabile locale.

  • Riga 16: con return si esce dalla funzione: buffer viene distrutto e il distruttore libera l’array.
  • Riga 18: senza errore la funzione finisce qui, e succede lo stesso.

Il distruttore viene chiamato a ogni uscita: nessun delete[] da ricordare.

Il RAII

Questo modo di scrivere le classi si chiama RAII (Resource Acquisition Is Initialization, “l’acquisizione di una risorsa avviene nell’inizializzazione”). È un idioma del C++, con tre regole:

  1. la risorsa (memoria nell’heap, un file aperto) si acquisisce nel costruttore;
  2. si rilascia nel distruttore;
  3. l’oggetto che possiede la risorsa si dichiara come variabile locale o come attributo di un altro oggetto, distrutto con lui (come le matrici di Eigen in un layer). Se deve stare nell’heap, lo alloca il costruttore di un altro oggetto e lo libera il suo distruttore, come l’array in Buffer.

Così il distruttore viene eseguito da solo a ogni uscita dal blocco.

Anche la libreria standard segue il RAII: std::vector libera la sua memoria, std::ofstream chiude il suo file.

Il RAII e new

Anche con new il costruttore viene eseguito: new riserva la memoria e chiama il costruttore. Il distruttore è automatico solo per le variabili locali; per un oggetto creato con new lo chiama delete, che poi libera la memoria.

Buffer b(1000); Buffer* p = new Buffer(1000);
Dove sta sullo stack nell’heap; p contiene il suo indirizzo
Costruttore alla dichiarazione eseguito da new
Distruttore all’uscita dal blocco solo con delete p;
Senza delete nessun problema il distruttore non parte: si perdono il Buffer e il suo array

Buffer è scritta secondo il RAII, ma usata così non lo rispetta. Il RAII non vieta new, ma un new senza proprietario: il new va nel costruttore di un oggetto che fa il delete nel suo distruttore, come in Buffer.

Approfondimento: il RAII con un file

#include <fstream>
#include <string>

class Registro {
private:
    std::ofstream file_;
public:
    Registro(const std::string& nomefile) {
        file_.open(nomefile);
    }
    ~Registro() { file_.close(); }
    void scrivi(const std::string& riga) {
        file_ << riga << "\n";
    }
};

int main() {
    Registro registro("registro.txt");
    registro.scrivi("inizio del calcolo");
    registro.scrivi("fine del calcolo");
    return 0;
}

La risorsa può essere un file: std::ofstream (da <fstream>) ci scrive con <<, come std::cout.

  • Riga 9: il costruttore apre il file.
  • Riga 11: il distruttore lo chiude.
  • Riga 21: registro viene distrutto e il file, con le due righe, è chiuso.

Il close() è esplicito per mostrare il rilascio: std::ofstream chiuderebbe il file anche da solo.

Metodi const: il problema

Il metodo valore() serve a leggere l’attributo, non a modificarlo. Se per errore lo modifica, il compilatore non lo segnala: non ha modo di sapere che il metodo doveva soltanto leggere.

#include <iostream>

class Grandezza {
private:
    double valore_;
public:
    Grandezza(double v) : valore_(v) {}
    double valore() {
1        valore_ = 0.0;
        return valore_;
    }
};

int main() {
    Grandezza g(9.81);
    std::cout << g.valore() << "\n";    // stampa 0
    return 0;
}
1
L’errore: il metodo modifica valore_. Il programma compila senza avvisi, anche con -Wall, e stampa 0 invece di 9.81.

Metodi const: la soluzione

#include <iostream>

class Grandezza {
private:
    double valore_;
public:
    Grandezza(double v) : valore_(v) {}
    void imposta(double v) { valore_ = v; }
    double valore() const {
        // valore_ = 0.0;
        return valore_;
    }
};

int main() {
    Grandezza g(9.81);
    g.imposta(1.62);
    std::cout << g.valore() << "\n";  // 1.62
    return 0;
}

Riga 9: il const dopo la parentesi dei parametri dichiara che il metodo non modifica l’oggetto; imposta() (riga 8) lo modifica e non è const. Il compilatore lo verifica: dentro un metodo const gli attributi si possono leggere, non modificare.

Togliendo il commento della riga 10 la compilazione si ferma (assignment of member ... in read-only object).

Il const fa parte della firma: se il metodo è definito fuori dalla classe, si ripete, double Grandezza::valore() const { ... }.

Metodi const e const reference

#include <iostream>

class Vettore2D {
private:
    double x_, y_;
public:
    Vettore2D(double x, double y) : x_(x), y_(y) {}
    void scala(double f) { x_ *= f; y_ *= f; }
    double norma2() const { return x_ * x_ + y_ * y_; }
};

void stampaNorma2(const Vettore2D& v) {
    std::cout << v.norma2() << "\n";
    // v.scala(2.0);
}

int main() {
    Vettore2D v(3.0, 4.0);
    stampaNorma2(v);                  // 25
    return 0;
}

Il parametro const Vettore2D& v (riga 12, Lezione 05) evita la copia e vieta di modificare v: su v si possono chiamare solo metodi const.

  • Riga 13: norma2(), il quadrato della norma, è const: ammessa.
  • Riga 14: scala() non lo è: errore, se si toglie il commento.

Se norma2() non fosse const, anche la riga 13 darebbe errore: i metodi che non modificano l’oggetto vanno tutti dichiarati const.

Argomenti di default nei metodi

#include <iostream>

class Contatore {
private:
    int conteggio_ = 0;
public:
    void incrementa(int passo = 1);
    int valore() const { return conteggio_; }
};

void Contatore::incrementa(int passo) {
    conteggio_ += passo;
}

int main() {
    Contatore c;
    c.incrementa();                    // passo = 1
    c.incrementa(5);                   // passo = 5
    std::cout << c.valore() << "\n";   // 6
    return 0;
}

Un argomento di default (Lezione 05) è il valore usato quando l’argomento manca: c.incrementa() equivale a c.incrementa(1).

Se dichiarazione e definizione sono separate (Lezione 08), il default si scrive solo nella dichiarazione (riga 7, nel .hpp).

Ripeterlo nella definizione (riga 11) è un errore di compilazione (default argument given for parameter 1 con g++).

Argomenti di default e ambiguità

#include <iostream>

class Grandezza {
private:
    double valore_;
public:
1    Grandezza() : valore_(0.0) {}
2    Grandezza(double v = 0.0) : valore_(v) {}
    double valore() const { return valore_; }
};

int main() {
3    Grandezza a(2.5);
    std::cout << a.valore() << "\n";
4    // Grandezza b;
    return 0;
}
1
Costruttore 1: senza argomenti.
2
Costruttore 2: con zero o un argomento, grazie al default.
3
Con un argomento si può usare solo il costruttore 2.
4
Senza argomenti si possono usare entrambi.

Un costruttore con tutti gli argomenti di default si può chiamare anche senza argomenti. Se nella classe c’è anche un costruttore senza argomenti, per Grandezza b; vanno bene tutti e due, e il compilatore non può scegliere.

Togliendo il commento dalla riga di b la compilazione si ferma; con g++: call of overloaded 'Grandezza()' is ambiguous.

Il rimedio è tenere uno solo dei due costruttori.

Approfondimento: overloading o default

#include <iostream>
#include <cmath>
#include <string>
void stampa(const std::string& msg,
            bool a_capo = true) {
    std::cout << msg;
    if (a_capo) std::cout << "\n";
}
class Vettore2D {
private:
    double x_, y_;
public:
    Vettore2D(double x, double y)
        : x_(x), y_(y) {}
    Vettore2D(double angolo)
        : x_(std::cos(angolo)), y_(std::sin(angolo)) {}
};

int main() {
    stampa("prima riga");
    stampa("seconda riga, senza a capo", false);
    Vettore2D v(1.0, 2.0);   // componenti
    Vettore2D u(0.0);        // versore con angolo 0
    return 0;
}

Argomento di default (righe 4–5): il codice è lo stesso e cambia solo un’opzione, andare a capo o no.

Overloading (Lezione 05: più funzioni con lo stesso nome e parametri diversi; righe 13–16): i due costruttori calcolano x_ e y_ in modi diversi, dalle componenti cartesiane oppure dall’angolo di un versore. Un solo costruttore con un argomento di default non potrebbe fare entrambe le cose.

Esercizio in classe: un array dinamico

DynamicArray alloca un array di double nell’heap nel costruttore e lo libera nel distruttore. Header e main sono dati; voi completate DynamicArray.cpp.

  1. In una cartella create DynamicArray.hpp, main.cpp e DynamicArray.cpp, copiando il codice dalle due slide seguenti.

  2. Compilate subito, prima di scrivere codice:

    g++ -Wall -g -fsanitize=address main.cpp DynamicArray.cpp -o main
    ./main

    Lo scheletro compila: stampa una riga di zeri, uno 0 e la riga del distruttore.

  3. Completate DynamicArray.cpp: in ciascuna delle quattro funzioni manca una sola riga, indicata dal commento // da completare. Il controllo sugli indici è già scritto.

Esercizio in classe (segue): le verifiche

  1. Ricompilate ed eseguite: l’output deve essere quello indicato sotto il main, e l’AddressSanitizer non deve stampare nulla.
  2. Commentate il delete[] nel distruttore, ricompilate ed eseguite. L’AddressSanitizer deve segnalare 40 byte(s) leaked in 1 allocation(s): l’array di 5 double da 8 byte non è stato liberato. Su macOS usate leaks, come nella Lezione 08. Poi rimettete il delete[].

Il codice di partenza: header e main

DynamicArray.hpp
#pragma once

class DynamicArray {
private:
    int size_;
    double* data_;
public:
    DynamicArray(int size);
    ~DynamicArray();
    void imposta(int indice, double valore);
    double leggi(int indice) const;
};

Output atteso, a esercizio completato:

0 0.5 1 1.5 2
0
distruttore: liberati 5 elementi

La seconda riga è leggi(7): gli indici validi vanno da 0 a 4, quindi leggi restituisce 0.

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

int main() {
    DynamicArray a(5);
    for (int i = 0; i < 5; i++) {
        a.imposta(i, 0.5 * i);
    }
    for (int i = 0; i < 5; i++) {
        std::cout << a.leggi(i) << " ";
    }
    std::cout << "\n";
    a.imposta(7, 1.0);     // indice non valido
    std::cout << a.leggi(7) << "\n";
    return 0;
}

Il file da completare

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

DynamicArray::DynamicArray(int size) : size_(size), data_(nullptr) {
    // da completare (una riga): data_ riceve un array di size_ double (new[])
}

DynamicArray::~DynamicArray() {
    // da completare (una riga): liberate data_ con delete[]
    std::cout << "distruttore: liberati " << size_ << " elementi\n";
}

void DynamicArray::imposta(int indice, double valore) {
    if (indice >= 0 && indice < size_) {      // indice valido
        // da completare (una riga): scrivete valore in data_[indice]
    }
}

double DynamicArray::leggi(int indice) const {
    if (indice >= 0 && indice < size_) {      // indice valido
        // da completare (una riga): restituite data_[indice]
    }
    return 0.0;                               // indice non valido
}

Soluzione dell’esercizio: il codice

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

DynamicArray::DynamicArray(int size) : size_(size), data_(nullptr) {
    data_ = new double[size_];
}

DynamicArray::~DynamicArray() {
    delete[] data_;
    std::cout << "distruttore: liberati " << size_ << " elementi\n";
}

void DynamicArray::imposta(int indice, double valore) {
    if (indice >= 0 && indice < size_) {      // indice valido
        data_[indice] = valore;
    }
}

double DynamicArray::leggi(int indice) const {
    if (indice >= 0 && indice < size_) {      // indice valido
        return data_[indice];
    }
    return 0.0;                               // indice non valido
}

Soluzione dell’esercizio: i file dati

Header e main sono quelli dati.

DynamicArray.hpp
#pragma once

class DynamicArray {
private:
    int size_;
    double* data_;
public:
    DynamicArray(int size);
    ~DynamicArray();
    void imposta(int indice, double valore);
    double leggi(int indice) const;
};
main.cpp
#include <iostream>
#include "DynamicArray.hpp"

int main() {
    DynamicArray a(5);
    for (int i = 0; i < 5; i++) {
        a.imposta(i, 0.5 * i);
    }
    for (int i = 0; i < 5; i++) {
        std::cout << a.leggi(i) << " ";
    }
    std::cout << "\n";
    a.imposta(7, 1.0);     // indice non valido
    std::cout << a.leggi(7) << "\n";
    return 0;
}

Attenzione: la classe non si può ancora copiare

Supponiamo di aggiungere al main la riga DynamicArray b = a;.

  • Questa riga copia gli attributi di a in b: size_ e data_. data_ è un indirizzo, quindi b riceve lo stesso indirizzo di a, non una copia dell’array.
  • a e b puntano allo stesso array nell’heap.
  • Alla fine del main vengono distrutti entrambi, e i due distruttori eseguono delete[] sullo stesso indirizzo: è il double free della Lezione 08, e il programma termina con un errore.

Come scrivere una classe che si copia correttamente è l’argomento della Lezione 10.

Esercizio lampo: cosa stampa?

#include <iostream>

struct E {
    int id;
    E(int i) : id(i) {}
    ~E() { std::cout << "distrutto " << id << "\n"; }
};

int main() {
    E a(1);
    {
        E b(2);
        std::cout << "fine del blocco\n";
    }
    E c(3);
    std::cout << "fine del main\n";
    return 0;
}

Esercizio lampo: la risposta

fine del blocco
distrutto 2
fine del main
distrutto 3
distrutto 1
  • b è dichiarata nel blocco interno: viene distrutta alla sua graffa di chiusura, prima che venga creata c.
  • Alla fine del main restano a e c. Vengono distrutte in ordine inverso a quello di creazione: prima c, poi a.