CodeGym /Corsi /C++ SELF /Ogni .cpp viene compilato separatamente

Ogni .cpp viene compilato separatamente

C++ SELF
Livello 26, Lezione 0
Disponibile

1. Compilazione separata e translation unit

Quando si impara, un unico file va bene: meno cambi di contesto, più facile copiare, più semplice consegnare esercizi. Ma non appena il programma vive più di una serata, appare la situazione tipica: main.cpp si ingrandisce, come la cartella Download di una persona che crede che un giorno riordinerà tutto. In un punto avete l'input, in un altro i calcoli, vicino la stampa dei modelli, e tutto è intrecciato così strettamente che è spaventoso spostare anche uno spazio in più.

Un progetto su più file permette di distribuire il codice per significato: separare «logica», «input-output», «modelli dati». E un punto importante: dividiamo il codice non per il compilatore, ma per le persone. Il compilatore, francamente, è pronto a digerire anche un file enorme — ma poi dovrete viverci dentro voi.

A partire dal tema di oggi, porteremo gradualmente il nostro progetto didattico a uno stadio più maturo. Immaginiamo di avere una piccola applicazione console "Todo": memorizza le attività in std::vector<std::string>, stampa la lista e permette di aggiungere una nuova attività. Finora tutto era in un unico main.cpp. Oggi proveremo a spostare alcune funzioni in un .cpp separato — e con questo coglieremo l'idea principale della lezione.

Il compilatore compila ogni .cpp separatamente

La considerazione importante del giorno è quasi offensivamente semplice: il compilatore compila ogni .cpp separatamente. Non apre «l'intero progetto» come un grande testo unico (per impostazione predefinita). Prende un .cpp, lo elabora, lo trasforma in un risultato intermedio (semplificando: un "object file"), poi prende il successivo .cpp, e così via.

Perché è fatto così? Perché è comodo e veloce per progetti grandi. Se avete cambiato una funzione in un .cpp, sarebbe strano ricompilare tutto il resto. La compilazione separata permette di ricostruire solo le parti che sono cambiate.

Ma questo modello ha una conseguenza diretta che cattura i principianti già al primo tentativo di separare i file: il codice in un .cpp non diventa automaticamente visibile in un altro .cpp. Ovvero «la funzione esiste nel file vicino» non aiuta, finché il compilatore non ha visto almeno la sua dichiarazione nel punto in cui la chiamate.

Immaginate di avere due verifiche in due aule. Nell'aula №1 avete scritto una splendida formula su un foglio. Nell'aula №2 l'insegnante controlla un altro foglio e non sa cosa avete scritto nella prima aula. Così si comportano i diversi .cpp: ognuno vive la sua vita.

Che cosa è una translation unit

Introduciamo ora il termine chiave: translation unit.

Quando il compilatore «compila il file main.cpp», non compila solo il testo di main.cpp, ma anche tutto ciò che è stato inserito tramite #include. Mentalmente si può pensare che #include sia un'operazione molto stupida ma utile di «incolla qui il testo di un altro file». Quindi la translation unit è:

il .cpp corrente + il testo di tutti i header che sono stati inclusi (direttamente o indirettamente)

Da qui la logica: se volete che la dichiarazione di una funzione/tipo sia visibile in main.cpp, deve trovarsi all'interno della translation unit di main.cpp. Il modo più comune è includere un header (.hpp), ma per oggi fissiamo solo il principio.

Lo standard C++ prevede fasi formali di "traduzione" della sorgente; tra queste c'è una fase in cui, dopo il preprocessore, si forma ciò che il compilatore considera la translation unit. Nei materiali di lavoro dello standard questo è legato alle fasi di traduzione e si sottolinea che il concetto di translation unit è proprio connesso a queste fasi.

Mini-schema utile (molto semplificato, ma funziona per farsi un'idea):

flowchart TD
    A[main.cpp + #include ...] --> B[Preprocessore: inserito il testo degli header]
    B --> C[Translation unit]
    C --> D[Compilazione: controllo sintassi/tipi, generazione di object code]
    D --> E[... poi "incollamento" di tutte le parti nel programma]

Oggi ci interessa soprattutto il concetto di «Translation unit»: è il confine di visibilità nella fase di compilazione di un singolo file.

3. Pratica: perché i .cpp non si "vedono" tra loro

Esempio minimo: "la funzione esiste, ma il compilatore non la conosce"

Facciamo l'esempio più semplice che dimostra il problema.

Variante A: "sembra che tutto dovrebbe funzionare"… ma no

math.cpp:

int add(int a, int b) {
    return a + b;
}

main.cpp:

#include <iostream>

int main() {
    std::cout << add(2, 3) << '\n'; // vogliamo 5
}

La logica del principiante è chiara: «la funzione c'è, eccola, nel file vicino». Ma il compilatore compila main.cpp separatamente. Nella translation unit di main.cpp il nome add non esiste. Quindi l'errore sarà all'incirca (la formulazione dipende dal compilatore/IDE): "error: 'add' was not declared in this scope".

Ovvero: «non so cosa sia add».

Variante B: aggiunta della dichiarazione — il compilatore è soddisfatto

Ora aggiungiamo la dichiarazione della funzione in main.cpp — solo una riga, senza il corpo:

main.cpp:

#include <iostream>

int add(int a, int b); // dichiarazione: "questa funzione esiste"

int main() {
    std::cout << add(2, 3) << '\n'; // 5
}

math.cpp rimane lo stesso:

int add(int a, int b) {
    return a + b;
}

Ora durante la compilazione di main.cpp il compilatore conosce la firma add(int, int) e può verificare la correttezza della chiamata. Il corpo della funzione non è necessario in questa fase — basta la dichiarazione.

E qui emerge una disciplina che svilupperemo: chiamata in un .cpp, implementazione in un altro .cpp, e la dichiarazione deve essere disponibile dove la chiamiamo.

Variante C: perché non si può "semplicemente includere il .cpp vicino" con #include

A volte lo studente pensa: «dato che #include inserisce testo, includiamo #include "math.cpp"». Questo obbligherà davvero il compilatore a vedere il codice… ma si inizierà a rompere il modello della compilazione separata. Perché è una cattiva idea — ne discutiamo sotto, ma per ora fissiamo: .cpp è un file che si suppone venga compilato separatamente, non un pezzo da "incollare" altrove.

Esempio pratico: spostiamo la stampa delle attività in un file separato

Facciamolo su qualcosa di più reale di add. Abbiamo una mini-app "Todo": vogliamo stampare le attività e aggiungerne di nuove. Prima tutto era in main.cpp, ora proviamo a spostare la funzione di stampa in todo_print.cpp.

Passo 1: in main.cpp chiamiamo la funzione di stampa

main.cpp:

#include <iostream>
#include <string>
#include <vector>

void print_tasks(const std::vector<std::string>& tasks); // dichiarazione

int main() {
    std::vector<std::string> tasks = {"Buy milk", "Learn C++"};

    print_tasks(tasks);
    std::cout << "Done!\n"; // Done!
}

Nota lo stile: prima la dichiarazione, poi l'uso. Questo permette a main.cpp di compilare in modo indipendente.

Passo 2: mettiamo l'implementazione in todo_print.cpp

todo_print.cpp:

#include <iostream>
#include <string>
#include <vector>

void print_tasks(const std::vector<std::string>& tasks) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        std::cout << (i + 1) << ") " << tasks[i] << '\n';
    }
}

Questo codice è semplice, ma mostra già un punto importante: ogni .cpp deve includere ciò di cui ha bisogno. todo_print.cpp usa std::vector, std::string, std::cout — quindi deve includere <vector>, <string>, <iostream>. Non si può sperare che «in main.cpp sia già incluso».

Se dimenticate di #include <vector>, il compilatore, mentre elabora todo_print.cpp, non è tenuto a «ricordare» che altrove nel progetto c'era <vector>. Compila il file separatamente — e questo ci riporta alla logica delle translation unit.

Cosa abbiamo fissato nella testa

Per ora adottiamo uno stile "provvisorio" (dichiarazione della funzione direttamente in main.cpp). Va bene per il primo approccio. Nella prossima lezione lo struttureremo correttamente: sposteremo la dichiarazione nell'header .hpp per evitare duplicazioni.

Ma ciò che oggi è fondamentale comprendere è la meccanica principale: main.cpp e todo_print.cpp sono compilati come mondi separati, e l'unico modo per scambiare informazioni tra loro è fare in modo che le dichiarazioni necessarie siano incluse in entrambe le translation unit.

4. Perché non bisogna fare #include "something.cpp" e come correggere "nome non trovato"

Perché includere "something.cpp" con #include è una cattiva idea

Molto spesso lo studente trova una "soluzione rapida": se main.cpp non vede la funzione in math.cpp, includiamo math.cpp dentro main.cpp con #include. Formalmente può funzionare: il testo di math.cpp finirà nella translation unit di main.cpp, il compilatore vedrà la definizione della funzione e tutto «sembra» andare.

Il problema è che trasformate involontariamente il progetto in «un unico enorme file costruito da pezzi». Questo rompe diverse cose.

Prima di tutto, perdete la compilazione separata: le modifiche nel «.cpp incluso» faranno ricompilare tutto ciò che lo include. In secondo luogo, aumenta il rischio di duplicazioni: se due diversi .cpp includono lo stesso «.cpp incollato», si possono verificare conflitti già durante il link dell'eseguibile (oggi non entriamo nei dettagli, ma fidatevi: è problematico).

Il pensiero architetturale corretto è: .cpp vengono compilati separatamente, e le "dichiarazioni comuni" vivono negli header inclusi. Non entriamo ora nella struttura dettagliata degli header (è la lezione successiva), ma la regola «non includere .cpp» è già utile da ricordare, come «non infilare la forchetta nella presa».

Come pensare quando il compilatore segnala "nome non trovato"

Quando iniziate a dividere il codice in file, gli errori di tipo "non dichiarato" diventano più frequenti. È normale: state cambiando il modello del progetto e il cervello non è ancora allenato.

Se il compilatore riporta qualcosa come:

  • was not declared in this scope
  • use of undeclared identifier
  • "impossibile trovare l'identificatore …"

quasi sempre significa una di due cose.

La prima situazione — avete davvero dimenticato di dichiarare il nome prima dell'uso nello stesso file. Classico: avete scritto main, chiamato foo(), ma la dichiarazione di foo è più in basso — il compilatore non la vede.

La seconda situazione — più attinente a oggi: il nome esiste, ma non è finito nella translation unit di questo .cpp. Per esempio avete dichiarato la funzione in un altro .cpp e pensate che sia sufficiente. Ma il compilatore, elaborando l'attuale .cpp, non legge il file vicino. Quindi non avete la dichiarazione nella translation unit corrente (o nell'header che avete incluso).

Una micro-tabella utile per l'auto-controllo (senza esagerare, solo come guida):

Dove si trova dichiarazione/definizione? Il compilatore la vede quando compila main.cpp? Perché
Nello stesso main.cpp più sopra nel testo Sì È la stessa translation unit
In un header .hpp incluso in main.cpp Sì #include ha inserito il testo nella translation unit
In un altro .cpp No Un altro .cpp è un'altra translation unit

Un altro trucco "da professionista": quando vedete l'errore, chiedetevi sempre: "In quale file si trova il compilatore ora?" L'errore quasi sempre indica file e riga. Non è un dettaglio: è un suggerimento: "in questa translation unit il nome necessario non c'è".

5. Errori tipici

Errore №1: aspettarsi che "se la funzione è nel progetto, la vedranno ovunque".
È un'intuizione presa dal caso a file unico: lì tutto è visibile ovunque, se il codice è posizionato appropriatamente. Nel modello multi-file questo lascia di essere vero. Ogni .cpp viene compilato separatamente e il file vicino non è un "proseguimento" del corrente. Si risolve con l'abitudine: dove usi un nome, lì deve esserci la sua dichiarazione (di solito tramite l'header).

Errore №2: scrivere la dichiarazione della funzione "a mano" in più posti e rischiare che siano diverse.
I principianti spesso copiano la signature: in un file int add(int, int);, in un altro diventa long add(int, int); o compare un const. Il compilatore può iniziare a lamentarsi in modi strani e voi penserete "ma sono quasi identiche". La buona pratica: la dichiarazione deve essere una sola (in un header) e gli altri file la includono.

Errore №3: includere .cpp con #include perché "è più veloce".
È quel brutto trucco che sembra funzionare finché il progetto è piccolo. Poi complica tutto: allunga i tempi di build, provoca conflitti, rende il progetto illeggibile e trasforma il debug in archeologia. Disciplina semplice: includere header, compilare i .cpp separatamente.

Errore №4: dimenticare che ogni .cpp deve avere i propri #include per i tipi che usa.
Spesso succede così: avete spostato una funzione in utils.cpp, e usa std::string, e improvvisamente il compilatore dice che std::string non esiste. Strano: "ma in main.cpp c'era <string>!". Però utils.cpp è una translation unit separata. Se un tipo è usato in quel file, l'header standard necessario deve essere incluso proprio in quel file.

Errore №5: andare nel panico e "curare" gli errori aggiungendo #include <bits/stdc++.h> o decine di header inutili.
A volte si vorrebbe semplicemente "spargere include" per far tacere gli errori. È come mettere nastro adesivo su un tubo che perde: funziona per un attimo, ma il problema resta. È molto più utile capire quale nome manca, in quale .cpp viene usato e quale dichiarazione deve entrare nella translation unit. Così si aggiunge esattamente quello che serve — e si impara a gestire il codice invece di supplicarlo di funzionare.

1
Compito
C++ SELF, livello 26, lezione 0
Bloccato
Cassa e somma
Cassa e somma
1
Compito
C++ SELF, livello 26, lezione 0
Bloccato
Saluto al banco
Saluto al banco
1
Compito
C++ SELF, livello 26, lezione 0
Bloccato
Report dei sensori
Report dei sensori
1
Compito
C++ SELF, livello 26, lezione 0
Bloccato
Lista di incarichi
Lista di incarichi
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION