Programmazione Scientifica++: Lezione 8

Header Files, Compilazione e Memoria (Stack/Heap)

Cristiano De Michele

Argomenti di oggi

  • Dove arriveremo: il framework finito riconosce cifre scritte a mano
  • Dividere il codice in più file
    • Metodi definiti fuori dalla classe: l’operatore ::
    • Header (.hpp) e file sorgente (.cpp), header guard e #pragma once
    • Compilazione separata, linking e CMake
  • La memoria di un programma: stack e heap
  • Memoria dinamica
    • Come in C: da malloc/free a new/delete
    • Array dinamici e matrici dinamiche
  • Gli errori tipici: memory leak, dangling pointer, double free
  • Come si trovano: l’AddressSanitizer

Dove arriveremo

Alla fine del corso il framework BASIC_NEURAL++ riconosce cifre scritte a mano. Il programma test del framework:

  1. carica una rete già addestrata, con 784 ingressi, 1000 neuroni nascosti e 10 uscite;
  2. misura l’accuratezza sulle 10 000 immagini di test di MNIST: 98,8 %;
  3. classifica dieci immagini di cifre scritte a mano, da 0 a 9, mostrando ogni immagine e la cifra riconosciuta;
  4. mostra come la rete “vede” ciascuna cifra, facendo scorrere il segnale all’indietro dalle uscite agli ingressi (back query, Laboratorio 16).

La rete imparerà a riconoscere le cifre di MNIST nei Laboratori 13 e 14.

Perché ancora dettagli del linguaggio

I dettagli del C++ di queste lezioni sembrano lontani dalle reti neurali, ma sono gli strumenti con cui si scrive il framework, che nei laboratori comincia subito:

laboratorio che cosa si scrive
04 (domani) array e una matrice in memoria dinamica, come in C
05 la classe Matrix e il primo strato, DenseLayer
06 la rete, Sequential
13 e 14 la backpropagation e l’addestramento su MNIST

Nelle prossime lezioni arriveranno anche esercizi di calcolo scientifico tratti da Programmazione Scientifica, il libro del Laboratorio di Calcolo. Non sono un argomento a parte: molti di quei metodi servono al framework, per addestrare la rete e per valutarne i risultati.

Gli argomenti di oggi nel framework

  • Codice in più file. Nei Laboratori 05 e 06 Matrix e DenseLayer sono divise in .hpp e .cpp, compilate insieme da CMake. Nel framework finale le classi sono template e ognuna sta per intero nel suo header (layer.hpp, sequential.hpp, …); eigen_wrap.hpp definisce Matrix<ntype> come matrice di Eigen.
  • Memoria dinamica. I dati della rete non stanno nello stack, che è di pochi MB: i pesi di uno strato \(784 \times 200\) sono 156 800 double, cioè 1,25 MB, e le 60 000 immagini di addestramento di MNIST sono 47 milioni di pixel. Nel Laboratorio 04 allocherete gli array con new[]; nel Laboratorio 05 la classe Matrix terrà i suoi dati in un blocco allocato con new double[rows * cols].
  • AddressSanitizer. Nel Laboratorio 04 verificherete con -fsanitize=address che a ogni new[] corrisponda un delete[].

Un programma in un solo file

Finora la classe e il main() stavano nello stesso file main.cpp. Un progetto con decine di classi, come un framework per reti neurali, in un solo file avrebbe tre problemi:

  1. File molto lunghi, difficili da leggere.
  2. Compilazione lenta: ogni modifica, anche minima, richiede di ricompilare tutto il progetto.
  3. Lavoro in gruppo difficile: più persone che modificano lo stesso file entrano continuamente in conflitto.

La soluzione è separare la dichiarazione (l’interfaccia) dall’implementazione.

Definire i metodi fuori dalla classe

Finora i metodi erano definiti dentro la classe. Si può anche scrivere nella classe solo la dichiarazione (la firma seguita da ;) e definire il metodo fuori, con il nome preceduto da NomeClasse::.

#include <iostream>

class Neurone {
private:
    double peso_;
public:
    Neurone(double w);                    // solo la dichiarazione
    double uscita(double x);              // solo la dichiarazione
    double peso() { return peso_; }       // definito nella classe
};
1Neurone::Neurone(double w) : peso_(w) {}
2double Neurone::uscita(double x) {
    return peso_ * x;
}
int main() {
    Neurone n(0.5);
    std::cout << n.peso() << " " << n.uscita(2.0) << "\n";   // stampa 0.5 1
}
1
Il costruttore di Neurone: la lista di inizializzazione sta nella definizione.
2
Il metodo uscita di Neurone.

L’operatore :: e lo scope della classe

:: è l’operatore di risoluzione dello scope, lo stesso di std::cout (Lezione 04): come un namespace, una classe è uno scope, e Neurone::uscita è il nome completo del metodo uscita della classe Neurone.

  • Nel corpo di un metodo definito fuori dalla classe si usano gli attributi e gli altri metodi, anche privati, esattamente come dentro la classe.
  • Senza Neurone::, double uscita(double x) { ... } sarebbe una funzione libera, che non ha accesso agli attributi: la compilazione si ferma con error: 'peso_' was not declared in this scope.
  • I metodi brevi, come peso(), possono restare definiti nella classe.

Questa separazione permette di dividere una classe in due file: le dichiarazioni nell’header, le definizioni nel file sorgente.

Header (.hpp) e file sorgente (.cpp)

Ogni classe si divide in due file:

Header (.hpp o .h)

Contiene che cosa fa la classe: la dichiarazione della classe, gli attributi e le firme dei metodi, senza il codice dei metodi.

File sorgente (.cpp o .cc)

Contiene come lo fa: il corpo { ... } di ogni metodo dichiarato nell’header.

Chi usa la classe include l’header. Come visto nella Lezione 04, #include è una direttiva del preprocessore: copia il testo dell’header nel file che lo include, prima della compilazione.

Nel framework finale le classi sono template e stanno interamente negli header, metodi compresi: lo vedremo nella Lezione 16.

Un esempio: header e file sorgente

Neurone.hpp
#pragma once

class Neurone {
private:
    double peso_;
    double bias_;

public:
    Neurone(double w, double b);
    double uscita(double x);
};

L’header basta per sapere come si usa la classe.

Nel framework non c’è una classe neurone: l’unità è lo strato (DenseLayer, Laboratorio 05), un vettore di neuroni.

Neurone.cpp
1#include "Neurone.hpp"
#include <cmath>

2Neurone::Neurone(double w, double b)
    : peso_(w), bias_(b) {}

3double Neurone::uscita(double x) {
    double z = peso_ * x + bias_;
    return 1.0 / (1.0 + std::exp(-z));
}
1
Il file sorgente include il proprio header: il compilatore deve conoscere la classe.
2
L’operatore di risoluzione dello scope :: indica che la funzione è il costruttore della classe Neurone.
3
Lo stesso per il metodo uscita.

Le inclusioni multiple

In un progetto gli header si includono a vicenda:

  • Strato.hpp contiene #include "Neurone.hpp";
  • Ottimizzatore.hpp contiene #include "Neurone.hpp";
  • main.cpp contiene #include "Strato.hpp" e #include "Ottimizzatore.hpp".

Il preprocessore copia il testo di Neurone.hpp due volte nello stesso file, e la compilazione si ferma con error: redefinition of 'class Neurone': una classe si può definire una sola volta.

Le header guard

La soluzione classica sono le header guard: tre direttive del preprocessore (Lezione 04), da scrivere in ogni header.

1#ifndef NEURONE_HPP
2#define NEURONE_HPP

class Neurone {
    // ... il contenuto della classe
};

3#endif
1
Se la macro NEURONE_HPP non è ancora definita, il testo fino a #endif viene incluso.
2
La macro viene definita: alla seconda inclusione nello stesso file la condizione della riga 1 è falsa, e il testo viene saltato.
3
Fine del blocco protetto.

Il nome della macro è arbitrario, ma deve essere diverso per ogni header: per convenzione è il nome del file in maiuscolo.

#pragma once

Una sola riga, all’inizio dell’header, ottiene lo stesso effetto senza dover scegliere il nome di una macro:

1#pragma once

class Neurone {
    // ... il contenuto della classe
};
1
Il preprocessore include questo file al più una volta per ogni file .cpp che lo richiede.

Nota

#pragma once non fa parte dello standard C++, ma è supportato da tutti i compilatori principali (GCC, Clang, MSVC). Nel corso si usano entrambe le forme: nei Laboratori 05 e 06 Matrix e DenseLayer hanno le header guard.

Compilazione e linking

Nella Lezione 04 un solo comando, g++ -o Welcome Welcome.cpp, eseguiva tutte le fasi: preprocessore, compilazione e linking. Con più file sorgenti le fasi si possono separare:

1g++ -c Neurone.cpp -o Neurone.o          # compilazione
g++ -c main.cpp -o main.o                # compilazione
2g++ main.o Neurone.o -o rete             # linking
1
Compilazione (-c): ogni .cpp diventa un file oggetto .o, in modo indipendente dagli altri. Per compilare main.cpp basta che le funzioni che chiama siano dichiarate negli header inclusi.
2
Linking: il linker unisce i file .o e collega ogni chiamata al codice della funzione chiamata. Se una funzione è dichiarata ma il suo codice non c’è in nessun .o, il linking fallisce con undefined reference to 'Neurone::uscita(double)'.

Il vantaggio della compilazione separata: se si modifica solo Neurone.cpp, si ricompila solo quello e si ripete il linking.

CMake

Scrivere a mano questi comandi diventa scomodo già con pochi file. CMake li genera: nel CMakeLists.txt si elencano i file sorgenti dell’eseguibile, come nel Task 7 del Laboratorio 03.

CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(Rete)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_compile_options(-Wall)

1add_executable(rete main.cpp Neurone.cpp)
1
Tutti i .cpp dell’eseguibile; gli header no, li trova il preprocessore tramite #include.

A ogni compilazione (make, o il pulsante Build di VS Code) vengono ricompilati solo i file modificati, e quelli che includono un header modificato.

Header e sorgente, o solo header

Una classe si può dividere in .hpp e .cpp, oppure scrivere tutta nell’header (header-only):

.hpp e .cpp solo header
compilazione si ricompila il .cpp modificato si ricompila chi include l’header
installazione va compilata e collegata basta copiare gli header
template compilati a parte, tipo per tipo nessun passo in più
ottimizzazione una funzione di un altro .cpp non si può espandere dove è chiamata il compilatore vede il codice e può espandere le funzioni dove sono chiamate

Eigen è header-only: per usarla non si compila nessuna libreria. Nel corso ci sono entrambe le scelte: .hpp e .cpp nei Laboratori 05 e 06, solo header nel framework finale, le cui classi sono template (Lezione 16).

Esercizio in classe

Nella Lezione 06 Misure e Statistiche stavano in file separati dal main. Ora ogni classe ha un header con le dichiarazioni e un file sorgente con le definizioni.

  1. Misure.hpp, protetto da una header guard (#ifndef MISURE_HPP, #define MISURE_HPP, #endif): la costante const int MAX_MISURE = 100; e la dichiarazione di Misure, con gli attributi privati double x_[MAX_MISURE] e int n_ e le firme Misure(), void aggiungi(double v), int numero(), double valore(int i).
  2. Misure.cpp: include Misure.hpp e definisce i metodi come nella Lezione 06; il costruttore pone n_ a zero con la lista di inizializzazione.
  3. Statistiche.hpp, protetto da #pragma once: include Misure.hpp, perché il costruttore riceve un Misure&, e dichiara Statistiche, con gli attributi privati double media_ e double sigma_ e le firme Statistiche(Misure& m), double media(), double sigma().

Esercizio in classe (segue)

  1. Statistiche.cpp: include Statistiche.hpp e <cmath> e definisce il costruttore, che calcola media e deviazione standard campionaria, e i due metodi.
  2. main.cpp include <iostream>, Misure.hpp e Statistiche.hpp. Crea Misure periodo;, aggiunge le misure 2.01, 1.98, 2.03, 1.99 e 2.00, crea Statistiche s(periodo); e stampa T = <media> s, sigma = <sigma> s.

Esercizio in classe (segue)

  1. Finora gli esercizi delle lezioni si compilavano a mano con g++. Questo ha tre file sorgenti e si compila con CMake, come nei laboratori.
    • Nella cartella dei cinque file create CMakeLists.txt con questo contenuto:
    CMakeLists.txt
    cmake_minimum_required(VERSION 3.10)
    project(Pendolo)
    
    set(CMAKE_CXX_STANDARD 17)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)
    add_compile_options(-Wall)
    
    add_executable(pendolo main.cpp Misure.cpp Statistiche.cpp)
    • Dal terminale, nella stessa cartella: mkdir build e cd build; poi cmake .., che prepara la compilazione (una volta sola), e make, che compila (dopo ogni modifica); infine ./pendolo.
    • In VS Code con CMake Tools: aprite la cartella, scegliete il compilatore quando viene chiesto, poi Build e Run.

Esercizio in classe (segue)

  1. Due domande, da verificare al calcolatore:
    1. In main.cpp il testo di Misure.hpp viene incluso due volte: perché? Togliete le tre righe della header guard e ricompilate: quale errore si ottiene?
    2. Rimettete la header guard e togliete Statistiche.cpp da add_executable: quale errore si ottiene, e in quale fase?

Soluzione: Misure

Misure.hpp
#ifndef MISURE_HPP
#define MISURE_HPP

const int MAX_MISURE = 100;

class Misure {
private:
    double x_[MAX_MISURE];
    int n_;

public:
    Misure();
    void aggiungi(double v);
    int numero();
    double valore(int i);
};

#endif
Misure.cpp
#include "Misure.hpp"

Misure::Misure() : n_(0) {}

void Misure::aggiungi(double v) {
    if (n_ < MAX_MISURE) {
        x_[n_] = v;
        ++n_;
    }
}

int Misure::numero() { return n_; }

double Misure::valore(int i) { return x_[i]; }

Soluzione: Statistiche

Statistiche.hpp
#pragma once
#include "Misure.hpp"

class Statistiche {
private:
    double media_;
    double sigma_;

public:
    Statistiche(Misure& m);
    double media();
    double sigma();
};
Statistiche.cpp
#include "Statistiche.hpp"
#include <cmath>

Statistiche::Statistiche(Misure& m) {
    int n = m.numero();
    double somma = 0.0;
    for (int i = 0; i < n; ++i) somma += m.valore(i);
    media_ = somma / n;
    double s2 = 0.0;
    for (int i = 0; i < n; ++i) {
        double d = m.valore(i) - media_;
        s2 += d * d;
    }
    sigma_ = std::sqrt(s2 / (n - 1));
}

double Statistiche::media() { return media_; }

double Statistiche::sigma() { return sigma_; }

Soluzione: il main

main.cpp
#include <iostream>
#include "Misure.hpp"
#include "Statistiche.hpp"

int main() {
    Misure periodo;
    periodo.aggiungi(2.01);
    periodo.aggiungi(1.98);
    periodo.aggiungi(2.03);
    periodo.aggiungi(1.99);
    periodo.aggiungi(2.00);

    Statistiche s(periodo);
    std::cout << "T = " << s.media() << " s, sigma = " << s.sigma() << " s\n";
    return 0;
}

Output: T = 2.002 s, sigma = 0.0192354 s, come nella Lezione 06.

Soluzione: le due domande

  1. main.cpp include Misure.hpp direttamente e una seconda volta attraverso Statistiche.hpp. Senza header guard la compilazione di main.cpp si ferma con error: redefinition of 'const int MAX_MISURE' e error: redefinition of 'class Misure'. Il #pragma once di Statistiche.hpp non basta: protegge solo Statistiche.hpp.

  2. La compilazione di main.cpp riesce, perché Statistiche.hpp dichiara tutto; il linking fallisce con undefined reference to 'Statistiche::Statistiche(Misure&)', e lo stesso per media() e sigma().

La memoria di un programma

Il sistema operativo assegna a ogni programma in esecuzione uno spazio di memoria, diviso in quattro parti principali:

  1. Codice (text segment): le istruzioni in linguaggio macchina del programma, in sola lettura.
  2. Dati (data segment): le variabili globali e static, che esistono per tutta l’esecuzione.
  3. Stack: le variabili locali delle funzioni, allocate e liberate automaticamente.
  4. Heap: la memoria allocata e liberata esplicitamente dal programma, in esecuzione.

Stack e heap a confronto

Stack

  • Allocazione veloce: si sposta un indice.
  • Gestito automaticamente: le variabili nascono alla { e vengono distrutte alla } del loro blocco (scope).
  • Memoria contigua.
  • Piccolo: di solito 8 MB su Linux e macOS. Superarlo (stack overflow) termina il programma con Segmentation fault.

Heap

  • Allocazione più lenta: l’allocatore deve cercare un blocco libero della dimensione richiesta.
  • Gestito dal programmatore: la memoria resta allocata finché il programma non la libera, o fino alla fine dell’esecuzione.
  • Si può frammentare.
  • Grande: limitato dalla memoria del sistema.

Il limite dello stack

La dimensione di un array sullo stack deve essere una costante di compilazione.

#include <iostream>

int main() {
    int dimensione;
    std::cin >> dimensione;       // nota solo in esecuzione

1    // int a[dimensione];

    return 0;
}
1
Come visto nella Lezione 06, è un array di lunghezza variabile, che lo standard C++ non ammette. Eppure compila: g++ e clang++ (che sul Mac è anche il comando g++) lo accettano come estensione. clang++ avvisa con -Wall (variable length arrays in C++ are a Clang extension), g++ solo con -pedantic (ISO C++ forbids variable length array); con -pedantic-errors diventa un errore per entrambi. Non va usato: non è portabile, e un valore grande di dimensione supera lo stack.

Se la dimensione si conosce solo in esecuzione, per esempio perché si legge da un file, la memoria va presa dall’heap.

Come in C: da malloc a new

In C l’heap si usa con malloc e free; in C++ con new e delete.

#include <cstdlib>

int main() {
    int N = 1000;
1    double* a = (double*) std::malloc(N * sizeof(double));
2    double* b = new double[N];
    a[0] = 1.0;
    b[0] = 2.0;
3    std::free(a);
    delete[] b;
    return 0;
}
1
Come in C, ma in C++ il void* restituito da malloc va convertito esplicitamente (Lezione 04).
2
new conosce il tipo: niente sizeof, niente conversione. Per un oggetto chiama anche il costruttore.
3
Ciò che si alloca con malloc si libera con free, ciò che si alloca con new con delete: i due meccanismi non si mescolano.

Se la memoria manca, malloc restituisce NULL, mentre new lancia l’eccezione std::bad_alloc (Lezioni 19 e 20). Nel corso si usano solo new e delete.

Allocazione dinamica: new

#include <iostream>

int main() {
1    double* ptr = new double(5.0);

    std::cout << *ptr << "\n";       // stampa 5

    delete ptr;
    return 0;
}
1
new double(5.0) alloca un double nell’heap, lo inizializza a 5 e restituisce il suo indirizzo, che viene salvato in ptr.

Il double sta nell’heap; il puntatore ptr, che ne contiene l’indirizzo, è una variabile locale e sta nello stack.

Deallocazione: delete

La memoria allocata con new resta occupata finché non viene liberata con delete, o fino alla fine del programma, quando il sistema operativo recupera tutto lo spazio del processo.

int main() {
    double* ptr = new double(5.0);

    // ... calcoli ...

1    delete ptr;
2    ptr = nullptr;

    return 0;
}
1
delete libera la memoria il cui indirizzo è contenuto in ptr. La variabile ptr continua a esistere e contiene ancora lo stesso indirizzo, che però non è più valido.
2
Per questo dopo delete conviene assegnare nullptr (Lezione 05). delete su nullptr non fa nulla.

Il problema di una memoria mai liberata non riguarda la fine del programma, ma i programmi che allocano ripetutamente, per esempio a ogni passo di un ciclo di addestramento: lo vedremo fra poco con il memory leak.

Array dinamici: new[] e delete[]

Con new[] si alloca un blocco contiguo di elementi nell’heap; la dimensione può essere una variabile nota solo in esecuzione.

#include <iostream>

int main() {
    int N;
    std::cin >> N;                     // per esempio 10000

1    double* dati = new double[N];
    dati[0] = 3.14;
    std::cout << dati[0] << "\n";

2    delete[] dati;
    return 0;
}
1
Le parentesi quadre: si allocano N elementi double.
2
Ciò che si alloca con new[] si libera con delete[]. Usare delete senza parentesi è undefined behaviour: spesso il programma termina senza errori. Se new[] e delete sono nella stessa funzione, g++ -Wall e clang++ avvisano in compilazione (-Wmismatched-new-delete); se sono in funzioni diverse, lo segnala solo l’AddressSanitizer, come alloc-dealloc-mismatch.

Matrici dinamiche: allocazione

Una matrice nell’heap si può costruire come un array di puntatori, uno per riga.

int main() {
    int righe = 3;
    int colonne = 4;

1    double** matrice = new double*[righe];

    for (int i = 0; i < righe; ++i) {
2        matrice[i] = new double[colonne];
    }

3    matrice[0][0] = 1.5;

    // ... manca la deallocazione: slide successiva
    return 0;
}
1
double** è un puntatore a puntatore a double: qui contiene l’indirizzo di un array di righe puntatori.
2
Ogni riga è un array dinamico a sé.
3
Gli elementi si usano con la stessa sintassi di una matrice statica.

Matrici dinamiche: deallocazione

Non basta delete[] matrice;: le righe vanno liberate per prime, nell’ordine inverso dell’allocazione.

    // ... codice della slide precedente ...

    for (int i = 0; i < righe; ++i) {
1        delete[] matrice[i];
    }

2    delete[] matrice;

    return 0;
}
1
Prima ogni riga. Senza questo ciclo, le righe restano allocate fino alla fine del programma.
2
Poi l’array dei puntatori alle righe. Invertendo l’ordine, dopo delete[] matrice gli indirizzi delle righe non sarebbero più leggibili.

Una matrice in un blocco unico

L’alternativa, usata nei Laboratori 04 e 05, è un solo blocco di righe * colonne elementi, con l’elemento \((i, j)\) in posizione i * colonne + j (ordine row-major).

#include <iostream>

int main() {
    int righe = 3, colonne = 4;
1    double* dati = new double[righe * colonne];

    for (int i = 0; i < righe; ++i)
        for (int j = 0; j < colonne; ++j)
2            dati[i * colonne + j] = i * colonne + j;

    std::cout << dati[2 * colonne + 3] << "\n";          // elemento (2, 3): stampa 11

3    delete[] dati;
    return 0;
}
1
Una sola allocazione, con gli elementi contigui in memoria. È anche l’ordine in cui MNIST memorizza i 28 × 28 pixel di un’immagine.
2
L’indice si calcola a mano: nel Laboratorio 05 lo farà il metodo get della classe Matrix.
3
Una sola deallocazione.

Un blocco unico con due indici

Al blocco unico si può affiancare un array di puntatori alle righe: gli elementi restano contigui e si usano con due indici.

#include <iostream>

int main() {
    int righe = 3, colonne = 4;
    double* dati = new double[righe * colonne];          // il blocco unico
    double** m = new double*[righe];                     // un puntatore per riga
    for (int i = 0; i < righe; ++i)
1        m[i] = dati + i * colonne;
    for (int i = 0; i < righe; ++i)
        for (int j = 0; j < colonne; ++j)
2            m[i][j] = i * colonne + j;
    std::cout << m[2][3] << " " << dati[2 * colonne + 3] << "\n";   // stampa 11 11
3    delete[] m;
    delete[] dati;
    return 0;
}
1
Aritmetica dei puntatori (Lezione 06): dati + i * colonne è l’indirizzo dell’elemento \((i, 0)\). Si somma un numero di elementi, non di byte: il fattore sizeof(double) lo applica il compilatore.
2
m[i][j] è *(m[i] + j), cioè dati[i * colonne + j].
3
Due allocazioni e due deallocazioni, contro le righe + 1 della matrice a righe separate.

Il memory leak

Se la memoria allocata con new non viene liberata con delete, e il suo indirizzo si perde, quella memoria resta occupata e non è più raggiungibile: è un memory leak.

void calcolo() {
1    double* locale = new double[50000];   // 400 000 byte
}

int main() {
    while (true) {
2        calcolo();
    }
    return 0;
}
1
Alla fine della funzione il puntatore locale viene distrutto, perché è una variabile locale; i 400 000 byte nell’heap restano allocati, e nessuno ne conosce più l’indirizzo.
2
A ogni chiamata si perdono altri 400 kB. Su Linux il programma occupa gigabyte di memoria in pochi secondi; quando la memoria del sistema si esaurisce, il sistema operativo lo termina.

Un leak nascosto: il return anticipato

Il delete può esserci ed essere comunque mancato: basta un’uscita anticipata dalla funzione che non ci passa.

#include <iostream>

void leggiFile() {
    int* buffer = new int[1000];
    bool errore_lettura = true;      // un errore di lettura
    if (errore_lettura) {
        std::cerr << "Errore di lettura\n";
1        return;
    }
2    delete[] buffer;
}

int main() { leggiFile(); return 0; }
1
In caso di errore la funzione termina subito, senza delete[].
2
Non viene eseguita: i 1000 int restano allocati. La correzione è un delete[] buffer; prima di ogni return. Con il paradigma RAII (Lezione 09) la memoria si libera da sola a ogni uscita dalla funzione.

Il dangling pointer

Dopo un delete, il puntatore contiene ancora l’indirizzo della memoria liberata.

int main() {
    int* ptr = new int(42);

1    delete ptr;

2    *ptr = 100;

    return 0;
}
1
L’allocatore del programma segna la memoria come libera e può riusarla alla prossima new. La memoria resta al processo: non torna al sistema operativo e non può essere assegnata a un altro programma.
2
ptr è un dangling pointer. Scrivere a quell’indirizzo è undefined behaviour: di solito non succede nulla di visibile, ma si possono corrompere dati che la prossima new assegnerà a un altro oggetto, e l’errore si manifesta altrove nel programma.

Per questo, dopo un delete, si assegna ptr = nullptr;: un accesso tramite nullptr termina il programma subito con Segmentation fault, ed è più facile da trovare di una corruzione dei dati.

Il double free

Liberare due volte la stessa memoria:

int main() {
    int* p1 = new int(10);
1    int* p2 = p1;

2    delete p1;

    // ... più avanti ...
3    delete p2;

    return 0;
}
1
Due puntatori con lo stesso indirizzo: una sola allocazione.
2
La memoria torna all’allocatore.
3
Seconda deallocazione dello stesso indirizzo: è undefined behaviour. Su Linux l’allocatore della libreria C se ne accorge e interrompe il programma con free(): double free detected in tcache 2 e Aborted. Il messaggio dipende dal sistema.

Lo stesso errore nasce quando si copia un oggetto che contiene un puntatore: lo vedrete nel Task 7 del Laboratorio 04, e la soluzione nella Lezione 10.

L’AddressSanitizer

Questi errori spesso non producono sintomi immediati. g++ e clang++ li rilevano in esecuzione con l’AddressSanitizer, che si attiva in compilazione: g++ -fsanitize=address -g main.cpp -o main. Con CMake, nel CMakeLists.txt:

add_compile_options(-fsanitize=address -g)
add_link_options(-fsanitize=address)
  • Il programma si ferma nel punto dell’errore, indicando file e riga, in caso di:
    • uso di memoria già liberata con delete;
    • accesso fuori dai limiti di un array, come nella Lezione 05;
    • delete usato su un array allocato con new[], al posto di delete[].
  • I leak sono riportati all’uscita, con la riga della new (LeakSanitizer: detected memory leaks).

L’AddressSanitizer rallenta il programma: si usa durante lo sviluppo. Su macOS: slide successiva.

L’AddressSanitizer su macOS

Con il compilatore di Apple (sul Mac il comando g++ è clang++) l’AddressSanitizer non segnala i leak, e segnala un delete su memoria allocata con new[] solo con un’opzione. Su macOS quindi:

  1. accessi non validi: come su Linux, con g++ -fsanitize=address -g;
  2. delete su memoria allocata con new[]: lo stesso eseguibile, lanciato con ASAN_OPTIONS=alloc_dealloc_mismatch=1 ./main;
  3. leak: un eseguibile compilato senza -fsanitize, lanciato con il comando leaks di macOS:
g++ -g main.cpp -o main
leaks --atExit -- ./main

All’uscita leaks riporta i blocchi persi e la riga della new, per esempio 3 leaks for 144 total leaked bytes. Le righe iniziali su MallocStackLogging e not debuggable non sono errori.

Esercizio: trovare un memory leak

Esercizio veloce: usare l’AddressSanitizer per trovare un leak.

  1. Scrivete double** creaMatrice(int righe, int colonne), che alloca una matrice con new[]: prima l’array dei puntatori, poi ogni riga.
  2. Scrivete void distruggiMatrice(double** m, int righe), volutamente incompleta: solo delete[] m, senza il ciclo sulle righe.
  3. Nel main, con const int R = 3, C = 4;, create double** W = creaMatrice(R, C);, riempitela con W[i][j] = i * C + j, stampate W[2][3] = <valore> e chiamate distruggiMatrice(W, R).
  4. Compilate con g++ -fsanitize=address -g ed eseguite (su macOS: compilate con g++ -g ed eseguite con leaks --atExit -- ./<eseguibile>). Quanti byte risultano persi, in quante allocazioni, e perché?
  5. Correggete distruggiMatrice e rieseguite: l’AddressSanitizer non deve stampare nulla.

Esercizio: soluzione

#include <iostream>

double** creaMatrice(int righe, int colonne) {
    double** m = new double*[righe];              // l'array dei puntatori
    for (int i = 0; i < righe; ++i)
        m[i] = new double[colonne];               // poi ogni riga
    return m;
}

void distruggiMatrice(double** m, int righe) {
    for (int i = 0; i < righe; ++i)
1        delete[] m[i];        // prima le righe
    delete[] m;               // poi l'array dei puntatori
}

int main() {
    const int R = 3, C = 4;
    double** W = creaMatrice(R, C);
    for (int i = 0; i < R; ++i)
        for (int j = 0; j < C; ++j)
            W[i][j] = i * C + j;
    std::cout << "W[2][3] = " << W[2][3] << "\n";
    distruggiMatrice(W, R);
}
1
L’ordine è obbligato: dopo delete[] m gli indirizzi delle righe non sono più leggibili, e le righe non si possono più liberare.

Soluzione: il rapporto dei leak

Il programma stampa W[2][3] = 11. Con la distruggiMatrice incompleta, che esegue solo delete[] m:

SUMMARY: AddressSanitizer: 96 byte(s) leaked in 3 allocation(s).

Le tre allocazioni perse sono le tre righe, da 4 double cioè 32 byte: \(3 \times 4 \times 8 = 96\). L’array dei puntatori è stato liberato: è l’unica cosa che delete[] m libera.

Con la versione corretta l’AddressSanitizer non stampa nulla. Su macOS leaks riporta 3 leaks for 144 total leaked bytes: le stesse tre righe, con la dimensione dei blocchi dell’allocatore di macOS (48 byte) invece dei 32 byte richiesti.

Importante

Nel Laboratorio 05 Matrix allocherà un blocco di rows * cols double: senza delete[], ogni matrice \(784 \times 200\) lascia allocati 1,25 MB, e in un ciclo di addestramento le matrici si costruiscono a ogni passo.