Header Files, Compilazione e Memoria (Stack/Heap)
.cppFinora 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:
Soluzione C++: Separare sempre la Dichiarazione (Interfaccia) dall’Implementazione!
.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.
Layer.hppQuesto file è super compatto, chiunque può leggerlo e capire a colpo d’occhio cosa fa Layer.
Layer.cpp::. Diciamo: “Questo è il costruttore appartenente alla classe Layer”.
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!
Per evitare inclusioni multiple, negli anni ’80 sono state inventate le Header Guards (Direttive per il pre-compilatore). Vanno scritte in ogni file .hpp.
LAYER_HPP_ non è ancora stata definita, entra.”
#ifndef fallirà e il testo verrà ignorato.
#pragma onceEsiste 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
};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!
Se hai diviso il programma in main.cpp, Layer.cpp e Neuron.cpp, come si crea l’eseguibile?
.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..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.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!
Separiamo l’Interfaccia dall’Implementazione!
Cerchio.hpp e Cerchio.cpp..hpp:
#pragma once.Cerchio.raggio_.calcolaArea() e calcolaCirconferenza()..cpp:
Cerchio.hpp e implementa il costruttore e i metodi calcolando l’area (\(\pi r^2\)) e circonferenza (\(2\pi r\)). Non dimenticare Cerchio::.Cerchio.hpp
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_;
}Cerchio:: per indicare l’appartenenza.
Quando esegui un programma, il S.O. gli assegna un blocco di memoria. Questo blocco è diviso in 4 segmenti principali:
const double PI = 3.14). Sopravvivono per tutta l’esecuzione.{ e muoiono alla } (scope).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;
}Soluzione? Se vogliamo un array la cui dimensione dipende da un file letto a runtime, dobbiamo usare l’Heap!
newIl 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;
}5.0) è nell’Heap, ma il puntatore ptr che lo “guarda” è una normalissima variabile locale che vive nello Stack!
deleteLo 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;
}delete NON cancella il puntatore ptr in sé! Cancella solo la memoria fisica a cui ptr sta puntando in quel momento.
new[] e delete[]Possiamo allocare interi blocchi di memoria contigua nell’Heap. Questa volta, la dimensione può tranquillamente essere una variabile decisa a runtime!
[ ] usate con new.
new[], DEVI usare delete[]! Usare solo delete libererebbe solo il primo elemento dell’array, lasciando bloccati gli altri 9.999!
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;
}double** significa “puntatore a un puntatore”.
Per liberare una matrice dinamica, non basta chiamare delete[] matrice;. Bisogna distruggerla compiendo esattamente il percorso inverso della creazione!
Cosa succede se allochi memoria con new e ti dimentichi di chiamare delete?
Molti memory leak accadono non per distrazione, ma perché la logica del programma “salta” il delete!
Cosa succede se chiami delete, ma poi continui ad usare il puntatore per sbaglio?
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!
Cosa succede se liberi due volte la stessa scatola?
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:
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à.