1. Il principio Include What You Use
Quando un progetto cresce, la compilazione comincia a occupare tempo rilevante. All’inizio sembra innocuo: «beh, 3 secondi». Poi hai sbloccato lo schermo — e stai bevendo il tè mentre viene compilata una «semplice modifica in un file». Il principio Include What You Use (includi ciò che usi) aiuta a tenere sotto controllo le dipendenze e a non trasformare la build in una serie TV di 12 stagioni.
L’idea è molto semplice e quasi banale: ogni header deve includere esplicitamente tutto ciò che serve per le sue dichiarazioni, e al tempo stesso non portare cose superflue. Questo rende il progetto più resistente al refactoring e spesso accelera la compilazione, perché diminuisce la quantità di codice che il compilatore è costretto a «masticare» ad ogni modifica.
Immaginiamo il nostro progetto didattico — un piccolo tracker di task da console chiamato MiniTracker. Non scriviamo più tutto in main.cpp, ma abbiamo suddiviso il codice in file. In questa situazione ogni «include» superfluo in un header comincia a moltiplicarsi come i meme del lunedì mattina: un header è incluso da 10 file .cpp, e quel file inutile entra nella compilazione 10 volte.
Header autosufficiente
C’è un ottimo criterio pratico di qualità per un header: la autosufficienza. Significa che se prendi un qualsiasi X.hpp e lo includi in un .cpp vuoto, questo deve compilare (a condizione che il progetto sia configurato correttamente). È come la regola «ogni ingrediente è etichettato»: apri il barattolo e capisci cosa c’è dentro, non bisogna indovinare dall’odore se «sembra zucchero, ma perché frizza?».
L’autosufficienza è direttamente collegata a Include What You Use. Se il tuo header usa std::string, deve includere <string>. Se dichiara qualcosa con std::vector, deve includere <vector>. Non «può», non «sarebbe bene», ma deve, perché altrimenti l’header inizia a dipendere dall’ordine di include in altri file, ed è fragilità pura.
Facciamo un semplice header per il modello Task nel nostro MiniTracker.
// include/app/task.hpp
#pragma once
#include <string> // Task contiene std::string, quindi <string> serve qui
namespace app {
struct Task {
int id{};
std::string title;
bool done{};
};
} // namespace app
Qui è tutto onesto: Task contiene std::string, quindi <string> è incluso. Non importa chi e in quale ordine includerà task.hpp — non «crollarà» perché da qualche parte hanno dimenticato <string>.
Dipendenze transitive
Il nemico più insidioso di Include What You Use è la dipendenza transitiva. Succede quando il tuo codice compila non perché hai tutto incluso correttamente, ma perché «hai fortuna»: il tipo necessario è arrivato tramite un include di qualcun altro. Oggi hai fortuna, domani qualcuno rimuove un include inutile altrove — e tutto si rompe. È come vivere pensando: «tanto il vicino ha il cacciavite, se serve…» — e poi il vicino parte.
Ecco un cattivo esempio di header: usa std::string, ma non include <string>.
// include/app/task_bad.hpp
#pragma once
namespace app {
struct TaskBad {
int id{};
std::string title; // Errore: std::string potrebbe non essere visibile
};
} // namespace app
Perché questo può «funzionare per caso»? Perché qualcuno prima di questo header ha incluso <string>. Per esempio:
// src/main.cpp
#include <string> // per caso!
#include "app/task_bad.hpp" // ora compila (ma è un'illusione)
int main() {
app::TaskBad t{1, "Buy milk"};
(void)t;
}
Ora immagina che in main.cpp qualcuno decida che <string> è superfluo (e lo è, se non viene usato direttamente) e lo rimuova:
// src/main.cpp
#include "app/task_bad.hpp" // improvvisamente non compila più
int main() {
app::TaskBad t{1, "Buy milk"};
(void)t;
}
Ecco che compare l'errore di compilazione «std::string non dichiarato». La cosa più frustrante è che l'autore della modifica in main.cpp non ha colpa: ha fatto una buona azione (togliere un include inutile), e il problema è emerso altrove a causa della fragilità dell'header.
Da un punto di vista disciplinare, il pensiero corretto è: se un tipo o una funzione è menzionata in un header — l'header deve portare con sé l'header standard necessario. Questo è Include What You Use al suo livello più utile.
Cosa mettere in .hpp e cosa lasciare in .cpp
Un errore frequente dei principianti è includere «di tutto un po'» nell'header, giusto per essere sicuri che «funzioni». Nasce così lo stile di mettere <iostream> in ogni .hpp, e poi la compilazione comincia a somigliare al caricamento di un vecchio gioco per PlayStation 1: sembra che stia per partire, ma non ancora.
La regola guida è: .hpp contiene l'interfaccia, e .cpp contiene l'implementazione. Se un include serve solo perché dentro il .cpp si stampa o si calcola qualcosa, non è necessario nell'header.
Consideriamo un esempio: vogliamo stampare un task su uno stream. Creiamo un modulo separato per questo.
// include/app/task_printer.hpp
#pragma once
#include <iosfwd> // sufficiente la dichiarazione di std::ostream (più leggero di <iostream>)
#include "app/task.hpp" // stampiamo Task, quindi serve Task
namespace app {
void print_task(std::ostream& out, const Task& task);
} // namespace app
Nota un dettaglio importante: qui non c'è <iostream>. E va bene così. Nell'header ci serve solo std::ostream come tipo di parametro. Per questo esiste l'header standard <iosfwd> — è «leggero» e non trascina un sacco di dettagli.
L'implementazione invece vive nel .cpp, dove possiamo includere tutto ciò che è pesante e necessario:
// src/task_printer.cpp
#include "app/task_printer.hpp"
#include <iostream> // qui si può: l'implementazione usa l'output formattato
namespace app {
void print_task(std::ostream& out, const Task& task) {
out << "[" << (task.done ? 'x' : ' ') << "] "
<< task.id << ": " << task.title << '\n';
}
} // namespace app
Il senso di questa separazione non è solo estetico (anche il codice ordinato è piacevole). Il punto è che l'header task_printer.hpp verrà incluso in molti posti, mentre task_printer.cpp è compilato come unità di traduzione separata. Meno l'header trascina, meno lavoro fa il compilatore per ciascun .cpp che lo include.
Ora un esempio di tipico «sovraccarico dell'header». Supponiamo che qualcuno abbia scritto così:
// include/app/task_printer_bad.hpp
#pragma once
#include <iostream> // pesante, e non necessario per la dichiarazione
#include "app/task.hpp"
namespace app {
void print_task(std::ostream& out, const Task& task);
}
La funzione è dichiarata esattamente allo stesso modo. Ma ora qualsiasi file che vuole solo conoscere print_task è costretto a «masticare» <iostream>. E <iostream> non è una piccola carta, è un'enciclopedia con segnalibri.
Includi il tuo .hpp per primo
C’è un'abitudine che rivela rapidamente problemi di dipendenze transitive: in ogni .cpp il primo include dovrebbe essere il proprio header. Non è una guerra di religione né estetica — è un modo per «rompere l'illusione che tutto vada bene», se l'header non è realmente autosufficiente.
Perché funziona? Se il tuo .cpp include prima header standard di terzi e poi il tuo .hpp, potresti per caso portare tipi necessari in modo transitivo e non notare la falla nell'header. Se invece il tuo .hpp è incluso per primo — deve reggere da solo.
Ecco uno stile corretto:
// src/task_service.cpp
#include "app/task_service.hpp" // per primo!
#include <algorithm> // serve solo per l'implementazione
namespace app {
int count_done(const std::vector<Task>& tasks) {
return static_cast<int>(std::count_if(tasks.begin(), tasks.end(),
[](const Task& t) { return t.done; }));
}
} // namespace app
Ecco invece uno stile che spesso nasconde problemi:
// src/task_service.cpp
#include <vector> // per caso abbiamo portato qualcosa di utile
#include "app/task_service.hpp" // e l'header potrebbe non essere autosufficiente
// ...
La differenza sembra piccola, ma su un progetto reale diventa una superpotenza: catturi l'errore subito nel «posto giusto», invece di scoprirlo dopo una settimana in seguito a un cambio dell'ordine di #include in un altro file.
Per consolidare, creiamo l'header del servizio task (per ora contiene una funzione). Nota: siccome l'interfaccia usa std::vector, includiamo <vector> direttamente nell'header.
// include/app/task_service.hpp
#pragma once
#include <vector> // l'interfaccia usa std::vector
#include "app/task.hpp" // l'interfaccia usa Task
namespace app {
int count_done(const std::vector<Task>& tasks);
} // namespace app
Qui l'header è autosufficiente e non conta sulla fortuna.
Costo di #include e header leggeri
È importante capire cosa fa un #include. Il preprocessor letteralmente inserisce il testo di un file in un altro. Se un header trascina ancora 20 header, e quelli trascinano altri 50, allora al compilatore arriva un testo enorme. Anche se gli include guards / #pragma once evitano l'inclusione ripetuta dello stesso file, il compilatore comunque deve aprire i file, processare le direttive e a volte analizzare buona parte del codice.
È utile pensare alla build come a una «palla di neve». Modifichi un header — e viene ricompilato tutto ciò che lo include (direttamente o in modo transitivo). Più dipendenze superflue ci sono negli header, più grande diventa la palla.
Disegniamo uno schema di dipendenze (semplificato) per il nostro MiniTracker:
flowchart TD
A[task.hpp] --> B[task_service.hpp]
A --> C[task_printer.hpp]
C --> D[main.cpp]
B --> D
Se per errore task_printer.hpp include <iostream>, allora <iostream> finisce in ogni .cpp che stampa i task. Se ci sono 15 di questi file — paghi la compilazione 15 volte per un header che ti serve davvero solo in un .cpp.
È utile tenere a mente una tabella semplice (non assoluta, ma molto pratica nella maggior parte dei casi):
| Header | Tipica "pesantezza" | Quando includere |
|---|---|---|
, , |
più spesso moderata | se il tipo è menzionato nell'interfaccia |
|
leggera | se nell'interfaccia c'è std::ostream&/std::istream& |
|
spesso pesante | quasi sempre solo nel .cpp dove effettivamente scrivi su std::cout |
|
moderata | di solito in .cpp, se gli algoritmi non servono nell'interfaccia |
Sì, «pesantezza» non è un termine formale dello standard. È piuttosto una sensazione ingegneristica: alcuni header trascinano molte implementazioni e template, e la compilazione ne risente.
Un altro punto importante: Include What You Use non significa «includi il meno possibile». Significa «includi esattamente ciò che serve». A volte questo implica che in un .hpp ci saranno una o due righe di include in più di quanto vorresti. E va bene, perché il costo di una riga in più è minore del costo della fragilità del progetto.
Per esempio, se abbiamo una funzione che restituisce una stringa, allora <string> deve essere incluso direttamente nell'header, anche se «per caso» è già incluso via task.hpp. Non giochiamo a indovina.
// include/app/task_format.hpp
#pragma once
#include <string> // l'interfaccia usa std::string
#include "app/task.hpp" // l'interfaccia usa Task
namespace app {
std::string format_task(const Task& task);
} // namespace app
Se poi Task smette improvvisamente di contenere std::string (ad esempio sostituisci il campo con qualcos'altro), task.hpp potrebbe non includere più <string>. Ed è proprio qui che la dipendenza transitiva ti avrebbe fatto lo sgambetto — ma noi l'abbiamo evitata includendo correttamente.
2. Errori tipici
Errore #1: l'header "funziona solo con l'ordine giusto di include".
Classica dipendenza transitiva: usi std::string, std::vector o std::ostream, ma non includi l'header standard necessario perché «altrove è già incluso». Questa economia è come cercare di risparmiare sui freni della bicicletta: sembra tutto ok finché non devi frenare.
Errore #2: includere header pesanti in .hpp "per ogni evenienza".
Spesso il principiante mette <iostream> in ogni header perché «potrebbe servire». Poi un giorno serve davvero — ma non a te, bensì al compilatore che ora soffre continuamente. Abitudine più sana: nell'header solo ciò che serve alle dichiarazioni; tutto il resto per l'implementazione va nel .cpp.
Errore #3: non separare interfaccia e implementazione e trascinare include di implementazione nell'header.
Se nell'header ci sono definizioni di funzioni (non solo dichiarazioni) che usano un sacco di cose, l'header inevitabilmente si gonfia. Nel corso attuale manteniamo una disciplina semplice: dichiarazioni in .hpp, definizioni in .cpp. Questo aiuta automaticamente a tenere gli include a dieta senza essere estremisti.
Errore #4: non includere il proprio .hpp per primo nel .cpp e non accorgersi del problema per molto tempo.
Quando l'ordine degli include è «casualmente corretto», gli errori non si manifestano. Poi il progetto cresce, qualcuno fa refactoring, e all'improvviso la build si rompe «in un punto incomprensibile». Includere il proprio header per primo è come allacciarsi la cintura: la maggior parte del tempo sembra superfluo, ma al momento del bisogno salva molti nerve.
Errore #5: confondere "minimizzare le dipendenze" con "togliere tutti gli include e sperare nella magia".
Include What You Use non richiede eroismi. Se la tua interfaccia usa std::vector, allora <vector> deve essere nell'header. Se l'interfaccia usa std::string, allora <string> deve essere nell'header. Minimizzare significa rimuovere il superfluo: ciò che serve solo all'implementazione e non fa parte dell'interfaccia.
GO TO FULL VERSION