C++ Core Guidelines: Quelldateien

Modernes C++  –  5 Kommentare

Die Organisation von Quelldateien ist ein Topik, das selten in C++ adressiert wird. Mit C++20 werden wir Module erhalten. Bis es aber soweit ist, solltest du zwischen der Implementierung und dem Interface deiner Quelldateien streng unterscheiden.

Die C++ Core Guidelines bringen ihre Absicht zu Quelldateien unmissverständlich auf den Punkt: "Distinguish between declarations (used as interfaces) and definitions (used as implementations). Use header files to represent interfaces and to emphasize logical structure." Konsequenterweise erhalten sie mehr als 10 Regeln zu Quelldateien. Die ersten beschäftigen sich mit Interface-Dateien (*.h-Dateien) und Implementierungsdateien (*.cpp-Dateien) und die verbleibenden adressieren Namensräume.

Los geht es mit den Regeln zu Interface- und Implementierungsdateien. Hier sind die ersten sieben Regeln.

Die erste Regel SF.1: Use a .cpp suffix for code files and .h for interface files if your project doesn’t already follow another convention erwähnt explizit die Konsistenz deines Projekts. Wenn du ein C++-Projekt besitzt, sollten die Header-Dateien *.h und die Implementierungsdateien *.cpp genannt werden. Diese Konvention sollte aber einer bereits bestehenden Policy untergeordnet werden.

In meiner Zeit als Softwareentwickler habe ich viele Konventionen für Header- und Implementierungs-Dateien kennengelernt. Hier sind ein paar, die mir einfallen.

Header-Dateien:

  • *.h
  • *.hpp
  • *.hxx

Implementierungsdateien:

  • *.cpp
  • *.c
  • *.cc
  • *.cxx

Dies sind sicherlich noch nicht alle Konventionen.

Falls eine Header-Datei so eingebunden wird, dass die Definition eines Objekt oder die Definition einer Nicht-inline-Funktion mehr als einmal vorkommt, beschwert sich dein Linker. Dies ist der Grund für die zweite Regel: SF.2: A .h file may not contain object definitions or non-inline function definitions. Um etwas genauer zu sein, es gilt in C++ die One-Definition-Rule:

ODR

ODR steht für One Definition Rule und sagt im Falle einer Funktion aus:

  • Eine Funktion kann nicht mehr als eine Definition pro Übersetzungseinheit besitzen.
  • Eine Funktion kann nicht mehr als eine Definition pro Programm besitzen.
  • Eine Inline-Funktion mit externer Bindung kann in mehr als einer Übersetzungseinheit definiert werden. Für jede Definition muss aber gelten, dass sie identisch ist.

In modernen Compilern besitzt das Schlüsselwort inline nicht mehr die Funktionalität, Funktionen zu inlinen. Moderne Compiler ignorieren es nahezu. Der Anwendungsfall für inline besteht darin, Funktionen auszuzeichnen, die gemäß ODR richtig sind. Der Name inline ist mittlerweile sehr irreführend.

Lass mich testen, ob sich mein Linker beschwert, wenn ich versuche ein Programm zu linken und dabei die One Defintion Rule zu verletzen. Das folgende Codebeispiel besitzt eine Header-Datei header.h und zwei Implementierungsdateien. Letztere inkludieren die Header-Datei und brechen damit die One Definition Rule, da die Funktion func doppelt inkludiert wird.

// header.h

void func(){}
// impl.cpp

#include "header.h"
// main.cpp

#include "header.h"

int main(){}

Erwartungsgemäß beschwert sich der Linker über die mehrfache Inkludierung der Funktion func:

Die nächsten zwei Regeln sind für die Lesbarkeit und Wartbarkeit des Codes wichtig: SF.3: Use .h files for all declarations used in multiple source files und SF.4: Include .h files before other declarations in a file.

Die Regel 5 ist interessanter: SF.5: A .cpp file must include the .h file(s) that defines its interface. Die Frage ist: Was passiert, wenn ich die Header-Datei *.h nicht in die Implementierungsdatei *.cpp inkludiere und es eine Diskrepanz zwischen der Header-Datei und der Implementierungsdatei gibt?

Angenommen, ich hatte einen schlechten Tag und definiere die Funktion func so, dass sie ein int-Typ annimmt zurückgibt:

// impl.cpp 

// #include "impl.h"

int func(int){
return 5;
}

Mein Fehler war es, dass ich eine Funktion func in der Header-Datei impl.h deklariert habe, die einen int-Typ annimmt, aber ein std::string zurückgibt.

// impl.h 

#include <string>

std::string func(int);

Ich inkludiere nun die Header-Datei in der main-Funktion, da ich diese Funktion verwenden will.

// main.cpp 

#include "impl.h"

int main(){
auto res = func(5);
}

Das Problem ist, dass der Fehler erst zu dem Zeitpunkt auftritt, wenn das Hauptprogramm main.cpp übersetzt wird. Dies kann der Linkzeitpunkt sein. Das ist deutlich zu spät.

Wenn ich die Header-Datei impl.h in die Datei impl.cpp inkludiere, erhalte ich einen Fehler bereits zur Compilezeit.

Die nächsten Regeln beschäftigen sich mit Namensräumen: SF.6: Use using namespace directives for transition, for foundation libraries (such as std), or within a local scope (only). Ehrlich gesagt, diese Regel ist mir zu weich formuliert. Ich bin gegen die Direktive using namespace wie in dem folgenden Beispiel.

#include <cmath> 

using namespace std;

int g(int x) {
int sqrt = 7;
// ...
return sqrt(x); // error
}

Das Programm lässt sich nicht übersetzen, den es gibt eine Namenskollision. Dies ist aber nicht mein Hauptargument gegen die using-Direktive. Mein Hauptargument ist, dass sie den Ursprung eines Namens verschleiert und damit die Lesbarkeit des Programms bricht:

#include <iostream>
#include <chrono>

using namespace std;
using namespace std::chrono;
using namespace std::literals::chrono_literals;

int main(){

std::cout << std::endl;

auto schoolHour= 45min;

auto shortBreak= 300s;
auto longBreak= 0.25h;

auto schoolWay= 15min;
auto homework= 2h;

auto schoolDayInSeconds= 2 * schoolWay + 6 * schoolHour + 4 * shortBreak + longBreak + homework;

cout << "School day in seconds: " << schoolDayInSeconds.count() << endl;

duration<double, ratio<3600>> schoolDayInHours = schoolDayInSeconds;
duration<double, ratio<60>> schoolDayInMinutes = schoolDayInSeconds;
duration<double, ratio<1, 1000>> schoolDayInMilliseconds = schoolDayInSeconds;

cout << "School day in hours: " << schoolDayInHours.count() << endl;
cout << "School day in minutes: " << schoolDayInMinutes.count() << endl;
cout << "School day in milliseconds: " << schoolDayInMilliseconds.count() << endl;

cout << endl;

}

Weißt du auswendig, welches Literal, welche Funktion oder welches Objekt in welchem Namensraum definiert wurde? Falls nicht, wird das Nachschlagen eines Namens deutlich schwieriger. Dies trifft dann vor allem zu, wenn du erst beginnst, C++ zu programmieren.

Bevor ich diesen Artikel beende, möchte ich noch auf eine wichtige Regel eingehen: SF.7: Don’t write using namespace at global scope in a header file. Dies ist die Begründung, warum du using-Direktiven nicht in Header-Dateien verwenden solltest.

  • Wenn du diese Header-Datei verwendest, wirst du die using-Direktive nicht mehr los.
  • Die Gefahr einer Namenskollision steigt deutlich an.
  • Eine Modifikation des Namensraums kann dazu führen, dass der Code sich nicht mehr übersetzen lässt, da ein neuer Name eingeführt wurde.

Wie geht's weiter?

Zuerst einmal gibt es noch ein paar Regeln in den C++ Core Guidelines zur Organisation for Sourecode-Dateien. Zusätzlich bekommen wir Module mit C++20. Gerne möchte ich vorstellen, welche Auswirkung dieses wichtige Feature auf C++ besitzt.

Das neue pdf-Päckchen ist fertig: