1. Compilazione separata
Se state iniziando a programmare, la compilazione separata può sembrare una cerimonia strana: «Perché non fare sempre g++ main.cpp -o app e vivere tranquilli?» Sui piccoli esempi si può fare. Ma appena il progetto raggiunge 5–20 file, si inizia a notare che la build somiglia alla preparazione del borscht: anche se aggiungete un pizzico di sale, per qualche motivo vi viene chiesto di far crescere di nuovo la barbabietola.
La compilazione separata risolve proprio questo problema. L'idea è molto semplice: compilare separatamente ogni .cpp in un artefatto intermedio (file oggetto), e poi "cucirli" insieme con il linker. Così, modificando un singolo .cpp non è necessario ricompilare tutto: si ricompila solo il file oggetto interessato e si rilinka il risultato.
Questo fa risparmiare tempo e aiuta anche a capire meglio dove si è verificato l'errore: nella fase di compilazione di un file specifico o nella fase di linking di tutte le parti in un unico programma.
2. File oggetto e il flag -c
Che cos'è un file oggetto .o
Un file oggetto (.o) è il risultato della compilazione di un solo .cpp. Contiene codice macchina (o quasi), più tabelle dei simboli: quali funzioni e variabili quel file definisce, e quali vuole ricevere dall'esterno.
Immaginate che il .cpp sia un singolo pezzo di un set LEGO. Il compilatore lo trasforma in un "pezzo pronto" (file oggetto), ma può avere dei fori dove altri pezzi devono inserirsi. Il linker è colui che prende tutti i pezzi e costruisce una grande nave, verificando che tutti i fori coincidano e che niente sia perso sotto il divano.
È importante ricordare: un file oggetto non è ancora un programma. Non è eseguibile. È un risultato intermedio.
Il flag -c: "compila, ma non linkare"
Il flag -c dice letteralmente al compilatore: "Fai tutto ciò che riguarda la compilazione, ma fermati prima del linking". Quindi il compilatore prenderà il vostro .cpp, lo trasformerà in un file oggetto .o — e si fermerà lì.
La forma tipica del comando è:
g++ -std=c++23 -c file.cpp
Se non specificate -o, il compilatore di solito crea un file oggetto con un nome prevedibile file.o. Ma quando costruite un progetto con più file, è meglio abituarsi a nominare esplicitamente gli artefatti di uscita:
g++ -std=c++23 -c file.cpp -o file.o
3. Mini‑progetto TextStats
Per non discutere la compilazione separata nel vuoto, costruiremo una piccola applicazione composta da più file. Leggerà una riga e stamperà: il numero di caratteri alfabetici e il numero di spazi. La logica è semplice, ma è comodo estrarla in un modulo separato.
Struttura dei file
flowchart LR A[main.cpp] -->|#include 'text_stats.hpp'| B[text_stats.hpp] C[text_stats.cpp] -->|#include 'text_stats.hpp'| B A -->|chiama funzioni| C
text_stats.hpp: dichiarazioni delle funzioni
Quando si inizia a dividere il progetto in file, è importante separare mentalmente due ruoli: l'header .hpp dice "cosa esiste" (dichiarazioni), mentre il .cpp contiene "come funziona" (definizioni). È come un menu in un bar: il menu promette che "la pizza esiste", ma la pizza non la ottieni dal menu, la ottieni dalla cucina.
Creiamo l'header text_stats.hpp:
#pragma once
#include <string>
int count_letters(const std::string& s);
int count_spaces(const std::string& s);
Notate: qui non c'è l'implementazione, solo le firme. E va bene così.
text_stats.cpp: definizioni delle funzioni
Ora creiamo il file text_stats.cpp che implementa le funzioni. È importante che le firme coincidano esattamente con quanto dichiarato nel .hpp.
#include "text_stats.hpp"
#include <cctype>
int count_letters(const std::string& s) {
int cnt = 0;
for (char c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) ++cnt;
}
return cnt;
}
E la seconda funzione (teniamo gli esempi volutamente piccoli):
#include "text_stats.hpp"
int count_spaces(const std::string& s) {
int cnt = 0;
for (char c : s) if (c == ' ') ++cnt;
return cnt;
}
Sì, qui c'è due volte #include "text_stats.hpp". In un progetto reale terreste entrambe le funzioni in un unico text_stats.cpp, ma per scopi didattici è utile vedere che ogni .cpp è un'unità compilabile a sé stante.
main.cpp: usiamo le funzioni
In main.cpp leggiamo una riga e stampiamo le statistiche:
#include <iostream>
#include <string>
#include "text_stats.hpp"
int main() {
std::string s;
std::getline(std::cin, s);
std::cout << "letters=" << count_letters(s) << '\n';
std::cout << "spaces=" << count_spaces(s) << '\n';
}
Esempio di input/output (per vedere il risultato):
input: Hello world
output: letters=10
spaces=1
4. Build manuale: .cpp → .o → executable
Ora la parte più gustosa: come costruire tutto manualmente.
Passo 1: compiliamo ogni .cpp in file oggetto
Prima compilate ogni sorgente separatamente:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o
g++ -std=c++23 -c main.cpp -o main.o
A questo punto non viene eseguito nulla. Avrete semplicemente i file text_stats.o e main.o.
Passo 2: linkiamo i file oggetto in un programma
Ora bisogna "cucire" i file oggetto in un eseguibile. Si fa con una normale chiamata a g++, ma senza -c, e gli input saranno i .o:
g++ main.o text_stats.o -o textstats
Dopo questo comparirà textstats (o textstats.exe su ambienti Windows). E questo può essere eseguito:
./textstats
5. Ricompilazione incrementale
Immaginate la situazione tipica: decidete di migliorare count_letters e aggiungere il trattamento di qualche carattere. Modificate solo text_stats.cpp.
Con la compilazione separata procedete così:
-
ricompilate solo il file oggetto interessato:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o -
rilinkate:
g++ main.o text_stats.o -o textstats
main.cpp in questo modo non viene toccato, main.o resta invariato. Questo è il vantaggio pratico: il progetto può essere grande, voi cambiate una piccola parte — e ricompilate solo quella.
6. Errore undefined reference: cosa significa
Gli errori di compilazione sono di solito abbastanza chiari: indicano riga, colonna e cosa si aspettava. Gli errori di linking all'inizio sembrano una "maledizione in una lingua antica". Ma hanno una logica molto precisa.
La frase undefined reference to ... significa: da qualche parte c'è un utilizzo di una funzione/variabile, ma tra ciò che avete passato al linker non c'è una definizione corrispondente.
La causa più comune a questo livello è che avete dimenticato di aggiungere il file oggetto necessario (o il corrispondente .cpp) nella fase di linking.
Facciamo un esempio di build sbagliata. Avete fatto:
g++ -std=c++23 -c main.cpp -o main.o
g++ main.o -o textstats
Quindi non avete linkato text_stats.o. Cosa succederà? La compilazione di main.cpp passerà, perché l'header text_stats.hpp ha fornito al compilatore le dichiarazioni. Ma il linker, quando "cucirà" il programma, dirà: "Ok, in main.o c'è una chiamata a count_letters, ma dov'è l'implementazione? Io non la vedo".
E qui entra in gioco una regola importantissima:
#include aiuta la compilazione (visibilità delle dichiarazioni), ma non aiuta la fase di linking (collegamento delle implementazioni).
Mentalmente questa è una trappola per i principianti: "Ma ho incluso l'header!" Sì, l'avete fatto. Ma l'header non è un .cpp né un .o.
7. Come diagnosticare un undefined reference esaminando i file
Quando vedete undefined reference, si ha voglia di fare esperimenti frenetici: spostare file, mettere #include a caso, rinominare tutto. È divertente, ma di solito inefficace.
Meglio tenere a mente un algoritmo calmo.
Prima determiniamo che si tratta di linking
Gli errori di linking di solito contengono parole come undefined reference, ld, linker, collect2. Il compilatore potrebbe comunque aver creato il file .o.
Se è linking, il problema è quasi sempre uno dei tre:
- non avete aggiunto il .o (o il .cpp) giusto al linking;
- avete dichiarato una cosa e definito un'altra (le firme non coincidono);
- avete definito la stessa cosa "troppo spesso" (questo è già multiple definition, lo tocchiamo solo di sfuggita).
Se manca un file oggetto, fatevi una domanda
La domanda è: in quale .cpp doveva vivere il codice di quella funzione?
Per esempio, se l'errore riguarda count_spaces, il candidato ovvio è text_stats.cpp. Quindi sulla linea di linking dovrebbe esserci text_stats.o.
Cioè il comando di linking deve elencare i file oggetto in modo che tra essi ci sia quello che contiene la definizione.
Se il file oggetto c'è, ma il simbolo non viene trovato
Questo è un caso più subdolo: sembra che stiate linkando text_stats.o, ma l'errore persiste. Allora è molto probabile che il compilatore e il linker vedano due funzioni diverse:
- in main.cpp chiamate int count_letters(const std::string&),
- ma in text_stats.cpp per errore avete scritto int count_letters(std::string) (per valore) o avete dimenticato const.
Per un umano questo sembra "quasi lo stesso". Per il linker sono due simboli diversi. Non è tenuto a indovinare le vostre intenzioni (e per fortuna, altrimenti inizierebbe a indovinare tutto).
Collegamento con ODR e inline
Oggi non entriamo in teoria profonda, ma è utile sapere che molti problemi di linking sono legati alla regola "one definition rule" (ODR). Anche nelle note al standard si sottolinea che inline è prima di tutto uno strumento per permettere a più dichiarazioni di soddisfare l'ODR, non un "suggerimento per l'ottimizzatore".
In termini semplici: se iniziate a mettere definizioni di funzioni negli header senza comprenderne le conseguenze, potete facilmente ottenere multiple definition. Per ora tenete una regola semplice: dichiarazioni — nel .hpp, implementazioni — nel .cpp.
Cheat sheet dei comandi
È utile avere una piccola mappa per non dover ricordare tutto a memoria.
| Scopo | Comando | Risultato |
|---|---|---|
| Compilare un file, ma non linkare | |
file oggetto |
| Linkare un programma dai file oggetto | |
eseguibile |
| Ricompilare solo il modulo modificato | |
aggiornato |
| Capire rapidamente un undefined reference | controllare che nel linking sia presente il file |
scomparsa dell'errore |
8. Errori tipici
Errore n.1: aspettarsi un eseguibile dopo un comando con -c.
-c disattiva il linking. Il compilatore crea onestamente un file oggetto e si ferma. Se dopo g++ -c main.cpp provate a eseguire il risultato — state cercando di eseguire un "semilavorato". È come cercare di mangiare l'impasto perché "ho già mescolato farina e acqua".
Errore n.2: pensare che #include "includa l'implementazione".
#include inserisce il testo dell'header nel vostro .cpp durante la fase di preprocessore. Aiuta la compilazione a vedere le dichiarazioni, ma non aggiunge codice al linking. Perciò un undefined reference si risolve non aggiungendo #include, ma includendo il file .o (o il .cpp) correttamente nella riga di linking.
Errore n.3: non linkare tutti i file oggetto del progetto.
Questa è la causa più frequente di undefined reference a livello di riga di comando. Avete compilato main.o, compilato text_stats.o, ma nel linking avete scritto solo g++ main.o -o app. Il linker non è telepatico: se un file non è nella lista, è come se non esistesse.
Errore n.4: mismatch tra la firma dichiarata e quella definita.
È insidioso il caso in cui nell'header avete int f(const std::string&);, ma nel .cpp scrivete int f(std::string);. La compilazione separata di ciascun file può succedere, ma il linking fallirà. Il linker cerca una corrispondenza esatta del simbolo. Per lui sono funzioni diverse, anche se a voi sembrano quasi identiche.
Errore n.5: "risolvere rapidamente" spostando la definizione nell'header e creare nuovi problemi.
A volte dopo un undefined reference un principiante pensa: "Ok! Allora rendiamo l'implementazione visibile ovunque" — e incolla il corpo della funzione nell'.hpp. Su un progetto piccolo questo può "funzionare per caso", ma in un progetto reale porta facilmente a multiple definition (perché lo stesso codice finisce in più unità di traduzione). A questo livello è più sicuro attenersi alla disciplina base: implementazione nel .cpp, dichiarazioni nell'.hpp.
GO TO FULL VERSION