Header Files, Compilazione e Memoria (Stack/Heap)
::.hpp) e file sorgente (.cpp), header guard e #pragma oncemalloc/free a new/deleteAlla fine del corso il framework BASIC_NEURAL++ riconosce cifre scritte a mano. Il programma test del framework:
La rete imparerà a riconoscere le cifre di MNIST nei Laboratori 13 e 14.
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.
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.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].-fsanitize=address che a ogni new[] corrisponda un delete[].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:
La soluzione è separare la dichiarazione (l’interfaccia) dall’implementazione.
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
}Neurone: la lista di inizializzazione sta nella definizione.
uscita di Neurone.
:: 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.
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.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.
.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.
Neurone.hpp
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
:: indica che la funzione è il costruttore della classe Neurone.
uscita.
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.
La soluzione classica sono le header guard: tre direttive del preprocessore (Lezione 04), da scrivere in ogni header.
NEURONE_HPP non è ancora definita, il testo fino a #endif viene incluso.
Il nome della macro è arbitrario, ma deve essere diverso per ogni header: per convenzione è il nome del file in maiuscolo.
#pragma onceUna 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
};.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.
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:
-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.
.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.
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).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.
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).
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.
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).Misure.cpp: include Misure.hpp e definisce i metodi come nella Lezione 06; il costruttore pone n_ a zero con la lista di inizializzazione.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().Statistiche.cpp: include Statistiche.hpp e <cmath> e definisce il costruttore, che calcola media e deviazione standard campionaria, e i due metodi.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.g++. Questo ha tre file sorgenti e si compila con CMake, come nei laboratori.
CMakeLists.txt con questo contenuto:CMakeLists.txt
mkdir build e cd build; poi cmake .., che prepara la compilazione (una volta sola), e make, che compila (dopo ogni modifica); infine ./pendolo.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?Statistiche.cpp da add_executable: quale errore si ottiene, e in quale fase?MisureStatisticheStatistiche.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_; }mainmain.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.
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.
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().
Il sistema operativo assegna a ogni programma in esecuzione uno spazio di memoria, diviso in quattro parti principali:
static, che esistono per tutta l’esecuzione.{ e vengono distrutte alla } del loro blocco (scope).Segmentation fault.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;
}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.
malloc a newIn C l’heap si usa con malloc e free; in C++ con new e delete.
void* restituito da malloc va convertito esplicitamente (Lezione 04).
new conosce il tipo: niente sizeof, niente conversione. Per un oggetto chiama anche il costruttore.
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.
new#include <iostream>
int main() {
1 double* ptr = new double(5.0);
std::cout << *ptr << "\n"; // stampa 5
delete ptr;
return 0;
}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.
deleteLa 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.
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.
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.
new[] e delete[]Con new[] si alloca un blocco contiguo di elementi nell’heap; la dimensione può essere una variabile nota solo in esecuzione.
N elementi double.
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.
Una matrice nell’heap si può costruire come un array di puntatori, uno per riga.
double** è un puntatore a puntatore a double: qui contiene l’indirizzo di un array di righe puntatori.
Non basta delete[] matrice;: le righe vanno liberate per prime, nell’ordine inverso dell’allocazione.
delete[] matrice gli indirizzi delle righe non sarebbero più leggibili.
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;
}get della classe Matrix.
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;
}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.
m[i][j] è *(m[i] + j), cioè dati[i * colonne + j].
righe + 1 della matrice a righe separate.
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.
locale viene distrutto, perché è una variabile locale; i 400 000 byte nell’heap restano allocati, e nessuno ne conosce più l’indirizzo.
return anticipatoIl delete può esserci ed essere comunque mancato: basta un’uscita anticipata dalla funzione che non ci passa.
delete[].
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.
Dopo un delete, il puntatore contiene ancora l’indirizzo della memoria liberata.
new. La memoria resta al processo: non torna al sistema operativo e non può essere assegnata a un altro programma.
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.
Liberare due volte la stessa memoria:
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.
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:
delete;delete usato su un array allocato con new[], al posto di delete[].new (LeakSanitizer: detected memory leaks).L’AddressSanitizer rallenta il programma: si usa durante lo sviluppo. Su macOS: slide successiva.
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:
g++ -fsanitize=address -g;delete su memoria allocata con new[]: lo stesso eseguibile, lanciato con ASAN_OPTIONS=alloc_dealloc_mismatch=1 ./main;-fsanitize, lanciato con il comando leaks di macOS: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 veloce: usare l’AddressSanitizer per trovare un leak.
double** creaMatrice(int righe, int colonne), che alloca una matrice con new[]: prima l’array dei puntatori, poi ogni riga.void distruggiMatrice(double** m, int righe), volutamente incompleta: solo delete[] m, senza il ciclo sulle righe.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).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é?distruggiMatrice e rieseguite: l’AddressSanitizer non deve stampare nulla.#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);
}delete[] m gli indirizzi delle righe non sono più leggibili, e le righe non si possono più liberare.
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.