CodeGym /Kurse /C++ SELF /Declaration vs definition: Ursache von Linker-Fehlern

Declaration vs definition: Ursache von Linker-Fehlern

C++ SELF
Level 29, Lektion 0
Verfügbar

1. Deklaration und Definition

Wenn ein Programm größer wird als main.cpp mit 20 Zeilen, verlangt das Gehirn nach „Ordnung schaffen“. Und C++ sagt dann: „Natürlich! Lass uns Code in Dateien aufteilen“. Klingt nach einem Gewinn… bis Sie eines Tages eine Build‑Meldung sehen wie: „ich habe alles kompiliert, aber ich kann nicht linken“. In diesem Moment möchte man meist das Universum, den Compiler oder den Sitznachbarn beschuldigen.

Das Problem läuft fast immer auf eine einfache Sache hinaus: dem Compiler reicht manchmal die Information, dass etwas existiert (das ist die Deklaration), aber um das endgültige Programm zu bauen, muss irgendwo ein tatsächlicher „Körper“ vorhanden sein (das ist die Definition). Das Modell „gesagt vs gemacht“ tritt in C++ ständig auf — und erklärt den Großteil der „magischen“ Fehler in Mehrdatei‑Projekten.

Deklaration: „ich verspreche, dass es existiert“

Stellen Sie sich vor, Sie bitten einen Freund um Hilfe: „Peter, komm bitte und bring eine Pizza mit“. Das ist nützliche Planungsinformation. Aber die Pizza erscheint dadurch nicht auf dem Tisch. Eine Deklaration in C++ ist ungefähr dieses Versprechen: „Hier ist ein Name, hier ein Typ, so benutzt man es“. Dank der Deklaration kann der Compiler prüfen, dass Sie eine Funktion korrekt aufrufen, mit den richtigen Parametern und dem erwarteten Rückgabewert.

Die vertrauteste Form der Deklaration ist der Funktionsprototyp (die Signatur ohne Körper):

// math.hpp
#pragma once

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

In einer anderen Datei kann der Compiler bereits den Aufrufcode erzeugen:

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

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

Beachten Sie den psychologischen Trick: es scheint, dass da, weil main.cpp add „sieht“, alles in Ordnung sein müsste. Aber in Wirklichkeit hat das #include "math.hpp" nur das Versprechen gebracht, nicht die „Pizza“.

Etwas weniger offensichtlich, aber sehr wichtig: eine Deklaration ist oft „Information für den Compiler“, nicht „Anweisung zur Erzeugung eines Objekts“.

Ein weiteres Beispiel für Deklaration ist die Typdeklaration mittels struct, enum class usw. Hier gibt es aber einen wichtigen Unterschied: die Typdefinition ist nicht dasselbe wie die Definition einer Funktion/Variable (dazu kommen wir später noch einmal, denn genau hier verheddern sich Anfänger am häufigsten).

Definition: „hier ist es, hier ist Körper/Speicher“

Wenn die Deklaration „ich verspreche, die Pizza kommt“, dann ist die Definition der Moment, in dem die Pizza da ist, die Schachtel offen ist und alle plötzlich freundlicher werden.

In C++ erzeugt die Definition die Entität:

  • für eine Funktion — sie liefert den Körper { ... }
  • für eine Variable — sie reserviert den Speicher (erzeugt ein Objekt)
  • für einen Typ — sie beschreibt die Struktur (Felder, Varianten usw.)

Beginnen wir mit dem Einfachsten: Funktionsdefinition.

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

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

Jetzt „existiert die Pizza“: der Linker kann die Implementierung von add finden.

Und eine zentrale Praxis, die einen Großteil der Industrie zusammenhält: in .hpp liegen meist Deklarationen, und in .cpp Definitionen.

Schauen wir uns nun Variablen an. Innerhalb einer Funktion ist es normalerweise klar: wenn Sie eine Variable schreiben, definieren Sie sie.

int main() {
    int x = 10;   // das ist die Definition der Variable x
    x += 5;
}

Auf Datei‑/globaler Ebene wird es interessanter: dort kann eine „einfach aussehende Zeile“ plötzlich beim Bauen eines großen Projekts Probleme verursachen. Wir werden heute nicht in alle Randfälle und „spezielle Schlüsselwörter“ eintauchen (die in späteren Vorlesungen kommen), aber die Grundidee steht fest: Definition erzeugt die Entität, Deklaration beschreibt sie nur.

3. Wo was lebt: Typen, Funktionen, Variablen und der Linker

An diesem Punkt ist es hilfreich, die Entitäten auseinanderzudröseln, weil bei Anfängern oft ein gefährlicher Gedanke auftaucht: „ein struct ist ja auch eine Definition… also muss der Linker es auch finden?“. Spoiler: nein, und das ist eine gute Nachricht.

Wichtig ist, sich klarzumachen, dass ein mehrdateiiges Projekt als eine Menge separater „Stücke“ (Translation Units) kompiliert wird. Das Konzept der translation unit ist grundlegend für das Build‑Modell: im Grunde ist es das Ergebnis dessen, was aus einer .cpp nach allen #includes und dem Präprozessor geworden ist. Im Standard ist dieses Konzept an die Übersetzungsphasen gebunden und bildet das Fundament des Build‑Modells.

Teilen wir die Welt jetzt in zwei Kategorien auf.

Funktionen und globale Variablen erzeugen Symbole, die beim Linken „zusammengenäht“ werden müssen. Der Compiler kann „rufe add auf“ generieren, aber um herauszufinden, wo genau add liegt, braucht es den Linker für das finale Linking.

Typen (struct, enum class, using) sind hauptsächlich Compiler‑Information: sie werden gebraucht, um Korrektheit zu prüfen und Daten im Speicher anzuordnen, aber ein „Objekt mit dem Namen struct Task“ existiert normalerweise nicht als separate Einheit, die der Linker finden muss.

Zur Veranschaulichung hier eine vereinfachte Tabelle. Sie ist vereinfacht (die Realität ist komplexer), aber für den Anfang sehr nützlich:

Entität Deklaration (declaration) Definition (definition) Wer „leidet“, wenn die Definition fehlt
Funktion
int f(int);
int f(int x){...}
Linker (häufig), manchmal Compiler
Variable „Typ/Name bekannt geben“ „Objekt/Speicher erstellen“ Linker (bei externer Nutzung)
Typ (struct)
struct Task;
struct Task { ... };
Compiler (wenn Sie Felder/Größe verwenden)

Und hier die wichtige Erkenntnis, weshalb wir das überhaupt besprechen: der Compiler prüft, dass Sie Namen richtig verwenden, und der Linker prüft, dass die Namen eine tatsächliche Implementierung/Objekt haben. Daher ist die Situation „Kompilierung OK, Build fehlgeschlagen“ möglich.

4. Schnittstelle und Implementierung in TaskBook trennen

Damit das Thema nicht nur abstrakte Philosophie bleibt, setzen wir das an einem Übungsprojekt fort. Nehmen wir eine einfache Konsolenanwendung TaskBook: sie speichert eine Liste von Aufgaben und kann sie auf dem Bildschirm ausgeben. Wir fügen keine neue Logik hinzu — heute geht es nicht um Features, sondern um die richtige Aufteilung des Codes in Dateien.

Stellen Sie sich folgende minimale Struktur vor (wie Sie sie schon zuvor verwendet haben):

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

task.hpp: Modell und Funktionsdeklarationen

Im Header halten wir das Modell und die Funktionsdeklarationen. Hier geht es nicht um „schön“, sondern darum, dass andere Dateien die Schnittstelle nutzen können, ohne Implementationseinzelheiten zu kennen.

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

namespace taskbook {

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

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

} // namespace taskbook

Hier ist struct Task { ... }; eine Typdefinition — das ist aber normal, sie im Header zu halten: andere Dateien müssen wissen, welche Felder Task hat, um damit arbeiten zu können.

Und Task make_task(std::string title); ist eine Funktionserklärung. Wir versprechen, dass diese Funktion existiert, und sagen, wie man sie benutzt.

task.cpp: Wir liefern die Funktion

Jetzt setzen wir das Versprechen um.

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

namespace taskbook {

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

} // namespace taskbook

Beachten Sie: Name und Namespace müssen übereinstimmen. Wenn im Header taskbook::make_task steht, und Sie in der .cpp versehentlich ohne namespace taskbook implementieren, definieren Sie formal eine andere Funktion (und werden dann traurig sein).

printer.hpp: Deklaration der Ausgabe

Erstellen wir ein Modul für die Ausgabe. Im Header deklarieren wir Funktionen, aber ohne Implementierung.

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

namespace taskbook {

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

} // namespace taskbook

printer.cpp: Implementierung der Ausgabe

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

namespace taskbook {

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

} // namespace taskbook

main.cpp: Verwendet nur Deklarationen

Und hier der Moment der Wahrheit: main.cpp verwendet Funktionen, sieht aber nur deren Deklarationen aus den .hpp-Dateien.

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

int main() {
    std::vector<taskbook::Task> tasks;
    tasks.push_back(taskbook::make_task("Über Deklaration/Definition lesen"));
    taskbook::print_tasks(tasks); // [ ] Über Deklaration/Definition lesen
}

main.cpp muss nicht wissen, wie make_task die Aufgabe erstellt oder wie print_tasks die Liste ausgibt. Deklarationen reichen. Aber das Gesamtprojekt muss irgendwo in den .cpp-Dateien die Definitionen dieser Funktionen haben.

5. Mini‑Diagnose: Wie erkennt man fehlende Definitionen

Es ist leicht in die Falle zu tappen: „Wenn der Compiler nicht meckert, ist alles gut“. Tatsächlich kann der Compiler „zu optimistisch“ sein: er macht seine Arbeit basierend auf dem, was Sie ihm per Deklaration versprochen haben.

In großen Projekten gibt es eine eigene Phase, die das Ergebnis „zusammenflickt“: sie basiert darauf, dass jede .cpp eine separate Translation Unit war und dann alle Ergebnisse vereint werden müssen. Die Trennung in Translation Units ist ein fundamentales Element des Build‑Modells.

Betrachten wir ein klassisches Fehler‑Szenario im Kontext unseres Themas: Aufruf vorhanden, Deklaration vorhanden, aber Definition fehlt.

Deklariert, aber die Definition vergessen

Angenommen, Sie haben in printer.hpp geschrieben:

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

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

Und die Datei printer.cpp haben Sie vergessen zu erstellen (oder erstellt, aber nicht dem Build hinzugefügt). Dann kann main.cpp durchaus kompiliert werden: dem Compiler reicht die Deklaration.

Aber beim finalen Linken tritt ein Fehler wie „Definition der Funktion taskbook::print_tasks(...) nicht gefunden“ auf. Die Fehlermeldung bedeutet immer: „Du hast versprochen, dass die Funktion existiert, ich habe sogar den Aufruf eingefügt, aber wo ist die Implementierung?“

Definition vorhanden, aber nicht die richtige

Eine weitere häufige Geschichte: Deklaration und Definition sehen ähnlich aus, sind es aber nicht. Manchmal wegen eines Parameters, manchmal wegen des namespace, manchmal einfach durch einen Tippfehler im Namen.

So deklariert:

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

Und so definiert:

// api.cpp
#include "api.hpp"
namespace taskbook {
int count_done(int total) {   // andere Signatur -> andere Funktion
    return total;
}
}

Ergebnis: Sie haben das Versprechen für eine Funktion und in Wahrheit eine andere implementiert. Der Compiler ist nicht dafür verantwortlich, zu erraten, was Sie meinten, und der Linker wird auch nicht raten, welche Funktion er verbinden soll.

Warum das „Linker‑Problem“ heißt

Alltäglich gesagt macht der Linker die Aufgabe, für verwendete Entitäten die Definitionen zu finden und alles in eine ausführbare Datei zu packen. Die Regeln „wann zwei Namen dieselbe Entität sind“ und „wann die Nutzung eine Definition verlangt“ sind ein großes Thema mit eigenen Abschnitten im Standard (z. B. ODR und odr‑use).

Für heute genügt ein praktisches Kriterium: wenn die Syntax korrekt ist, das Projekt aber trotzdem nicht linkt, fehlt sehr wahrscheinlich irgendwo die Definition von etwas, das Sie verwenden, oder die Definition weicht ab und ist deshalb nicht die erwartete.

6. Typische Fehler

Fehler Nr. 1: Header einbinden mit #include verwechseln mit Implementation einbinden.
Wenn Sie #include "printer.hpp" schreiben, binden Sie den Text des Headers ein — also Deklarationen. Die .cpp mit der Implementation wird dadurch nicht automatisch eingebunden. Folglich kann main.cpp korrekt aussehen, aber das Build scheitern, weil die Funktionendefinitionen in einer anderen .cpp liegen, die nicht im Projekt ist oder nicht kompiliert wurde.

Fehler Nr. 2: Funktion deklarieren und vergessen zu definieren (oder definieren, aber im falschen namespace).
Ein fieser Fall: Sie schreiben int f(); in einem Header, benutzen f() an verschiedenen Stellen und wollen später die .cpp ergänzen… und vergessen es. Oder Sie definieren f() ohne den korrekten namespace, sodass eine andere Funktion entsteht. Der Compiler überprüft Aufrufe anhand der Deklaration, aber das finale Build braucht die echte Definition von genau ns::f().

Fehler Nr. 3: Signatur stimmt zwischen Deklaration und Definition nicht überein.
Wenn im Header int sum(int, int); deklariert ist, Sie aber in der .cpp versehentlich int sum(int, int, int) schreiben, dann sind das zwei verschiedene Symbole (praktisch zwei verschiedene Funktionen). Menschlich wirken sie „ähnlich“, aber für Compiler und Linker sind es unterschiedliche Entitäten. Ergebnis — die versprochene Funktion wurde nicht gefunden.

Fehler Nr. 4: Denken, „auch Typen müssen gelinkt werden“.
Anfänger verschieben manchmal ein struct in eine .cpp und lassen im Header nur eine Art Vorankündigung stehen, und wundern sich dann, warum man in anderen Dateien keine Variable dieses Typs erstellen kann. Typen sind Compiler‑Information: um Felder, Größe, Default‑Konstruktoren usw. zu nutzen, muss der Compiler die Typdefinition sehen (deshalb gehören Typdefinitionen normalerweise in Header). Der Linker spielt hier kaum eine Rolle.

Fehler Nr. 5: Denken, „wenn die IDE die Funktion sieht, existiert sie auch“.
Eine IDE kann die Deklaration hervorheben, Autovervollständigung vorschlagen, „Gehe zur Deklaration“ funktioniert — das alles garantiert nicht, dass eine Definition existiert. IDEs helfen beim Schreiben des Codes, heben aber nicht die physikalischen Regeln des Build‑Systems auf: wenn eine Definition nicht kompiliert wurde oder nicht in das finale Artefakt eingebunden ist, wird das Programm nicht gebaut.

1
Aufgabe
C++ SELF, Level 29, Lektion 0
Gesperrt
Kalkulatormodul
Kalkulatormodul
1
Aufgabe
C++ SELF, Level 29, Lektion 0
Gesperrt
Begrüßungsdienst
Begrüßungsdienst
1
Aufgabe
C++ SELF, Level 29, Lektion 0
Gesperrt
Spielerprofil
Spielerprofil
1
Aufgabe
C++ SELF, Level 29, Lektion 0
Gesperrt
Notizenliste
Notizenliste
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION