1. È importante capire la build nell'IDE
A questo punto del corso scrivete già programmi che possono consistere di più file, ed è il momento in cui la magia del pulsante «Run» comincia a volte… lampeggiare un po' in rosso. È importante capire una cosa semplice: il pulsante di esecuzione nell'IDE non è una sola operazione, ma una catena di passaggi. E quando qualcosa si rompe, è utile poter dire: «Ok, si è rotto nella fase in cui si incollano i file», oppure «Questo è successo prima: il file non è stato neanche letto correttamente da #include».
In C++ la build è quasi sempre divisa in tre grandi fasi:
- preprocessing (preprocessamento),
- compile (compilazione),
- link (link).
Parleremo di queste fasi senza riga di comando (niente paurosi g++ -std=c++23 ...), solo come di una catena interna che l'IDE esegue per voi.
La mappa completa: da .cpp all'esecuzione del programma
Se si immagina la build come la produzione di una pizza (sì, anch'io vorrei che il link fosse una pizza), il preprocessore è la fase in cui aprite le confezioni degli ingredienti e leggete la ricetta, il compilatore è la «cottura delle singole parti», e il linker è la fase in cui si mette tutto in una scatola e si chiude il coperchio. Non è un'analogia perfetta, ma aiuta a non confondere i ruoli.
In forma di schema appare così:
flowchart LR
A[".cpp + #include .hpp"] -->|preprocessing| B["translation unit (testo dopo #include/#define)"]
B -->|compile| C["object file (.o/.obj)"]
C -->|link| D["executable (programma)"]
Di seguito — una tabella «input → output», per avere punti di riferimento visivi:
| Fase | Cosa forniamo in input | Cosa otteniamo in output | Cosa succede «in termini di significato» |
|---|---|---|---|
| Preprocessing | Testo .cpp + header inclusi .hpp + macro | Un unico grande testo (logicamente) | #include inserisce file, #define sostituisce testo, #if/#ifdef elimina/lascia parti |
| Compile | Il testo risultante di un singolo .cpp | File oggetto (.o/.obj) | Controllo di sintassi/tipi + generazione di codice macchina «per parti» |
| Link | Insieme di file oggetto | File eseguibile | Incollaggio di tutte le parti, «collegamento» delle chiamate di funzione con le loro implementazioni |
Conclusione pratica chiave (per ora teniamola a mente): il compilatore guarda ogni .cpp separatamente, mentre «mettere tutto insieme in un programma» è compito del linker.
2. Preprocessing: quando #include è «copia/incolla, ma ufficiale»
Il preprocessore è una fase che i principianti spesso sottovalutano, perché «ma è solo #include». Poi succede la magia: cambi una riga nell'header e all'improvviso «si è rotto tutto». Oppure sei sicuro di aver scritto il codice, ma il compilatore sembra vedere un testo diverso. Non c'è mistero: il preprocessore trasforma letteralmente il testo prima che il compilatore inizi a capire tipi, funzioni e regole del linguaggio.
Cosa fa davvero #include
#include "file.hpp" significa: «prendi il contenuto di file.hpp e inseriscilo qui». Proprio come Ctrl+C/Ctrl+V, ma senza senso di colpa e con supporto per sviluppo industriale.
Immaginiamo di continuare la nostra semplice applicazione console TaskBook (una lista di task molto semplice). Manteniamo l'interfaccia (dichiarazioni) in .hpp e l'implementazione in .cpp.
task.hpp (header, dichiarazioni):
#pragma once
#include <string>
void addTask(std::string title);
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
addTask("Read about linker");
std::cout << "OK\n"; // OK
}
Quando il preprocessore elaborerà main.cpp, otterrà un «testo unico» dove al posto di #include "task.hpp" ci sarà il contenuto dell'header (più il contenuto degli header che esso include, ad esempio <string>). Il compilatore analizzerà poi proprio questo testo.
Ed è importante: il preprocessore non capisce C++ come linguaggio. Non sa cosa sia una «funzione», un «tipo» o un «template». Lavora su testo e direttive #....
Macro: sostituzione di testo, non «piccole funzioni»
Un macro è anch'esso una questione puramente testuale. Questo significa che non rispetta regole di tipo, scope, e si comporta come se aveste fatto manualmente la sostituzione del testo.
Esempio:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
std::cout << SQUARE(1 + 2) << '\n'; // 9
}
Le parentesi qui ci salvano dalle classiche trappole di precedenza. Se avessimo scritto #define SQUARE(x) x * x, allora SQUARE(1 + 2) sarebbe diventato 1 + 2 * 1 + 2, e il risultato sarebbe... una sorpresa. Questa sorpresa è particolarmente fastidiosa, perché il compilatore formalmente non è obbligato a capire che volevate il «quadrato di un numero», vede solo un'espressione valida.
Compilazione condizionale: codice che «esiste» non sempre
Un altro strumento importante del preprocessore è la compilazione condizionale. È quando una porzione di codice viene inclusa o esclusa prima della compilazione.
Per esempio, facciamo una semplice configurazione per la diagnostica:
config.hpp:
#pragma once
#define ENABLE_DIAGNOSTICS 1
main.cpp:
#include <iostream>
#include "config.hpp"
int main() {
#if ENABLE_DIAGNOSTICS
std::cout << "Diagnostics ON\n"; // Diagnostics ON
#endif
std::cout << "Run\n"; // Run
}
Qui il compilatore vedrà o entrambi i std::cout, o solo "Run", a seconda del valore della macro. Per il compilatore il codice «rimosso» sembra semplicemente non esistere.
Un piccolo fatto teorico (ma utile): lo standard C++ descrive le «fasi di traduzione», e lì sono evidenziati i passaggi in cui le entità del preprocessore vengono elaborate e scompaiono dal testo prima dell'analisi successiva; le formulazioni e le revisioni su questo sono regolarmente discusse nei draft di lavoro dello standard.
4. Unit di traduzione: cos'è e perché conta
La parola «unità di traduzione» suona come se stessimo per tradurre Shakespeare. In realtà è un concetto molto pratico: l'unità di traduzione è ciò che si ottiene da un singolo .cpp dopo il preprocessore. Quindi .cpp più tutto ciò che è stato incluso via #include (direttamente o indirettamente), più macro espanse, più parti eliminate da #if.
Perché è importante? Perché la compilazione avviene per unità di traduzione. Quindi se avete main.cpp, task.cpp, storage.cpp, avrete tre unità di traduzione separate, e il compilatore le tratterà indipendentemente.
Questo spiega subito due «stranezze» che probabilmente avete già visto.
- Prima stranezza: «Perché ho scritto una funzione in task.cpp, ma in main.cpp il compilatore dice che non esiste?» Perché il compilatore compila main.cpp separatamente e deve vedere la dichiarazione (di solito tramite .hpp) direttamente in main.cpp dopo il #include.
- Seconda stranezza: «Perché se definisco una variabile nell'header, improvvisamente si rompe in molti file?» Perché l'header viene incluso in più unità di traduzione, e la stessa definizione si replica. Questo è esattamente il punto in cui emerge il tema del ODR: un progetto, una definizione di un'entità (nel senso appropriato).
5. Compile: la compilazione come «assemblaggio di pezzi separati»
La compilazione è la fase in cui dal testo (già dopo il preprocessore) si ottiene il file oggetto. L'idea più importante del giorno è: ogni .cpp viene compilato separatamente. Non «l'intero progetto tutto insieme», non «tutti i file contemporaneamente», ma rigorosamente uno per uno.
Cos'è un file oggetto e perché lo vedete raramente
Il file oggetto (.o o .obj) è il risultato della compilazione di un singolo .cpp. L'IDE di solito nasconde questi file in cartelle di servizio per non infastidire, ma logicamente si può pensare così:
- contiene già codice macchina per le funzioni di quel .cpp,
- contiene informazioni su quali simboli (funzioni/variabili globali) quel file fornisce,
- e quali simboli esso usa ma non definisce (ad esempio, chiama funzioni definite in altri .cpp).
Tornando al nostro TaskBook, task.cpp dopo la compilazione diventa una «scatola di pezzi»: contiene l'implementazione di addTask, ma la scatola non è ancora un programma pronto.
Perché la compilazione può riuscire anche se il programma non si può ancora linkare
Ecco una situazione frequente che sembra un paradosso, ma in realtà è del tutto logica.
task.hpp:
#pragma once
#include <string>
void addTask(std::string title);
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
addTask("Buy milk");
std::cout << "done\n"; // done
}
Questo main.cpp può compilare perfettamente, perché al compilatore basta conoscere la dichiarazione di addTask. Vede: «ok, esiste una funzione che prende std::string e ritorna void». Il compilatore può generare il codice per la chiamata.
Ma dove è il corpo di addTask? Questo non è un problema della compilazione di main.cpp. Diventerà un problema più avanti, al link, se l'implementazione manca o non è parte della build.
Perché modificare un .hpp spesso «ricompila mezza codebase»
Se cambiate un .cpp, l'IDE di solito ricompila solo quel .cpp. Ma se cambiate un header .hpp incluso in molti .cpp, l'IDE è costretto a ricompilare tutte le unità di traduzione interessate, perché dopo il preprocessore il loro testo finale è cambiato.
Questa è una delle ragioni per cui si diceva «include what you use» e «non mettere cose inutili in .hpp»: gli header influenzano fortemente i tempi di build.
6. Link: incollare i file oggetto in un unico programma
La linkatura è la fase finale: prendiamo diversi file oggetto e otteniamo un eseguibile che si può già lanciare. Se la compilazione è la fabbricazione dei pezzi di un kit, il link è l'istruzione «come connettere tutto per ottenere un modello che non si sfaldi al primo “Run”».
Cosa significa «collegare i simboli»
Quando main.cpp chiama addTask(...), nel file oggetto main.o ci sarà un'annotazione del tipo: «mi serve la funzione addTask(std::string)». E nel file oggetto task.o ci sarà l'annotazione: «eccola, la funzione addTask(std::string), prelevatela».
Il linker fa da «matrimonio» per questi due: trova dove è definito il simbolo richiesto e collega la chiamata con l'implementazione.
- Se l'implementazione non viene trovata — il linker non può costruire il programma.
- Se l'implementazione è trovata in due diversi file oggetto (cioè avete definito la stessa cosa due volte) — anche in questo caso il linker si lamenterà, perché non è chiaro quale delle due versioni sia quella «vera».
Mini-esempio «dichiarato ma non definito»
Facciamo un piccolo esempio in stile «come appare in un progetto».
math.hpp:
#pragma once
int add(int a, int b);
main.cpp:
#include <iostream>
#include "math.hpp"
int main() {
std::cout << add(2, 3) << '\n'; // vogliamo 5
}
Se nel progetto non esiste math.cpp con l'implementazione di add, allora main.cpp può compilare, ma il programma non si potrà linkare completamente. Questo perché «chiamare una funzione si può sapendo la dichiarazione», mentre «eseguire la chiamata richiede che da qualche parte ci sia il corpo».
Importante: ora non entriamo nella dettagliata classificazione dei messaggi di errore — sarà una lezione separata. Per ora ci serve solo il modello mentale: il link è il passo in cui il progetto diventa un unico eseguibile.
7. Come pensare alle fasi della build
Come identificare la fase a partire dai sintomi
Dopo la lezione di oggi dovreste prendere l'abitudine: quando qualcosa va storto, chiedetevi mentalmente: «In quale fase è successo?». Questo aiuta molto a non girare a vuoto nel codice.
- Se il problema è legato a #include (file non incluso, macro sostituita in modo strano, pezzo di codice «sparito»), è quasi sempre area del preprocessore.
- Se il problema è che «il codice non è riconosciuto come C++» (sintassi, tipi, «impossibile convertire», «metodo inesistente»), è area di compilazione.
- Se il problema è che «tutto andava bene, ma alla fine qualcosa non torna» (implementazione non trovata, trovate due implementazioni), è area di link.
E sì: non dovete ricordare tutte queste parole a memoria. L'importante è tenere a mente che il pulsante «Run» in un IDE esegue tre magie, non una sola. (Se fosse una sola magia, C++ sarebbe troppo gentile come linguaggio, e sarebbe sospetto.)
Esempio pratico: TaskBook attraversa tutta la pipeline
Ora costruiamo mentalmente una mini-versione di TaskBook composta da tre file, per vedere il percorso completo senza troppa complessità. Notate: gli esempi sono piccoli, ma la struttura è già «da progetto maturo»: interfaccia in .hpp, implementazione in .cpp, uso in main.cpp.
task.hpp:
#pragma once
#include <string>
void addTask(const std::string& title);
task.cpp:
#include "task.hpp"
#include <iostream>
void addTask(const std::string& title) {
std::cout << "Added: " << title << '\n'; // Added: ...
}
main.cpp:
#include "task.hpp"
int main() {
addTask("Learn preprocessing");
addTask("Learn compilation");
}
Ora percorriamo mentalmente la build.
- Prima il preprocessing per main.cpp: inserirà il contenuto di task.hpp (e lì, a sua volta, verrà inserito <string>). Dopo questo il compilatore vedrà la dichiarazione di addTask.
- Poi il compile: main.cpp diventa un file oggetto, con main() e con le chiamate a addTask, la cui implementazione starebbe da qualche parte. Separatamente viene compilato task.cpp: qui appare la funzione reale addTask.
- Infine il link: il linker collega main() e addTask in un unico programma. Solo dopo questo si può eseguire.
8. Errori tipici
Errore n.1: pensare che il compilatore “vada a vedere tutto il progetto in una volta”.
È una delle trappole mentali più comuni. Sembra logico: «tanto il progetto è tutto nello stesso posto». Ma in realtà la compilazione avviene per singolo .cpp. Perciò, se non avete incluso il giusto .hpp, il vicino task.cpp non vi salva: al momento della compilazione di main.cpp il compilatore non è tenuto a saperne nulla.
Errore n.2: aspettarsi che il preprocessore capisca tipi e scope.
Il preprocessore lavora sul testo. Non sa cosa sia std::string, non sa cosa sia una «variabile» e non sa «sostituire un macro in modo intelligente». Sostituirà semplicemente il pezzo di testo. Per questo i macro senza parentesi, i #define ingegnosi e gli strambi #if spesso producono effetti che sembrano bug del compilatore, mentre sono solo sostituzioni testuali.
Errore n.3: lasciare definizioni negli header “perché è più comodo”.
I principianti tendono a voler mettere tutto in .hpp per «vederlo subito». Ma l'header viene incluso da molti .cpp, e le definizioni (soprattutto variabili globali e funzioni non inline) si moltiplicano. Questo può portare a fallimenti nella build finale: il linker troverà definizioni duplicate e rifiuterà di costruire il programma.
Errore n.4: confondere “il file esiste” con “il file fa parte della build”.
Anche se task.cpp si trova nella cartella del progetto, non è garantito che l'IDE lo abbia aggiunto alla build. In alcuni ambienti bisogna includere esplicitamente il file nel target/progetto. In quel caso la compilazione dei singoli file può sembrare corretta, ma al passo finale di «incollaggio» potrebbe risultare che l'implementazione manca.
Errore n.5: curare problemi di link modificando il codice che compila.
Quando il programma «non si linka», la tentazione è riscrivere main.cpp, aggiungere #include, cambiare le firme a casaccio. Spesso questo genera caos. È molto più saggio chiedersi prima: «Sono sicuro che il problema sia di link? Allora cerco disallineamenti tra dichiarazioni/definizioni o l'assenza del giusto .cpp nella build». Questo approccio risparmia ore e riduce la voglia di «passare a Python» (anche se Python non è il colpevole).
GO TO FULL VERSION