Funktionsweise von C++: Compilation verstehen

Veröffentlicht: 2022-03-11

Bjarne Stroustrups The C++ Programming Language enthält ein Kapitel mit dem Titel „Eine Tour durch C++: Die Grundlagen“ – Standard C++. Dieses Kapitel in 2.2 erwähnt auf einer halben Seite den Kompilierungs- und Verknüpfungsprozess in C++. Kompilieren und Linken sind zwei sehr grundlegende Prozesse, die während der C++-Softwareentwicklung ständig passieren, aber seltsamerweise werden sie von vielen C++-Entwicklern nicht gut verstanden.

Warum wird der C++-Quellcode in Header- und Quelldateien aufgeteilt? Wie wird jeder Teil vom Compiler gesehen? Wie wirkt sich das auf das Kompilieren und Verlinken aus? Es gibt viele weitere Fragen wie diese, über die Sie vielleicht nachgedacht haben, die Sie jedoch als Konvention akzeptieren.

Egal, ob Sie eine C++-Anwendung entwerfen, neue Funktionen dafür implementieren, versuchen, Fehler zu beheben (insbesondere bestimmte seltsame Fehler) oder versuchen, C- und C++-Code zusammenzuarbeiten, wenn Sie wissen, wie Kompilierung und Verknüpfung funktionieren, sparen Sie viel Zeit und machen diese Aufgaben viel angenehmer. In diesem Artikel erfahren Sie genau das.

Der Artikel erklärt, wie ein C++-Compiler mit einigen der grundlegenden Sprachkonstrukte arbeitet, beantwortet einige häufig gestellte Fragen, die sich auf ihre Prozesse beziehen, und hilft Ihnen, einige damit zusammenhängende Fehler zu umgehen, die Entwickler häufig in der C++-Entwicklung machen.

Hinweis: Dieser Artikel enthält einige Beispielquellcodes, die von https://bitbucket.org/danielmunoz/cpp-article heruntergeladen werden können

Die Beispiele wurden auf einer CentOS-Linux-Maschine kompiliert:

 $ uname -sr Linux 3.10.0-327.36.3.el7.x86_64

Verwendung der g++-Version:

 $ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)

Die bereitgestellten Quelldateien sollten auf andere Betriebssysteme portierbar sein, obwohl die Makefiles, die sie für den automatisierten Erstellungsprozess begleiten, nur auf Unix-ähnliche Systeme portierbar sein sollten.

Die Build-Pipeline: Vorverarbeiten, kompilieren und verknüpfen

Jede C++-Quelldatei muss in eine Objektdatei kompiliert werden. Die aus der Kompilierung mehrerer Quelldateien resultierenden Objektdateien werden dann zu einer ausführbaren Datei, einer gemeinsam genutzten Bibliothek oder einer statischen Bibliothek verknüpft (wobei letztere nur ein Archiv von Objektdateien ist). C++-Quelldateien haben im Allgemeinen die Erweiterungssuffixe .cpp, .cxx oder .cc.

Eine C++-Quelldatei kann mit der #include -Direktive andere Dateien enthalten, die als Header-Dateien bezeichnet werden. Header-Dateien haben Erweiterungen wie .h, .hpp oder .hxx oder haben überhaupt keine Erweiterung wie in der C++-Standardbibliothek und den Header-Dateien anderer Bibliotheken (wie Qt). Die Erweiterung spielt für den C++-Präprozessor keine Rolle, der die Zeile mit der #include -Direktive buchstäblich durch den gesamten Inhalt der eingebundenen Datei ersetzt.

Der erste Schritt, den der Compiler für eine Quelldatei ausführt, besteht darin, den Präprozessor darauf auszuführen. Nur Quelldateien werden an den Compiler übergeben (um ihn vorzuverarbeiten und zu kompilieren). Header-Dateien werden nicht an den Compiler übergeben. Stattdessen werden sie aus Quelldateien eingefügt.

Jede Header-Datei kann während der Vorverarbeitungsphase aller Quelldateien mehrmals geöffnet werden, je nachdem, wie viele Quelldateien sie enthalten oder wie viele andere Header-Dateien, die aus Quelldateien enthalten sind, sie ebenfalls enthalten (es kann viele Umleitungsebenen geben). . Quelldateien hingegen werden nur einmal vom Compiler (und Präprozessor) geöffnet, wenn sie ihm übergeben werden.

Für jede C++-Quelldatei erstellt der Präprozessor eine Übersetzungseinheit, indem er Inhalt darin einfügt, wenn er eine #include-Direktive findet, und gleichzeitig Code aus der Quelldatei und den Headern entfernt, wenn er eine bedingte Kompilierung findet Blöcke, deren Direktive als false ausgewertet wird. Es erledigt auch einige andere Aufgaben wie Makroersetzungen.

Sobald der Präprozessor die Erstellung dieser (manchmal riesigen) Übersetzungseinheit abgeschlossen hat, beginnt der Compiler mit der Kompilierungsphase und erstellt die Objektdatei.

Um diese Übersetzungseinheit (den vorverarbeiteten Quellcode) zu erhalten, kann die Option -E zusammen mit der Option -o an den g++-Compiler übergeben werden, um den gewünschten Namen der vorverarbeiteten Quelldatei anzugeben.

Im Verzeichnis cpp-article/hello-world gibt es eine Beispieldatei „hello-world.cpp“:

 #include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }

Erstellen Sie die vorverarbeitete Datei wie folgt:

 $ g++ -E hello-world.cpp -o hello-world.ii

Und siehe die Anzahl der Zeilen:

 $ wc -l hello-world.ii 17558 hello-world.ii

Es hat 17.588 Zeilen in meiner Maschine. Sie können auch einfach make auf diesem Verzeichnis ausführen und es erledigt diese Schritte für Sie.

Wir können sehen, dass der Compiler eine viel größere Datei kompilieren muss als die einfache Quelldatei, die wir sehen. Das liegt an den enthaltenen Headern. Und in unserem Beispiel haben wir nur einen Header eingefügt. Die Übersetzungseinheit wird immer größer, je mehr wir Überschriften einfügen.

Dieser Vorverarbeitungs- und Kompilierungsprozess ist für die C-Sprache ähnlich. Es folgt den C-Regeln zum Kompilieren, und die Art und Weise, wie es Header-Dateien einfügt und Objektcode erzeugt, ist fast gleich.

So importieren und exportieren Quelldateien Symbole

Sehen wir uns nun die Dateien im Verzeichnis cpp-article/symbols/c-vs-cpp-names an.

Wie Funktionen verarbeitet werden.

Es gibt eine einfache C-Quelldatei (nicht C++) namens sum.c, die zwei Funktionen exportiert, eine zum Addieren von zwei Ganzzahlen und eine zum Addieren von zwei Gleitkommazahlen:

 int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }

Kompilieren Sie es (oder führen Sie make und alle Schritte aus, um die beiden auszuführenden Beispiel-Apps zu erstellen), um die Objektdatei sum.o zu erstellen:

 $ gcc -c sum.c

Sehen Sie sich nun die von dieser Objektdatei exportierten und importierten Symbole an:

 $ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI

Es werden keine Symbole importiert und zwei Symbole exportiert: sumF und sumI . Diese Symbole werden als Teil des .text-Segments (T) exportiert, sie sind also Funktionsnamen, ausführbarer Code.

Wenn andere Quelldateien (sowohl C als auch C++) diese Funktionen aufrufen möchten, müssen sie sie vor dem Aufruf deklarieren.

Die Standardmethode besteht darin, eine Header-Datei zu erstellen, die sie deklariert und sie in die Quelldatei einschließt, die wir sie nennen möchten. Der Header kann einen beliebigen Namen und eine beliebige Erweiterung haben. Ich habe sum.h :

 #ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif

Was sind diese ifdef / endif bedingten Kompilierungsblöcke? Wenn ich diesen Header aus einer C-Quelldatei einfüge, möchte ich, dass er wird:

 int sumI(int a, int b); float sumF(float a, float b);

Aber wenn ich sie aus einer C++-Quelldatei einfüge, möchte ich, dass es wird:

 extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"

Die C-Sprache weiß nichts über die extern "C" -Direktive, aber C++ weiß es, und es muss diese Direktive auf C-Funktionsdeklarationen angewendet werden. Dies liegt daran, dass C++ Funktions- (und Methoden-) Namen verstümmelt, weil es das Überladen von Funktionen/Methoden unterstützt, während C dies nicht tut.

Dies ist in der C++-Quelldatei mit dem Namen print.cpp zu sehen:

 #include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); }

Es gibt zwei gleichnamige Funktionen ( printSum ), die sich nur im Typ ihrer Parameter unterscheiden: int oder float . Das Überladen von Funktionen ist eine C++-Funktion, die in C nicht vorhanden ist. Um diese Funktion zu implementieren und diese Funktionen zu unterscheiden, verstümmelt C++ den Funktionsnamen, wie wir in ihrem exportierten Symbolnamen sehen können (ich wähle nur aus der Ausgabe von nm aus, was relevant ist). :

 $ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout

Diese Funktionen werden (in meinem System) als _Z8printSumff für die Float-Version und _Z8printSumii für die Int-Version exportiert. Jeder Funktionsname in C++ wird entstellt, es sei denn, er wird als extern "C" deklariert. Es gibt zwei Funktionen, die mit C-Verknüpfung in print.cpp : printSumInt und printSumFloat .

Daher können sie nicht überladen werden, oder ihre exportierten Namen wären die gleichen, da sie nicht verstümmelt sind. Ich musste sie voneinander unterscheiden, indem ich ein Int oder ein Float an das Ende ihrer Namen anhängte.

Da sie nicht verstümmelt sind, können sie, wie wir gleich sehen werden, vom C-Code aus aufgerufen werden.

Um die entstellten Namen so zu sehen, wie wir sie im C++-Quellcode sehen würden, können wir die Option -C (demangle) im nm -Befehl verwenden. Auch hier kopiere ich nur denselben relevanten Teil der Ausgabe:

 $ nm -C print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T printSum(float, float) 0000000000000000 T printSum(int, int) U std::cout

Mit dieser Option sehen wir anstelle von _Z8printSumff printSum(float, float) und anstelle von _ZSt4cout sehen wir std::cout, was menschenfreundlichere Namen sind.

Wir sehen auch, dass unser C++-Code C-Code aufruft: print.cpp ruft sumI und sumF , bei denen es sich um C-Funktionen handelt, die in sum.h mit einer C-Verknüpfung deklariert sind. Dies ist in der nm-Ausgabe von print.o oben zu sehen, die über einige undefinierte (U) Symbole informiert: sumF , sumI und std::cout . Diese undefinierten Symbole sollen in einer der Objektdateien (oder Bibliotheken) bereitgestellt werden, die zusammen mit dieser Objektdateiausgabe in der Verknüpfungsphase verknüpft werden.

Bisher haben wir nur Quellcode in Objektcode kompiliert, wir haben noch nicht gelinkt. Wenn wir die Objektdatei, die die Definitionen für diese importierten Symbole enthält, nicht zusammen mit dieser Objektdatei verknüpfen, stoppt der Linker mit einem „Fehlendes Symbol“-Fehler.

Beachten Sie auch, dass, da print.cpp eine C++-Quelldatei ist, die mit einem C++-Compiler (g++) kompiliert wurde, der gesamte darin enthaltene Code als C++-Code kompiliert wird. Funktionen mit C-Verknüpfung wie printSumInt und printSumFloat sind ebenfalls C++-Funktionen, die C++-Features verwenden können. Nur die Namen der Symbole sind mit C kompatibel, aber der Code ist C++, was daran zu erkennen ist, dass beide Funktionen eine überladene Funktion ( printSum ) aufrufen, was nicht passieren könnte, wenn printSumInt oder printSumFloat in C kompiliert würden.

Sehen wir uns jetzt print.hpp an, eine Header-Datei, die sowohl aus C- als auch aus C++-Quelldateien eingefügt werden kann, wodurch printSumInt und printSumFloat sowohl von C als auch von C++ aufgerufen werden können und printSum von C++ aufgerufen werden kann:

 #ifdef __cplusplus void printSum(int a, int b); void printSum(float a, float b); extern "C" { #endif void printSumInt(int a, int b); void printSumFloat(float a, float b); #ifdef __cplusplus } // end extern "C" #endif

Wenn wir es aus einer C-Quelldatei einbinden, wollen wir nur sehen:

 void printSumInt(int a, int b); void printSumFloat(float a, float b);

printSum kann von C-Code nicht gesehen werden, da sein Name verstümmelt ist, also haben wir keine (standardmäßige und portable) Möglichkeit, es für C-Code zu deklarieren. Ja, ich kann sie deklarieren als:

 void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);

Und der Linker wird sich nicht beschweren, da dies der genaue Name ist, den mein derzeit installierter Compiler dafür erfunden hat, aber ich weiß nicht, ob er für Ihren Linker funktioniert (wenn Ihr Compiler einen anderen entstellten Namen generiert) oder sogar für den nächste Version meines Linkers. Ich weiß nicht einmal, ob der Aufruf wie erwartet funktioniert, da es verschiedene Aufrufkonventionen gibt (wie Parameter übergeben und Rückgabewerte zurückgegeben werden), die Compiler-spezifisch sind und für C- und C++-Aufrufe (insbesondere für C++-Funktionen) unterschiedlich sein können die Mitgliedsfunktionen sind und den this-Zeiger als Parameter erhalten).

Ihr Compiler kann möglicherweise eine Aufrufkonvention für reguläre C++-Funktionen verwenden und eine andere, wenn sie als externe „C“-Verknüpfung deklariert sind. Wenn Sie also den Compiler betrügen, indem Sie sagen, dass eine Funktion C-Aufrufkonventionen verwendet, während sie tatsächlich C++ verwendet, kann dies zu unerwarteten Ergebnissen führen, wenn die jeweils verwendeten Konventionen in Ihrer Kompilierungs-Toolchain unterschiedlich sind.

Es gibt Standardmethoden zum Mischen von C- und C++-Code, und eine Standardmethode zum Aufrufen von überladenen C++-Funktionen aus C besteht darin, sie in Funktionen mit C-Verknüpfung zu verpacken, wie wir es durch Verpacken von printSum mit printSumInt und printSumFloat .

Wenn wir print.hpp aus einer C++-Quelldatei einfügen, wird das __cplusplus Präprozessormakro definiert und die Datei wird wie folgt angezeigt:

 void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C"

Dadurch kann C++-Code die überladene Funktion printSum oder ihre Wrapper printSumInt und printSumFloat .

Lassen Sie uns nun eine C-Quelldatei erstellen, die die Hauptfunktion enthält, die der Einstiegspunkt für ein Programm ist. Diese C-Hauptfunktion ruft printSumInt und printSumFloat , d. h., sie ruft beide C++-Funktionen mit C-Verknüpfung auf. Denken Sie daran, dass dies C++-Funktionen sind (deren Funktionskörper C++-Code ausführen), die nur keine C++-verstümmelten Namen haben. Die Datei heißt c-main.c :

 #include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }

Kompilieren Sie es, um die Objektdatei zu generieren:

 $ gcc -c c-main.c

Und sehen Sie sich die importierten/exportierten Symbole an:

 $ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt

Es exportiert wie erwartet main und importiert printSumFloat und printSumInt .

Um alles zusammen in einer ausführbaren Datei zu verknüpfen, müssen wir den C++-Linker (g++) verwenden, da mindestens eine Datei, die wir verknüpfen, print.o , in C++ kompiliert wurde:

 $ g++ -o c-app sum.o print.o c-main.o

Die Ausführung liefert das erwartete Ergebnis:

 $ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4

Versuchen wir es nun mit einer C++-Hauptdatei namens cpp-main.cpp :

 #include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; }

Kompilieren und sehen Sie die importierten/exportierten Symbole der Objektdatei cpp-main.o :

 $ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int)

Es exportiert und importiert die C-Verknüpfung printSumFloat und printSumInt sowie beide entstellten Versionen von printSum .

Sie fragen sich vielleicht, warum das Hauptsymbol nicht als entstelltes Symbol wie main(int, char**) aus dieser C++-Quelle exportiert wird, da es sich um eine C++-Quelldatei handelt und nicht als extern "C" definiert ist. Nun, main ist eine spezielle implementierungsdefinierte Funktion, und meine Implementierung scheint sich dafür entschieden zu haben, die C-Verknüpfung dafür zu verwenden, unabhängig davon, ob sie in einer C- oder C++-Quelldatei definiert ist.

Das Verknüpfen und Ausführen des Programms führt zum erwarteten Ergebnis:

 $ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8

Wie Header Guards funktionieren

Bisher habe ich darauf geachtet, meine Header nicht zweimal direkt oder indirekt aus derselben Quelldatei einzufügen. Aber da ein Header andere Header enthalten kann, kann derselbe Header indirekt mehrmals enthalten sein. Und da der Header-Inhalt einfach an der Stelle eingefügt wird, an der er eingefügt wurde, ist es einfach, mit doppelten Deklarationen zu enden.

Siehe die Beispieldateien in cpp-article/header-guards .

 // unguarded.hpp class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; // guarded.hpp: #ifndef __GUARDED_HPP #define __GUARDED_HPP class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; #endif // __GUARDED_HPP

Der Unterschied besteht darin, dass wir in guarded.hpp den gesamten Header in eine Bedingung einschließen, die nur eingefügt wird, wenn das Präprozessormakro __GUARDED_HPP nicht definiert ist. Wenn der Präprozessor diese Datei zum ersten Mal einbindet, wird sie nicht definiert. Da das Makro jedoch in diesem geschützten Code definiert ist, sieht der Präprozessor beim nächsten Einbinden (direkt oder indirekt aus derselben Quelldatei) die Zeilen zwischen #ifndef und #endif und verwirft den gesamten Code dazwischen Ihnen.

Beachten Sie, dass dieser Prozess für jede Quelldatei stattfindet, die wir kompilieren. Dies bedeutet, dass diese Header-Datei einmal und nur einmal für jede Quelldatei enthalten sein kann. Die Tatsache, dass es aus einer Quelldatei eingefügt wurde, verhindert nicht, dass es aus einer anderen Quelldatei eingefügt wird, wenn diese Quelldatei kompiliert wird. Es wird nur verhindert, dass es mehr als einmal aus derselben Quelldatei eingefügt wird.

Die Beispieldatei main-guarded.cpp enthält zweimal guarded.hpp :

 #include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }

Die vorverarbeitete Ausgabe zeigt jedoch nur eine Definition der Klasse A :

 $ g++ -E main-guarded.cpp # 1 "main-guarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-guarded.cpp" # 1 "guarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-guarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }

Daher kann es problemlos kompiliert werden:

 $ g++ -o guarded main-guarded.cpp

Aber die Datei main-unguarded.cpp enthält zweimal unguarded.hpp :

 #include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }

Und die vorverarbeitete Ausgabe zeigt zwei Definitionen der Klasse A:

 $ g++ -E main-unguarded.cpp # 1 "main-unguarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-unguarded.cpp" # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-unguarded.cpp" 2 # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 3 "main-unguarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }

Dies führt zu Problemen beim Kompilieren:

 $ g++ -o unguarded main-unguarded.cpp

In der Datei enthalten von main-unguarded.cpp:2:0 :

 unguarded.hpp:1:7: error: redefinition of 'class A' class A { ^ In file included from main-unguarded.cpp:1:0: unguarded.hpp:1:7: error: previous definition of 'class A' class A { ^

Der Kürze halber werde ich in diesem Artikel keine geschützten Überschriften verwenden, wenn dies nicht erforderlich ist, da es sich bei den meisten um kurze Beispiele handelt. Aber schützen Sie immer Ihre Header-Dateien. Nicht Ihre Quelldateien, die nirgendwo enthalten sein werden. Nur Header-Dateien.

Wertübergabe und Konstanz von Parametern

Sehen Sie sich die Datei by-value.cpp in cpp-article/symbols/pass-by :

 #include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }

Da ich die Direktive using namespace std verwende, muss ich die Namen von Symbolen (Funktionen oder Klassen) innerhalb des std-Namespace im Rest der Übersetzungseinheit nicht qualifizieren, was in meinem Fall der Rest der Quelldatei ist. Wenn dies eine Header-Datei wäre, hätte ich diese Direktive nicht einfügen sollen, da eine Header-Datei aus mehreren Quelldateien enthalten sein soll; Diese Direktive würde den gesamten std-Namespace ab dem Punkt, an dem sie meinen Header enthalten, in den globalen Geltungsbereich jeder Quelldatei bringen.

Sogar Header, die nach meinem in diesen Dateien enthalten sind, haben diese Symbole im Geltungsbereich. Dies kann zu Namenskonflikten führen, da sie nicht damit gerechnet haben. Verwenden Sie diese Direktive daher nicht in Headern. Verwenden Sie es nur in Quelldateien, wenn Sie möchten, und erst, nachdem Sie alle Header eingefügt haben.

Beachten Sie, dass einige Parameter konstant sind. Das bedeutet, dass sie im Hauptteil der Funktion nicht geändert werden können, wenn wir es versuchen. Es würde einen Kompilierungsfehler geben. Beachten Sie außerdem, dass alle Parameter in dieser Quelldatei als Wert übergeben werden, nicht als Referenz (&) oder als Zeiger (*). Dies bedeutet, dass der Aufrufer eine Kopie davon erstellt und an die Funktion übergibt. Daher spielt es für den Aufrufer keine Rolle, ob sie konstant sind oder nicht, denn wenn wir sie im Funktionsrumpf ändern, ändern wir nur die Kopie, nicht den ursprünglichen Wert, den der Aufrufer an die Funktion übergeben hat.

Da die Konstanz eines als Wert übergebenen Parameters (Kopie) für den Aufrufer keine Rolle spielt, wird sie in der Funktionssignatur nicht verstümmelt, wie es nach dem Kompilieren und Überprüfen des Objektcodes (nur die relevante Ausgabe) zu sehen ist:

 $ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)

Die Signaturen drücken nicht aus, ob die kopierten Parameter in den Körpern der Funktion konstant sind oder nicht. Es spielt keine Rolle. Für die Funktionsdefinition war es nur wichtig, dem Leser des Funktionskörpers auf einen Blick zu zeigen, ob sich diese Werte jemals ändern werden. In dem Beispiel ist nur die Hälfte der Parameter als const deklariert, damit wir den Kontrast sehen können, aber wenn wir const-korrekt sein wollen, sollten sie alle so deklariert worden sein, da keiner von ihnen im Funktionsrumpf geändert wird (und sie sollte nicht).

Da es für die Funktionsdeklaration egal ist, was der Aufrufer sieht, können wir den Header by-value.hpp so erstellen:

 #include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);

Das Hinzufügen der const-Qualifizierer hier ist erlaubt (Sie können sich sogar als const-Variablen qualifizieren, die in der Definition nicht const sind, und es wird funktionieren), aber dies ist nicht erforderlich und macht die Deklarationen nur unnötig ausführlich.

Referenz übergeben

Sehen wir uns by-reference.cpp :

 #include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }

Konstanz beim Übergeben von Referenzen ist für den Aufrufer wichtig, da sie dem Aufrufer mitteilt, ob sein Argument vom Aufgerufenen geändert wird oder nicht. Daher werden die Symbole mit ihrer Konstanz exportiert:

 $ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)

Dies sollte sich auch in der Kopfzeile widerspiegeln, die Anrufer verwenden werden:

 #include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);

Beachten Sie, dass ich die Namen der Variablen nicht wie bisher in die Deklarationen (in den Header) geschrieben habe. Dies ist auch legal, für dieses Beispiel und für die vorherigen. Variablennamen sind in der Deklaration nicht erforderlich, da der Aufrufer nicht wissen muss, wie Sie Ihre Variable benennen möchten. Aber Parameternamen sind im Allgemeinen in Deklarationen wünschenswert, damit der Benutzer auf einen Blick weiß, was jeder Parameter bedeutet und was er daher im Aufruf senden muss.

Überraschenderweise werden Variablennamen auch nicht in der Definition einer Funktion benötigt. Sie werden nur benötigt, wenn Sie den Parameter tatsächlich in der Funktion verwenden. Aber wenn Sie es nie verwenden, können Sie den Parameter mit dem Typ belassen, aber ohne den Namen. Warum sollte eine Funktion einen Parameter deklarieren, den sie niemals verwenden würde? Manchmal sind Funktionen (oder Methoden) nur Teil einer Schnittstelle, wie z. B. einer Callback-Schnittstelle, die bestimmte Parameter definiert, die an den Beobachter übergeben werden. Der Beobachter muss einen Rückruf mit allen Parametern erstellen, die die Schnittstelle angibt, da sie alle vom Aufrufer gesendet werden. Aber der Beobachter ist vielleicht nicht an allen interessiert, also kann die Funktionsdefinition statt einer Compiler-Warnung über einen „unbenutzten Parameter“ diesen einfach ohne Namen belassen.

Übergeben Sie den Zeiger

 // by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }

Um einen Zeiger auf ein konstantes Element (im Beispiel int) zu deklarieren, können Sie den Typ wie folgt deklarieren:

 int const * const int *

Wenn Sie auch möchten, dass der Zeiger selbst konstant ist, das heißt, dass der Zeiger nicht geändert werden kann, um auf etwas anderes zu zeigen, fügen Sie eine Konstante nach dem Stern hinzu:

 int const * const const int * const

Wenn Sie möchten, dass der Zeiger selbst konstant ist, aber nicht das Element, auf das er zeigt:

 int * const

Vergleichen Sie die Funktionssignaturen mit der entstellten Inspektion der Objektdatei:

 $ g++ -c by-pointer.cpp $ nm -C by-pointer.o 000000000000004a T sum(float*, float*) 0000000000000000 T sum(int const*, int const*) 0000000000000105 T sum(std::vector<float, std::allocator<float> > const*) 000000000000009c T sum(std::vector<int, std::allocator<int> > const*)

Wie Sie sehen, verwendet das nm -Tool die erste Notation (const nach dem Typ). Beachten Sie auch, dass die einzige Konstante, die exportiert wird und für den Aufrufer von Bedeutung ist, darin besteht, ob die Funktion das Element, auf das der Zeiger zeigt, ändert oder nicht. Die Konstanz des Zeigers selbst spielt für den Aufrufer keine Rolle, da der Zeiger selbst immer als Kopie übergeben wird. Die Funktion kann nur eine eigene Kopie des Zeigers erstellen, um auf etwas anderes zu zeigen, was für den Aufrufer irrelevant ist.

So kann eine Header-Datei wie folgt erstellt werden:

 #include <vector> int sum(int const* a, int const* b); float sum(float* a, float* b); int sum(std::vector<int>* const); float sum(std::vector<float>* const);

Das Übergeben von Zeigern ist wie das Übergeben von Referenzen. Ein Unterschied besteht darin, dass bei der Übergabe per Referenz erwartet und angenommen wird, dass der Aufrufer eine gültige Elementreferenz übergeben hat, die nicht auf NULL oder eine andere ungültige Adresse zeigt, während ein Zeiger beispielsweise auf NULL zeigen könnte. Zeiger können anstelle von Referenzen verwendet werden, wenn die Übergabe von NULL eine besondere Bedeutung hat.

Seit C++11 können Werte auch mit Move-Semantik übergeben werden. Dieses Thema wird in diesem Artikel nicht behandelt, kann aber in anderen Artikeln wie Argumentübergabe in C++ studiert werden.

Ein weiteres verwandtes Thema, das hier nicht behandelt wird, ist der Aufruf all dieser Funktionen. Wenn alle diese Header aus einer Quelldatei enthalten sind, aber nicht aufgerufen werden, werden die Kompilierung und Verknüpfung erfolgreich sein. Wenn Sie jedoch alle Funktionen aufrufen möchten, treten einige Fehler auf, da einige Aufrufe mehrdeutig sind. Der Compiler wird in der Lage sein, mehr als eine Version von sum für bestimmte Argumente auszuwählen, insbesondere wenn er wählt, ob er per Kopie oder per Referenz (oder const-Referenz) übergeben wird. Diese Analyse ist nicht Gegenstand dieses Artikels.

Kompilieren mit verschiedenen Flags

Sehen wir uns nun eine reale Situation im Zusammenhang mit diesem Thema an, in der schwer zu findende Fehler auftreten können.

Gehen Sie in das Verzeichnis cpp-article/diff-flags und sehen Sie sich Counters.hpp an:

 class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; };

Diese Klasse hat zwei Zähler, die bei Null beginnen und inkrementiert oder gelesen werden können. Für Debug-Builds, wie ich Builds aufrufe, in denen das NDEBUG Makro nicht definiert ist, füge ich auch einen dritten Zähler hinzu, der jedes Mal erhöht wird, wenn einer der anderen beiden Zähler erhöht wird. Das wird eine Art Debug-Helfer für diese Klasse sein. Viele Bibliotheksklassen von Drittanbietern oder sogar eingebaute C++-Header (je nach Compiler) verwenden Tricks wie diesen, um verschiedene Debugging-Ebenen zu ermöglichen. Dies ermöglicht Debug-Builds, Iteratoren zu erkennen, die außerhalb des Bereichs liegen, und andere interessante Dinge, an die der Bibliothekshersteller denken könnte. Ich nenne Release-Builds „Builds, in denen das NDEBUG Makro definiert ist“.

Für Release-Builds sieht der vorkompilierte Header so aus (ich verwende grep , um Leerzeilen zu entfernen):

 $ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };

Für Debug-Builds sieht es folgendermaßen aus:

 $ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };

Es gibt einen weiteren Zähler in Debug-Builds, wie ich bereits erklärt habe.

Ich habe auch einige Hilfsdateien erstellt.

 // increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); }
 // increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); }
 // main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; }

Und ein Makefile , das die Compiler-Flags nur für increment2.cpp anpassen kann:

 all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags

Lassen Sie uns also alles im Debug-Modus kompilieren, ohne NDEBUG zu definieren:

 $ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o

Jetzt ausführen:

 $ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7

Die Ausgabe ist genauso wie erwartet. Lassen Sie uns nun nur eine der Dateien mit definiertem NDEBUG kompilieren, die der Release-Modus wäre, und sehen, was passiert:

 $ make clean rm -f *.o diff-flags $ CFLAGS='-DNDEBUG' make g++ -c -O2 main.cpp g++ -c -DNDEBUG -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o $ ./diff-flags c.get1(): 0 c.get2(): 4 c.getDebugAllCounters(): 7

The output isn't as expected. increment1 function saw a release version of the Counters class, in which there are only two int member fields. So, it incremented the first field, thinking that it was m_counter1 , and didn't increment anything else since it knows nothing about the m_debugAllCounters field. I say that increment1 incremented the counter because the inc1 method in Counter is inline, so it was inlined in increment1 function body, not called from it. The compiler probably decided to inline it because the -O2 optimization level flag was used.

So, m_counter1 was never incremented and m_debugAllCounters was incremented instead of it by mistake in increment1 . That's why we see 0 for m_counter1 but we still see 7 for m_debugAllCounters .

Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector , and others were compiled with those options.

Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.

This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).

The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.

The Compiler Does a Lot More Than You May Think

In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.

From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.

I hope you will find the knowledge from this article useful in your C++ projects.


Weiterführende Literatur im Toptal Engineering Blog:

  • So lernen Sie die Sprachen C und C++: Die ultimative Liste
  • C# vs. C++: Was ist der Kern?