Zum Inhalt springen
Topos Uranos

Zusammenfassung

Bisher passte jedes Programm des Kurses in eine einzige Datei. Ein Programm von tausend Zeilen lässt sich nicht mehr in einem Zug lesen, und seine Funktionen verdienen es, anderen Programmen zu dienen. Diese Lektion lehrt, es aufzuteilen: ein Header, der deklariert, was ein Modul anbietet, eine Implementierungsdatei, die es definiert, und eine main.cpp, die es benutzt. Sie stellt die Include-Guards vor; die One-Definition-Rule, die die beiden klassischen Linkerfehler und den modernen Sinn von inline erklärt; die getrennte Kompilierung, die nur neu kompiliert, was sich geändert hat; und sie schließt mit einer eigenen Bibliothek, die aus Funktionen früherer Lektionen besteht.

Lernziele

  1. Ein Programm in einen Header mit Deklarationen, eine Implementierungsdatei und eine main.cpp aufteilen und es kompilieren.
  2. Include-Guards schreiben und erklären, welches Problem sie lösen; #pragma once als eine Erweiterung erkennen, die alle Compiler akzeptieren, die der Standard aber nicht enthält.
  3. Die One-Definition-Rule erklären und ihre Verletzungen anhand der Meldung des Linkers diagnostizieren („multiple definition“, „undefined reference“).
  4. Mit -c getrennt kompilieren und die Objekte linken und dabei erklären, was neu kompiliert wird, wenn sich jede einzelne Datei ändert.

Schnittstelle und Implementierung

Die Modularität

Eine Funktion mit Vertrag, wie die der Lektion Mit Funktionen entwerfen, trennt bereits, was sie verspricht, niedergelegt in ihrer Deklaration und ihrem Vertrag, davon, wie sie es einhält, niedergelegt in ihrem Rumpf; wer sie aufruft, braucht nur das Erste. Wenn dem so ist, hindert nichts daran, beide Teile in verschiedenen Dateien abzulegen. Ein Modul ist eine Gruppe von Funktionen (und der Typen, die sie benutzen) mit einem gemeinsamen Zweck; seine Schnittstelle ist das, was es dem anbietet, der es benutzt, und seine Implementierung die Art, wie es das tut. In C++ wird die Schnittstelle in einen Header (Headerdatei, Endung .hpp, oder .h, von C geerbt) geschrieben und die Implementierung in eine .cpp-Datei gleichen Namens.

Daraus folgen drei Vorteile. Man versteht in Teilen: Um das Modul zu benutzen, genügt es, seinen Header zu lesen. Man kompiliert in Teilen: Eine Änderung in der Implementierung zwingt nicht dazu, die neu zu kompilieren, die nur die Schnittstelle benutzen. Und man testet in Teilen, gegen jeden Vertrag, wie es die Lektion Testen lehren wird. Der Kurs LaTeX für das wissenschaftliche Schreiben teilt auf dieselbe Weise ein langes Dokument auf, in seiner Lektion „Lange Dokumente“.

Drei Dateien

Das Modul für ebene Geometrie in diesem Beispiel bietet drei Funktionen an; zwei davon stammen aus Lektion 5.3. Der Header deklariert sie nur, jede mit ihrem Vertrag. Die drei Zeilen, die mit # beginnen, werden im Folgenden erklärt; vorerst genügt es zu wissen, dass jeder Header sie enthält.

geometry.hpp
// geometry.hpp: Schnittstelle des Moduls für ebene Geometrie.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP​// Fläche des Kreises mit Radius radius.// Vorbedingung: radius >= 0.// Nachbedingung: gibt die Fläche des Kreises mit Radius radius zurück.double circleArea(double radius);​// Ob drei Längen die Seiten eines Dreiecks sind.// Vorbedingung: keine.// Nachbedingung: gibt true genau dann zurück, wenn es ein Dreieck mit den Seiten a, b und c gibt.bool isTriangle(double a, double b, double c);​// Fläche des Dreiecks mit den Seiten a, b und c.// Vorbedingung: isTriangle(a, b, c).// Nachbedingung: gibt die Fläche des Dreiecks mit den Seiten a, b und c zurück (Formel des Heron).double triangleArea(double a, double b, double c);​#endif

Die Implementierungsdatei bindet ihren eigenen Header ein und definiert die Funktionen. Die Header der Standardbibliothek, die sie braucht (<cmath> für die Wurzel, <numbers> für π\pi), werden hier eingebunden und nicht im Header, weil der, der das Modul benutzt, sie nicht braucht.

geometry.cpp
// geometry.cpp: Implementierung des Moduls für ebene Geometrie.#include "geometry.hpp"​#include <cmath>#include <numbers>​double circleArea(double radius){    return std::numbers::pi * radius * radius;}​bool isTriangle(double a, double b, double c){    return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}​double triangleArea(double a, double b, double c){    const double s{(a + b + c) / 2};    return std::sqrt(s * (s - a) * (s - b) * (s - c));}

Schließlich bindet main.cpp den Header ein und benutzt das Modul, ohne eine einzige Zeile seiner Implementierung zu sehen:

C++-Programm
#include <print>​#include "geometry.hpp"​int main(){    std::println("Kreis mit Radius 2: {:.4f}", circleArea(2.0));    std::println("Gibt es ein Dreieck mit den Seiten 3, 4, 5? {}", isTriangle(3.0, 4.0, 5.0));    std::println("Seine Fläche: {}", triangleArea(3.0, 4.0, 5.0));    std::println("Und mit den Seiten 1, 2, 3? {}", isTriangle(1.0, 2.0, 3.0));}
Ausgabe
Kreis mit Radius 2: 12.5664Gibt es ein Dreieck mit den Seiten 3, 4, 5? trueSeine Fläche: 6Und mit den Seiten 1, 2, 3? false

Die Anführungszeichen von #include "geometry.hpp" suchen die Datei zuallererst im Ordner der Datei, die sie einbindet, und die spitzen Klammern von #include <print> in denen des Systems: Die Anführungszeichen sind für die eigenen Dateien. Die Datei geometry.cpp bindet ihren eigenen Header als Erstes ein, damit ein Fehler, falls dem Header eine Einbindung fehlte, die er selbst braucht, beim Kompilieren des Moduls erschiene und nicht in der Datei eines anderen. Und das Dreieck mit den Seiten 1, 2 und 3 existiert nicht (3=1+23 = 1 + 2), sodass die Vorbedingung von triangleArea dazu zwingt, vorher danach zu fragen.

Zum Kompilieren erhält der Befehl die beiden .cpp-Dateien; der Header wird nicht genannt, weil er über #include in jede von ihnen gelangt. Die Kursoptionen werden in einer Variablen des Terminals, FLAGS, gespeichert, um sie nicht in jedem Befehl zu wiederholen; $FLAGS ruft sie ab. So schreibt man es unter Linux und macOS; in PowerShell werden Variablen anders geschrieben, und man schreibt die Optionen besser vollständig aus oder kompiliert aus dem Editor heraus (siehe „Getrennte Kompilierung“).

Terminal
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./mainKreis mit Radius 2: 12.5664Gibt es ein Dreieck mit den Seiten 3, 4, 5? trueSeine Fläche: 6Und mit den Seiten 1, 2, 3? false
BeispielDer Weg dreier Dateien

Die Abbildung greift die der Lektion Programme, Compiler und C++ für dieses Projekt auf.

Beweis

  1. Der Programmierer schreibt drei Dateien: einen Header und zwei .cpp-Dateien. Nur die .cpp-Dateien werden dem Compiler übergeben.

  2. Der Präprozessor kopiert geometry.hpp in jede Datei, die ihn einbindet. Es gibt also zwei Kopien der Deklarationen, eine in jeder Übersetzungseinheit, und das ist korrekt: Eine Funktion kann beliebig oft deklariert werden.

  3. Jede Einheit wird getrennt in ihre Objektdatei kompiliert. Beim Kompilieren von main.cpp schaut der Compiler nicht in geometry.cpp: Die Deklaration genügt ihm, um die Typen jedes Aufrufs zu prüfen.

  4. Der Linker fügt die beiden Objekte und die Standardbibliothek zusammen und löst jeden Aufruf von main.o an circleArea mit der Definition auf, die in geometry.o steht.

Ein häufiger Fehler ist es, die Implementierungsdatei einzubinden, #include "geometry.cpp". Wird sie zudem im Befehl genannt, sind ihre Funktionen zweimal definiert, und das Linken scheitert, nach der Regel, die unter „Die One-Definition-Rule“ erklärt wird; wird sie nicht genannt, ist main.cpp wieder ein einziger Text, der bei jeder Änderung ganz neu kompiliert wird. .cpp-Dateien werden kompiliert; Header werden eingebunden.