CodeGym /Corsi /C++ SELF /Declaration vs definition: fonte degli errori di link

Declaration vs definition: fonte degli errori di link

C++ SELF
Livello 29, Lezione 0
Disponibile

1. Dichiarazione e definizione

Quando un programma cresce oltre i main.cpp di 20 righe, viene naturale «mettere ordine». E qui C++ risponde: «Certo! Suddividiamo il codice in file». Suona come una vittoria… fino a quando un giorno non vedete il messaggio di build del tipo: «ho compilato tutto, ma non riesco a linkare». A quel punto è facile accusare l'universo, il compilatore o il vicino di banco.

Il problema quasi sempre è una cosa semplice: al compilatore a volte basta sapere che qualcosa esiste (questa è la dichiarazione), ma per creare il programma finale è necessario che da qualche parte ci sia il corpo reale (questa è la definizione). Il modello «detto vs fatto» in C++ appare continuamente — ed è proprio questo a spiegare la maggior parte degli errori "magici" nei progetti multi-file.

Dichiarazione: «prometto che esiste»

Immaginate di chiedere aiuto a un amico: «Pietro, verrai e porterai la pizza». È un'informazione utile per organizzare la serata. Ma la pizza non compare automaticamente sulla tavola. La dichiarazione in C++ è una promessa di questo tipo: «Ecco il nome, ecco il tipo, ecco come usarlo». Grazie alla dichiarazione il compilatore può verificare che usiate la funzione correttamente, con i parametri giusti e aspettandovi il tipo di ritorno corretto.

La forma di dichiarazione più nota è il prototipo di funzione (la firma senza corpo):

// math.hpp
#pragma once

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

In un altro file il compilatore può già generare il codice di chiamata:

// main.cpp
#include <iostream>
#include "math.hpp"

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

Nota il trucco psicologico: sembra che siccome main.cpp "vede" add, tutto dovrebbe andare bene. Ma in realtà #include "math.hpp" ha portato solo la promessa, non la «pizza».

Meno ovvio ma importante: la dichiarazione è spesso «informazione per il compilatore», non un'istruzione per creare un oggetto.

Un altro esempio di dichiarazione è la dichiarazione di tipo tramite struct, enum class ecc. Qui c'è una differenza importante: la definizione di un tipo non è la stessa cosa della definizione di una funzione/variabile (torneremo su questo più avanti, perché è proprio qui che i principianti si confondono di più).

Definizione: «ecco il corpo/la memoria»

Se la dichiarazione è la promessa della pizza, la definizione è quando la pizza è arrivata, la confezione è aperta e improvvisamente tutti sembrano più felici.

In C++ la definizione è ciò che crea l'entità:

  • per una funzione — fornisce il corpo { ... }
  • per una variabile — alloca la memoria (crea l'oggetto di storage)
  • per un tipo — definisce la sua struttura (campi, varianti ecc.)

Cominciamo dall'esempio più semplice: la definizione di una funzione.

// math.cpp
#include "math.hpp"

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

Ora la «pizza esiste»: il linker potrà trovare l'implementazione di add.

E una pratica chiave che regge gran parte dell'industria: nei file .hpp di solito ci sono le dichiarazioni, mentre nei file .cpp ci sono le definizioni.

Ora guardiamo le variabili. Dentro una funzione è tutto trasparente: dichiari una variabile e la stai definendo.

int main() {
    int x = 10;   // questa è la definizione della variabile x
    x += 5;
}

Ma a livello di file (globale) la situazione è più interessante: una singola riga può improvvisamente diventare la causa di problemi in una grande codebase. Oggi non entreremo nei casi sottili e nelle «parole chiave correttive» (che vedremo nelle lezioni successive), ma fissiamo l'idea base: la definizione crea l'entità, la dichiarazione la descrive soltanto.

3. Dove vive cosa: tipi, funzioni, variabili e linker

A questo punto è utile mettere le entità in ordine, perché nella testa di un principiante spesso c'è un'idea pericolosa: «ma struct è una definizione… quindi il linker dovrebbe cercarla?». Spoiler: no, ed è una buona notizia.

Ricordate che un progetto multi-file viene compilato come un insieme di pezzi separati (unità di traduzione). Il concetto di translation unit è fondamentale per il modello di build: è il risultato di ciò che resta da un file .cpp dopo tutti i #include e il preprocessore. Nello standard questo concetto è legato alle fasi di traduzione ed è la base del modello di build.

Dividiamo ora il mondo in due categorie.

Le funzioni e le variabili globali diventano dei simboli che vanno "cuciti" durante il linking. Il compilatore può generare la chiamata a add, ma per sapere dove si trova concretamente add serve il linker nella fase finale.

I tipi (struct, enum class, using) sono perlopiù matematica del compilatore: servono per verificare la correttezza del codice e per disporre i dati in memoria, ma una "porzione di codice macchina chiamata struct Task" normalmente non esiste come entità separata che il linker deve trovare.

Per fissare l'idea, ecco una tabella semplice. È semplificata (la realtà è sempre più complessa), ma per iniziare è molto utile:

Entità Dichiarazione (declaration) Definizione (definition) Chi «soffre» se manca la definizione
Funzione
int f(int);
int f(int x){...}
linker (spesso), a volte compilatore
Variabile «dichiarare tipo/nome» «creare oggetto/memoria» linker (per uso esterno)
Tipo (struct)
struct Task;
struct Task { ... };
compilatore (se usi campi/dimensioni)

E la cosa importante per cui discutiamo tutto questo: il compilatore verifica che usiate i nomi in modo corretto, mentre il linker verifica che i nomi abbiano una implementazione/oggetto reale. Perciò è possibile la situazione "compilazione riuscita, linking fallito".

4. Separare interfaccia e implementazione in TaskBook

Per non restare nella filosofia astratta, continuiamo con il nostro progetto didattico. Consideriamo una semplice applicazione console TaskBook: tiene una lista di task e sa stamparli a schermo. Non aggiungiamo logiche nuove — l'obiettivo di oggi non sono le feature, ma la corretta organizzazione del codice nei file.

Immaginiamo una struttura minima (come già fatto in giorni passati):

TaskBook/
  include/
    task.hpp
    printer.hpp
  src/
    task.cpp
    printer.cpp
    main.cpp

task.hpp: dichiariamo il modello e le funzioni

Nel header mettiamo il modello e le dichiarazioni di funzione. Qui l'obiettivo non è "bello", ma far sì che altri file possano usare il tipo senza conoscere i dettagli dell'implementazione.

// task.hpp
#pragma once
#include <string>

namespace taskbook {

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

Task make_task(std::string title); // dichiarazione

} // namespace taskbook

Qui lo struct Task { ... }; è la definizione del tipo, ma è normale averla nell'header: gli altri file devono sapere quali campi ha Task per poterlo usare.

Mentre Task make_task(std::string title); è una dichiarazione di funzione. Promettiamo che la funzione esisterà e spieghiamo come usarla.

task.cpp: forniamo la definizione della funzione

Ora implementiamo la promessa.

// task.cpp
#include "task.hpp"

namespace taskbook {

Task make_task(std::string title) {     // definizione
    Task t;
    t.title = std::move(title);
    t.done = false;
    return t;
}

} // namespace taskbook

Attenzione: il nome e lo namespace devono corrispondere. Se nell'header è taskbook::make_task e nel .cpp per errore omettete lo namespace taskbook, avrete definito formalmente una funzione diversa (e poi vi sentirete tristi).

printer.hpp: dichiariamo la stampa

Creiamo il modulo di stampa. Nell'header dichiariamo le funzioni, senza implementarle.

// printer.hpp
#pragma once
#include <vector>
#include "task.hpp"

namespace taskbook {

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

} // namespace taskbook

printer.cpp: definiamo la stampa

// printer.cpp
#include <iostream>
#include "printer.hpp"

namespace taskbook {

void print_tasks(const std::vector<Task>& tasks) {     // definizione
    for (const Task& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.title << "\n";
    }
}

} // namespace taskbook

main.cpp: usiamo solo le dichiarazioni

Ecco il momento della verità: main.cpp usa funzioni vedendo solo le loro dichiarazioni dagli .hpp.

// main.cpp
#include <vector>
#include "task.hpp"
#include "printer.hpp"

int main() {
    std::vector<taskbook::Task> tasks;
    tasks.push_back(taskbook::make_task("Leggere su declaration/definition"));
    taskbook::print_tasks(tasks); // [ ] Leggere su declaration/definition
}

main.cpp non deve conoscere come make_task crea la task né come print_tasks stampa la lista. Gli bastano le dichiarazioni. Ma il build finale deve avere le definizioni di queste funzioni in qualche file .cpp.

5. Mini-diagnosi: come capire cosa manca

È facile cadere nella trappola: «Se il compilatore non segnala errori — allora va tutto bene». In realtà il compilatore può essere «troppo ottimista»: fa la sua parte basandosi su ciò che gli avete promesso tramite le dichiarazioni.

In un progetto con molti file c'è una fase separata che "cucisce" il risultato: si basa sul fatto che ogni .cpp è stata una unità di traduzione separata, e poi tutti i risultati vanno combinati. La suddivisione in unità di traduzione è un elemento fondamentale del modello di build.

Consideriamo lo scenario classico di errore relativo al nostro argomento: la chiamata c'è, le dichiarazioni ci sono, ma le definizioni mancano.

Dichiarato, ma dimenticato definire

Supponiamo che abbiate scritto in printer.hpp:

#pragma once
#include <vector>
#include "task.hpp"

namespace taskbook {
void print_tasks(const std::vector<Task>& tasks); // dichiarazione
}

E che printer.cpp non sia stato creato (o sia stato creato ma non aggiunto al progetto di build). Allora main.cpp può anche compilare: per il compilatore va bene, vede la dichiarazione.

Ma nella fase di linking dell'applicazione finale apparirà un errore del tipo "implementazione della funzione taskbook::print_tasks(...) non trovata". Il senso dell'errore è sempre lo stesso: «hai promesso che la funzione esiste, io ci ho creduto e ho inserito la chiamata, ma dove è l'implementazione?».

La definizione c'è, ma non è quella giusta

Un'altra storia comune: dichiarazione e definizione sono "simili", ma diverse. A volte differiscono per un parametro, a volte per lo namespace, a volte per una semplice svista nel nome.

Dichiarate così:

// api.hpp
#pragma once
namespace taskbook {
int count_done(); // dichiarazione
}

Ma definite così:

// api.cpp
#include "api.hpp"
namespace taskbook {
int count_done(int total) {   // firma diversa -> funzione diversa
    return total;
}
}

Il risultato è che avrete la "promessa" di una funzione e l'esistenza reale di un'altra. Il compilatore non deve indovinare cosa intendevate, e il linker non deve scegliere quale delle due associare.

Perché si chiama problema di linking

In termini pratici il linker trova le definizioni per le entità usate e unisce tutto in un eseguibile. Le regole su "quando due nomi rappresentano la stessa entità" e "quando l'uso richiede la definizione" sono vaste, e fanno parte dello standard (per esempio le sezioni su ODR e odr-use).

Per oggi ci basta un criterio pratico: se la sintassi è corretta ma il progetto non si builda, è molto probabile che manchi la definizione di qualcosa che state usando, oppure la definizione non corrisponde a quella promessa.

6. Errori tipici

Errore n.1: confondere "includere l'header" con "includere l'implementazione".
Quando fate #include "printer.hpp", state includendo il testo dell'header, cioè le dichiarazioni. Ma il file .cpp con l'implementazione non viene incluso automaticamente. Di conseguenza main.cpp può apparire corretto, ma il linking fallirà perché le definizioni delle funzioni stanno in un altro .cpp che non è nel progetto o non è compilato.

Errore n.2: dichiarare la funzione e dimenticare di definirla (o definirla in un altro namespace).
Caso subdolo: avete messo int f(); in un header, iniziate a usarla in più posti e poi "prima o poi" la implementate… e vi dimenticate. Oppure definite f() senza lo namespace giusto e ottenete un'altra funzione. Il compilatore controllerà la chiamata sulla base della dichiarazione, ma il linking finale richiede la definizione di ns::f().

Errore n.3: mismatch della firma fra dichiarazione e definizione.
Se nell'header c'è int sum(int, int);, e nel .cpp scrivete per errore int sum(int, int, int), allora sono due simboli diversi (due funzioni diverse). Sulle prime sembrano simili, ma per compilatore e linker sono entità distinte. Il risultato è "implementazione promessa non trovata".

Errore n.4: aspettarsi che anche i tipi vadano linkati.
I principianti a volte cercano di mettere lo struct in un .cpp, lasciando nell'header solo una sorta di "dichiarazione", e poi si meravigliano di non poter creare variabili di quel tipo in altri file. I tipi sono informazione per il compilatore: per usare campi, dimensioni, costruttori di default ecc. il compilatore deve vedere la definizione del tipo (di solito nell'header). Il linker qui non è il protagonista.

Errore n.5: pensare che "se l'IDE mostra la funzione, allora esiste".
L'IDE può evidenziare la dichiarazione, l'auto-complete può suggerire il nome, "go to declaration" funziona — eppure non è una garanzia che esista una definizione. L'IDE aiuta a scrivere codice, ma non annulla le regole del linking: se la definizione non è compilata o non è inclusa nella build finale, l'eseguibile non sarà creato.

1
Compito
C++ SELF, livello 29, lezione 0
Bloccato
Modulo calcolatrice
Modulo calcolatrice
1
Compito
C++ SELF, livello 29, lezione 0
Bloccato
Servizio di saluto
Servizio di saluto
1
Compito
C++ SELF, livello 29, lezione 0
Bloccato
Profilo del giocatore
Profilo del giocatore
1
Compito
C++ SELF, livello 29, lezione 0
Bloccato
Elenco delle note
Elenco delle note
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION