Programmazione Scientifica++: Lezione 8

Header Files, Compilazione e Memoria (Stack/Heap)

Docente

Problematiche di un Singolo File .cpp

Finora abbiamo scritto sia la classe che il main() nello stesso file main.cpp. In un progetto vero (es. un Neural Framework) avremo decine di classi. Mettere tutto nello stesso file porterebbe a:

  1. File Enormi: Lunghissimi e illeggibili.
  2. Tempi di Compilazione Infinito: Ogni minima modifica richiede di ricompilare tutto il progetto.
  3. Nessun Lavoro di Squadra: Più programmatori non possono lavorare sullo stesso file senza creare continui conflitti.

Soluzione C++: Separare sempre la Dichiarazione (Interfaccia) dall’Implementazione!

Header Files (.hpp) e Source Files (.cpp)

Dividiamo le nostre classi in due file separati:

Header File (.hpp o .h) Contiene cosa fa la classe. Tutte le dichiarazioni, gli attributi e i prototipi (firme) dei metodi. Non c’è logica qui dentro.

Source File (.cpp o .cc) Contiene come lo fa. Include la logica reale (le graffe { ... }) per ogni singolo metodo promesso nell’Header.

Note

Gli Header servono come “Menu del ristorante” da mostrare ai clienti. I Source files sono la “Cucina” nascosta sul retro.

Esempio pratico affiancato

Layer.hpp

class Layer {
private:
    int num_neurons_;
    
public:
    Layer(int n); // Prototipo
    
    void forward(); // Prototipo
};

Questo file è super compatto, chiunque può leggerlo e capire a colpo d’occhio cosa fa Layer.

Layer.cpp

1#include "Layer.hpp"

2Layer::Layer(int n) {
    num_neurons_ = n;
}

3void Layer::forward() {
    // ... complessi calcoli matriciali ...
    // ... migliaia di righe di codice ...
}
1
Includiamo l’header per far sapere al compilatore di quale classe parliamo.
2
Operatore di Scope Resolution ::. Diciamo: “Questo è il costruttore appartenente alla classe Layer”.
3
Implementiamo il metodo.

L’Incubo delle Inclusioni Multiple

In un grande progetto, si includono header ovunque: - Optimizer.hpp fa #include "Layer.hpp" - Network.hpp fa #include "Layer.hpp" - Il main.cpp fa #include "Network.hpp" e #include "Optimizer.hpp"

Il povero compilatore vedrà il testo di Layer.hpp incollato due volte nello stesso file finale. Si fermerà gridando: Errore: Ridefinizione della classe Layer!

Header Guards: La Protezione Storica

Per evitare inclusioni multiple, negli anni ’80 sono state inventate le Header Guards (Direttive per il pre-compilatore). Vanno scritte in ogni file .hpp.

1#ifndef LAYER_HPP_
2#define LAYER_HPP_

class Layer {
    // ... tutto il contenuto della classe
};

3#endif
1
“Se la macro LAYER_HPP_ non è ancora stata definita, entra.”
2
“OK, sono entrato. Ora la definisco!” Al prossimo tentativo di inclusione da parte di un altro file, il blocco #ifndef fallirà e il testo verrà ignorato.
3
Fine del blocco protetto.

La Protezione Moderna: #pragma once

Esiste un modo molto più semplice, elegante e moderno che fa esattamente la stessa cosa delle Header Guards senza dover inventare nomi per macro:

1#pragma once

class Layer {
    // ... tutto il contenuto della classe
};
1
Una singola riga all’inizio del file. Dice al compilatore: “Se hai già letto questo file fisico, non aprirlo mai più durante questa compilazione”.

Note

#pragma once non fa ufficialmente parte dello standard C++ originale, ma tutti i compilatori moderni (GCC, Clang, MSVC) lo supportano. Per progetti nuovi, usatelo tranquillamente!

Compilazione e Linking (Il Dietro le Quinte)

Se hai diviso il programma in main.cpp, Layer.cpp e Neuron.cpp, come si crea l’eseguibile?

  1. Compilazione (.cpp -> .o): Ogni file sorgente viene compilato in totale isolamento in un file “oggetto” binario (main.o, Layer.o). Al compilatore non interessa se il programma intero ha senso, gli basta che le singole funzioni chiamate siano state dichiarate negli header.
  2. Linking (.o -> Eseguibile): Il Linker prende tutti i file .o e li cuce insieme, cercando di collegare ogni chiamata di funzione alla sua vera implementazione. Se prometti una funzione in un header ma ti scordi di implementarla nel .cpp, il linker fallirà con l’errore: undefined reference.

CMake e l’Automazione

Nessun essere umano vuole scrivere a mano: g++ -c main.cpp -o main.o g++ -c Layer.cpp -o Layer.o g++ main.o Layer.o -o my_neural_net

Nel mondo reale (e nei nostri laboratori pratici) usiamo CMake. CMake analizza i nostri file e istruisce il compilatore su come generare l’eseguibile in un colpo solo. Voi premerete solo il tasto “Play” sull’editor!

Esercizio in Classe (15 min)

Separiamo l’Interfaccia dall’Implementazione!

  1. Crea due file testuali: Cerchio.hpp e Cerchio.cpp.
  2. Nel file .hpp:
    • Proteggilo con #pragma once.
    • Dichiara la classe Cerchio.
    • Dichiara un attributo privato raggio_.
    • Dichiara un costruttore che accetta il raggio.
    • Dichiara i metodi calcolaArea() e calcolaCirconferenza().
  3. Nel file .cpp:
    • Includi Cerchio.hpp e implementa il costruttore e i metodi calcolando l’area (\(\pi r^2\)) e circonferenza (\(2\pi r\)). Non dimenticare Cerchio::.

Soluzione: L’Header File

Cerchio.hpp

#pragma once

class Cerchio {
private:
    double raggio_;

public:
    // Costruttore
1    Cerchio(double r);
    
    // Metodi
2    double calcolaArea();
    double calcolaCirconferenza();
};
1
Solo la firma del costruttore.
2
Solo i prototipi dei metodi. Niente calcoli matematici qui.

Soluzione: Il Source File

Cerchio.cpp

#include "Cerchio.hpp"
#include <cmath> // Per i calcoli

// Implementazione del Costruttore
1Cerchio::Cerchio(double r) {
    if (r > 0) raggio_ = r;
    else raggio_ = 0.0;
}

// Implementazione dei Metodi
2double Cerchio::calcolaArea() {
    return 3.14159 * raggio_ * raggio_;
}

double Cerchio::calcolaCirconferenza() {
    return 2.0 * 3.14159 * raggio_;
}
1
Uso del Cerchio:: per indicare l’appartenenza.
2
Qui c’è la logica matematica. Nessuno che include l’Header ha bisogno di leggere questi calcoli!

L’Anatomia della RAM

Quando esegui un programma, il S.O. gli assegna un blocco di memoria. Questo blocco è diviso in 4 segmenti principali:

  1. Code Segment (Text): Contiene le istruzioni binarie (il codice macchina) del tuo programma. È in sola lettura.
  2. Data Segment: Contiene le variabili globali e le costanti (es. const double PI = 3.14). Sopravvivono per tutta l’esecuzione.
  3. Lo Stack: Memoria allocata automaticamente per le variabili locali.
  4. L’Heap: Memoria “libera” per allocazioni manuali.

Stack vs Heap: Il Duello

Lo Stack (La Scrivania)

  • Molto veloce (push/pop).
  • Gestito automaticamente dal compilatore.
  • Le variabili nascono alla { e muoiono alla } (scope).
  • Memoria contigua e rigorosa.
  • Molto piccolo (pochi MB). Se lo riempi, il programma muore con uno Stack Overflow.

L’Heap (Il Magazzino)

  • Più lento (richiede di cercare frammenti liberi).
  • Gestito manualmente dal programmatore.
  • La memoria vive per sempre, finché non ordini tu di liberarla.
  • Incline alla frammentazione.
  • Enorme (teoricamente tutta la RAM del tuo PC).

Il limite dello Stack

Fino ad ora, tutte le variabili che hai creato vivevano nello Stack. Ma c’è un problema. In C++ gli array statici sullo stack devono avere una dimensione fissa e nota a compile-time (prima che il programma parta)!

int main() {
    int dimensione;
    std::cin >> dimensione; // L'utente sceglie la dimensione a runtime
    
1    // int array_bello[dimensione];
    
    return 0;
}
1
ERRORE! Lo standard C++ vieta array con dimensione variabile a runtime nello Stack. Lo Stack ha bisogno di sapere in anticipo quanto spazio preparare.

Soluzione? Se vogliamo un array la cui dimensione dipende da un file letto a runtime, dobbiamo usare l’Heap!

Allocazione Dinamica: new

Il C++ ci fornisce la keyword new per chiedere spazio all’Heap.

int main() {
    // Chiedo al S.O. lo spazio per un double nello Heap.
    // "new" mi restituisce l'indirizzo. Lo salvo in "ptr".
1    double* ptr = new double(5.0);
    
    std::cout << *ptr << "\n"; // Uso il dato. Stampa 5.0
    
    return 0;
}
1
Attenzione: l’oggetto vero e proprio (il 5.0) è nell’Heap, ma il puntatore ptr che lo “guarda” è una normalissima variabile locale che vive nello Stack!

De-Allocazione Dinamica: delete

Lo spazio preso con new ti appartiene finché non lo restituisci. Se non lo restituisci prima che il programma finisca, crei problemi al S.O. Si usa delete per restituire la memoria.

int main() {
    double* ptr = new double(5.0);
    
    // ... faccio calcoli complessi ...
    
    // Ho finito! Restituisco lo spazio al S.O.
1    delete ptr;
    
    return 0;
}
1
delete NON cancella il puntatore ptr in sé! Cancella solo la memoria fisica a cui ptr sta puntando in quel momento.

Array Dinamici: new[] e delete[]

Possiamo allocare interi blocchi di memoria contigua nell’Heap. Questa volta, la dimensione può tranquillamente essere una variabile decisa a runtime!

int main() {
    int N = 10000; // Letto da tastiera o da file!
    
    // Alloco un array di 10.000 double nello Heap!
1    double* dati = new double[N];
    
    dati[0] = 3.14; 
    
    // Libero l'intero blocco di memoria!
2    delete[] dati;
    
    return 0;
}
1
Notare le parentesi quadre [ ] usate con new.
2
ATTENZIONE: Se hai usato new[], DEVI usare delete[]! Usare solo delete libererebbe solo il primo elemento dell’array, lasciando bloccati gli altri 9.999!

Esempio Avanzato: Matrici Dinamiche (Allocazione)

Come si alloca una matrice (array 2D) nell’Heap? Bisogna creare un “Array di Array”. Ovvero, un array di puntatori!

int main() {
    int righe = 3;
    int colonne = 4;
    
    // 1. Alloco un array di puntatori (le righe)
1    double** matrice = new double*[righe];
    
    // 2. Per ogni riga, alloco un array di double (le colonne)
    for(int i = 0; i < righe; ++i) {
2        matrice[i] = new double[colonne];
    }
    
    matrice[0][0] = 1.5; // La uso normalmente!
    
    // ... manca il delete!
    return 0;
}
1
double** significa “puntatore a un puntatore”.
2
Ogni riga è ora un array dinamico indipendente.

Esempio Avanzato: Matrici Dinamiche (De-allocazione)

Per liberare una matrice dinamica, non basta chiamare delete[] matrice;. Bisogna distruggerla compiendo esattamente il percorso inverso della creazione!

// ... codice precedente ...

    // 1. Prima libero ogni singola colonna (parto dall'interno)
    for(int i = 0; i < righe; ++i) {
1        delete[] matrice[i];
    }
    
    // 2. Infine libero l'array contenitore delle righe
2    delete[] matrice;
    
    return 0;
}
1
Se saltassi questo ciclo, le singole righe rimarrebbero incastrate nell’Heap per sempre!
2
Solo ora posso distruggere il “padre”.

Il Terrore: Il Memory Leak

Cosa succede se allochi memoria con new e ti dimentichi di chiamare delete?

void funzione_cattiva() {
    double* locale = new double[50000]; // Circa 400 Kilobyte
    
1    // Ops... la funzione termina e io non ho chiamato delete!
}

int main() {
    while(true) { // Loop infinito
2        funzione_cattiva();
    }
    return 0;
}
1
Il puntatore locale muore (esce dallo scope). Ma la memoria nell’Heap rimane occupata e non è più raggiungibile! È “persa” (Leak).
2
Ad ogni ciclo perdiamo 400KB. In pochi secondi, il S.O. esaurisce la RAM e uccide il programma senza pietà.

Un Leak Subdolo: Il Return Prematuro

Molti memory leak accadono non per distrazione, ma perché la logica del programma “salta” il delete!

void leggiFile() {
    int* buffer = new int[1000];
    
    bool errore_lettura = true; // Simuliamo un errore improvviso
    
    if (errore_lettura) {
        std::cerr << "Errore fatale!\n";
1        return;
    }
    
2    delete[] buffer;
}
1
Il programma rileva un errore e decide di uscire immediatamente dalla funzione.
2
Questa riga non verrà MAI eseguita. Abbiamo appena creato un Memory Leak in modo del tutto involontario! Vedremo a brevissimo come l’Incapsulamento (RAII) risolva questo problema alla radice.

I Disastri: Dangling Pointers

Cosa succede se chiami delete, ma poi continui ad usare il puntatore per sbaglio?

int main() {
    int* ptr = new int(42);
    
1    delete ptr; // Ho liberato la memoria.
    
2    *ptr = 100;
    
    return 0;
}
1
Il S.O. segna quell’area come “libera” e la assegna subito a un altro programma o a un’altra funzione.
2
ptr è diventato un Dangling Pointer (Puntatore Appeso). Stai scrivendo il numero 100 in una zona di memoria che ora appartiene a qualcun altro! Corromperai dati casuali.

Note

Best Practice: Dopo un delete, imposta sempre ptr = nullptr;. Se provi a usare un nullptr, il programma crasha all’istante, aiutandoti a isolare il bug, invece di causare una corruzione silenziosa!

I Disastri: Double Free

Cosa succede se liberi due volte la stessa scatola?

int main() {
    int* p1 = new int(10);
1    int* p2 = p1; // p2 guarda la stessa memoria di p1
    
2    delete p1;
    
    // ... più tardi ...
3    delete p2;
    
    return 0;
}
1
Due puntatori, una singola allocazione nell’Heap.
2
La memoria viene restituita al S.O.
3
CRASH! (Double Free / Corruption). Stai cercando di restituire al S.O. qualcosa che non ti appartiene più. Il sistema interviene e chiude il programma.

Come si trovano questi errori? (AddressSanitizer)

Individuare un memory leak in 10.000 righe di codice a occhio nudo è impossibile. I programmatori professionisti usano i Sanitizers.

Il compilatore C++ moderno (g++ o clang++) ha uno strumento magico chiamato AddressSanitizer. Basta compilare con un flag speciale:

g++ -fsanitize=address -g main.cpp -o main

Se c’è un Memory Leak o un Dangling Pointer, il programma si bloccherà dicendovi l’esatta riga di codice .cpp in cui avete fatto il new dimenticando il delete!

Tip

Usate -fsanitize=address durante tutto lo sviluppo. Toglietelo solo alla fine quando generate la versione “Release” per la massima velocità.