CodeGym /Corsi /C++ SELF /Header vs sorgente: dichiarazioni e definizioni

Header vs sorgente: dichiarazioni e definizioni

C++ SELF
Livello 26, Lezione 1
Disponibile

1. Perché separare il codice in .hpp e .cpp

Quando il programma cresce, anche il più disciplinato main.cpp comincia a somigliare al frigorifero di uno studente prima degli esami: sembra che ci sia tutto il necessario, ma trovare qualcosa di specifico diventa un'avventura. La multiplificazione dei file non è una "capriccio dei grandi progetti", ma un modo pratico per mantenere la leggibilità: separare ciò che si può usare da come è fatto. Ed è proprio qui che compare la coppia: file header (.hpp) e file sorgente (.cpp).

Immaginate che il vostro modulo sia un bar. L'header è il menù: dal menù il cliente capisce cosa può ordinare e in che forma verrà servito. Il sorgente è la cucina: lì il cuoco fa la magia, ma il cliente non ha bisogno di sapere se avete tre pentole, un bollitore e panico.

In termini C++ questo significa: in .hpp normalmente si mettono le dichiarazioni (declarations), mentre in .cpp ci sono le definizioni (definitions). E se ora state pensando "sembra simile, sicuramente confonderò" — sì, vi confonderete. Tutti si confondono. Ma oggi lo curiamo.

Dichiarazione e definizione: qual è la differenza a livello di codice

A prima vista sembra che "dichiarazione" sia semplicemente "qualcosa che si scrive sulla funzione", e "definizione" sia "quando si scrive il corpo". È quasi vero, ma conta la formulazione precisa: la dichiarazione comunica al compilatore la forma del nome, mentre la definizione comunica l'implementazione.

Al compilatore, per verificare una chiamata di funzione, serve conoscere la sua firma: nome, tipo di ritorno, tipi dei parametri. Per eseguire realmente il programma serve il corpo (implementazione). Perciò, nella fase di compilazione spesso basta la dichiarazione, mentre l'implementazione può trovarsi in un altro .cpp e "attaccarsi" più tardi (abbiamo già discusso che i file vengono compilati separatamente).

Vediamo la differenza minima:

// Dichiarazione (declaration)
int add(int a, int b);

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

Il marcatore visivo principale per un principiante: la dichiarazione quasi sempre finisce con ;, mentre la definizione di una funzione ha il corpo in { ... }.

Ora un po' più interessante: struct di solito viene sia dichiarato che definito dove descrivete i suoi campi:

// Questa è la definizione del tipo (e allo stesso tempo la dichiarazione del nome Task)
struct Task {
    std::string title;
    bool done;
};

Quindi non tutte le entità si dividono strettamente "dichiarazione qui, definizione lì" allo stesso modo. Le funzioni si separano perfettamente, mentre i tipi di solito stanno "interamente" nell'header, poiché gli altri file devono sapere come è fatto il tipo (campi, dimensioni, ecc.).

Cosa conta come dichiarazione: esempi

Finché non fissiamo questo con esempi, la mente cercherà di semplificare tutto a "nel .hpp scriviamo qualsiasi cosa, e nel .cpp anche". Poi arriva il momento in cui il compilatore dice: "non so cos'è Task", e voi rispondete: "come non lo sai, l'ho scritto ieri". Quindi spezzettiamo i casi in esempi chiari.

Dichiarazione di funzione

Iniziamo dal più basilare. La dichiarazione di una funzione è la sua firma senza corpo:

int count_done_tasks(const std::vector<int>& flags);

Qui il compilatore apprende che esiste una funzione con quel nome e quei parametri. Ma "come conta" — non lo sa.

Definizione di funzione

La definizione è la dichiarazione più il corpo:

int count_done_tasks(const std::vector<int>& flags) {
    int count = 0;
    for (int x : flags) {
        if (x != 0) count += 1;
    }
    return count;
}

Dichiarazione di tipo e definizione di tipo

Con struct è più semplice: non appena scrivete il corpo della struct, avete definito il tipo.

#include <string>

struct User {
    std::string name;
};

Se un altro .cpp vuole creare User u;, deve vedere la definizione della struct User { ... }, altrimenti non capirà quanta memoria serve per u e quali campi ha l'oggetto.

2. Cosa mettere in .hpp e in .cpp

Header .hpp: cosa dovrebbe contenere e perché è un "contratto"

Quando le persone iniziano a creare .hpp, spesso lo vedono come "un altro file dove spostare del codice". Ma è meglio pensare diversamente: l'header è il contratto del modulo. Descrive come il modulo è utile al mondo esterno: quali tipi fornisce, quali funzioni si possono chiamare e quali dati vanno passati.

È importante ricordare che l'header viene solitamente incluso (#include) in più punti. Perciò l'header ha un requisito speciale: deve essere chiaro e autosufficiente. In altre parole, se nell'header appare std::string, l'header deve includere <string>, non sperare che "qualcuno l'abbia incluso prima". Altrimenti otterrete un progetto che compila solo con il "giusto ordine di include" — è come il codice che funziona solo il martedì.

Tra l'altro, una buona abitudine è scrivere negli header esplicitamente std::string, std::vector e non "spalmare" using namespace std;. Anche nelle bozze dello standard si vedono interventi sulla disciplina del prefisso std:: nelle interfacce, quindi l'idea di scriverlo esplicitamente non è solo una pignoleria dell'insegnante, ma buona igiene ingegneristica.

Fissiamo cosa si mette di solito in .hpp con un esempio pratico — cominciamo a costruire la mini-applicazione TaskPad (todo list da console). Prima poteva stare in un file solo; ora la separiamo in moduli.

Sorgente .cpp: perché serve se si può scrivere tutto nell'header

Domanda logica del principiante: "Se posso mettere dichiarazione e corpo nell'header, perché servirebbe il .cpp?" Ottima domanda, mostra sano scetticismo verso entità superflue. In pratica il .cpp serve per nascondere dettagli di implementazione, ridurre il "rumore" nell'interfaccia e non costringere ogni file del progetto a "digerire" tutte le implementazioni.

Nel .cpp si mettono normalmente le definizioni delle funzioni. È comodo anche tenere lì funzioni di supporto che non devono essere accessibili "dall'esterno", e includere header pesanti che servono solo per l'implementazione. Per esempio, per stampare si può includere <iostream> nel .cpp, evitando di portarlo in .hpp se l'interfaccia non espone stream.

Un'altra ragione pratica è la velocità di compilazione nei grandi progetti: quando si cambia l'implementazione in un .cpp, spesso viene ricompilato solo quel .cpp. Se invece cambiate codice nell'header, potete innescare la ricompilazione di molti file che includono quell'header. Non entriamo ora nei sistemi di build, ma l'idea intuitiva è semplice: l'header è la superficie pubblica, conviene cambiarla di rado.

Promemoria: .hpp vs .cpp e "dichiarazione" vs "definizione"

Per non lasciarlo come un insieme di immagini filosofiche, fissiamo due piccole tabelle. Non sostituiscono la pratica, ma aiutano la mente a controllarsi quando spostate codice.

Dichiarazione e definizione

Entità Dichiarazione Definizione
Funzione firma + ; firma + { ... }
Variabile (globale)
extern int x;
int x = 10;
struct "il nome del tipo esiste" (a volte separato)
struct T { ... };

Non approfondiamo qui le variabili globali — di solito nei progetti didattici non sono la pratica migliore, e parleremo più avanti del linking e di "quante definizioni sono consentite". La tabella serve solo per intuizione.

Header e sorgente

File Ruolo Cosa si trova di solito dentro
.hpp interfaccia (contratto) dichiarazioni di funzioni, definizioni di struct/enum, gli #include necessari per queste dichiarazioni
.cpp implementazione corpi delle funzioni, dettagli e strumenti di supporto, ulteriori #include per l'implementazione

3. Pratica: spostiamo TaskPad nel modulo tasks

Spostiamo la logica di TaskPad in .hpp/.cpp

Faremo ora un piccolo ma molto istruttivo refactoring. Immaginate che prima avessimo un unico main.cpp con il modello Task e le funzioni di aggiunta/stampa. Vogliamo ottenere il modulo tasks che si possa includere in diversi posti.

Passo 1: creiamo tasks.hpp — il "menù" del modulo

Partiamo dall'interfaccia. Qui descriviamo cos'è una task e quali operazioni forniamo.

// tasks.hpp
#pragma once  // ne parleremo più avanti; per ora usiamolo per abitudine

#include <string>
#include <vector>

struct Task {
    std::string title;
    bool done;
};

void add_task(std::vector<Task>& tasks, const std::string& title);
void print_tasks(const std::vector<Task>& tasks);

Un paio di osservazioni importanti su questo codice.

Primo, nell'header abbiamo incluso <string> e <vector>, perché sono usati nelle firme e nei campi di Task. L'header deve essere autosufficiente: se qualcuno include tasks.hpp, deve ottenere tutte le definizioni di tipo necessarie. Le sottigliezze di #pragma once e delle alternative le vedremo dopo; per ora accettiamolo come utile "gesto protettivo".

Secondo, usiamo const std::string& e const std::vector<Task>& nei parametri, perché copiare stringhe e vector senza necessità è generalmente sconsigliabile.

Passo 2: creiamo tasks.cpp — la "cucina" del modulo

Ora implementiamo le funzioni. Qui si può includere ciò che serve per funzionare, per esempio <iostream>.

// tasks.cpp
#include "tasks.hpp"

#include <iostream>

void add_task(std::vector<Task>& tasks, const std::string& title) {
    Task t{title, false};
    tasks.push_back(t);
}

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

Nota la buona disciplina: .cpp include il suo header "tasks.hpp" — e lo fa all'inizio. Altrimenti è facile trovarsi nella situazione in cui in .cpp tutto compila solo perché per caso qualcun altro ha incluso <vector> prima, mentre l'header da solo è rotto. Quando tasks.cpp include tasks.hpp per primo, eventuali problemi dell'header emergono subito.

Passo 3: main.cpp diventa più snello

Ora main.cpp non deve sapere come vengono stampate le task. Usa solo l'interfaccia.

// main.cpp
#include "tasks.hpp"

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

int main() {
    std::vector<Task> tasks;

    add_task(tasks, "Buy milk");
    add_task(tasks, "Learn C++ headers");

    print_tasks(tasks);
}

Se eseguite, otterrete qualcosa del genere:

[ ] Buy milk
[ ] Learn C++ headers

E qui sentite per la prima volta l'idea "contratto/implementazione": main.cpp sa che esistono add_task e print_tasks, ma non deve sapere come sono implementate internamente.

Sottigliezza importante: l'header non è un luogo per "comodità casuali"

A questo punto il principiante tende a voler "fare bello": scrivere nell'header using namespace std;, così da poter scrivere ovunque solo string e vector. Nel .cpp a volte si fa (con cautela), ma nell'header è quasi sempre una cattiva idea, perché influenzate ogni file che include quell'header. È come regalare a qualcuno una tazza che cambia il sapore del caffè in tutta la casa.

Perciò negli header manteniamo la disciplina: scriviamo esplicitamente std::string, std::vector. Questo migliora la leggibilità e riduce il rischio di collisioni di nomi. E non è solo "stile dell'insegnante": la coerenza del prefisso std:: ricorre spesso anche nella documentazione dello standard.

4. Errori tipici

Errore №1: in .hpp si è scritta la dichiarazione, ma in .cpp si è definito "quasi lo stesso".
Situazione molto comune: nell'header void print_tasks(const std::vector<Task>&), mentre nel sorgente improvvisamente void print_tasks(std::vector<Task>&) (senza const). A occhio la differenza è piccola, ma per il compilatore sono funzioni diverse. Risultato: o errore "definizione non trovata", o comportamento strano quando si è creato involontariamente un overload. Si risolve con disciplina: copiare la firma dall'header, e ancor meglio — includere sempre il proprio .hpp per primo nel .cpp, così le discrepanze saltano subito fuori.

Errore №2: l'header non è autosufficiente e "richiede un ordine degli include fortunato".
Se in tasks.hpp appare std::string ma manca #include <string>, tutto può "magicamente funzionare" in un file dove <string> è stato incluso prima, e improvvisamente cadere in un altro. Questo è particolarmente subdolo: il bug nasce non dalla logica ma dall'ordine degli #include. La regola: se un tipo è usato nell'header — l'header include il relativo header standard.

Errore №3: mettere le definizioni delle funzioni nell'header solo perché "è più veloce".
In un piccolo progetto didattico può sembrare più semplice scrivere tutto nell'header, e magari funziona. Ma includendo quell'header in più .cpp rischiate problemi in fase di linking (non entriamo nella meccanica, ma vedrete errori di linker). Regola pratica: nell'header mettiamo dichiarazioni, i corpi delle funzioni normali vanno nel .cpp.

Errore №4: l'header include dipendenze inutili che servono solo all'implementazione.
Per esempio, se stampate tramite std::cout e includete <iostream> in tasks.hpp, costringete ogni file che include tasks.hpp a portarsi dietro anche <iostream>, anche quando non serve stampare nulla. Nei progetti piccoli è solo rumore; nei grandi aumenta notevolmente i tempi di build. Regola semplice: se qualcosa serve solo per l'implementazione — quasi sempre va incluso nel .cpp.

Errore №5: provare a includere .cpp con #include per "incollare" i file.
A volte il principiante vede che main.cpp "non vede" una funzione in tasks.cpp, e fa #include "tasks.cpp". Questo può portare a comportamenti strani e rompe il modello della compilazione separata. I file .cpp sono pensati per la compilazione separata, mentre includiamo di regola gli header .hpp dove sono le dichiarazioni.

1
Compito
C++ SELF, livello 26, lezione 1
Bloccato
La cucina del saluto
La cucina del saluto
1
Compito
C++ SELF, livello 26, lezione 1
Bloccato
Media senza sorprese
Media senza sorprese
1
Compito
C++ SELF, livello 26, lezione 1
Bloccato
Contatore dei positivi
Contatore dei positivi
1
Compito
C++ SELF, livello 26, lezione 1
Bloccato
Cornice e vetrina
Cornice e vetrina
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION