Comment fonctionne C++ : comprendre la compilation

Publié: 2022-03-11

Le langage de programmation C++ de Bjarne Stroustrup a un chapitre intitulé « Une visite guidée de C++ : les bases » — C++ standard. Ce chapitre, en 2.2, mentionne en une demi-page le processus de compilation et de liaison en C++. La compilation et la liaison sont deux processus très basiques qui se produisent tout le temps pendant le développement de logiciels C++, mais curieusement, ils ne sont pas bien compris par de nombreux développeurs C++.

Pourquoi le code source C++ est-il divisé en fichiers d'en-tête et source ? Comment chaque partie est-elle vue par le compilateur ? Comment cela affecte-t-il la compilation et la liaison ? Il y a beaucoup d'autres questions comme celles-ci auxquelles vous avez peut-être pensé, mais que vous avez fini par accepter comme une convention.

Que vous conceviez une application C++, que vous implémentiez de nouvelles fonctionnalités pour celle-ci, que vous essayiez de résoudre des bogues (en particulier certains bogues étranges) ou que vous essayiez de faire fonctionner ensemble du code C et C++, savoir comment la compilation et la liaison fonctionnent vous fera gagner beaucoup de temps et rendre ces tâches beaucoup plus agréables. Dans cet article, vous apprendrez exactement cela.

L'article expliquera comment un compilateur C++ fonctionne avec certaines des constructions de langage de base, répondra à certaines questions courantes liées à leurs processus et vous aidera à contourner certaines erreurs connexes que les développeurs commettent souvent dans le développement C++.

Remarque : cet article contient un exemple de code source qui peut être téléchargé à partir de https://bitbucket.org/danielmunoz/cpp-article

Les exemples ont été compilés dans une machine Linux CentOS :

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

Utilisation de la version g++ :

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

Les fichiers source fournis doivent être portables sur d'autres systèmes d'exploitation, bien que les Makefiles qui les accompagnent pour le processus de construction automatisé ne doivent être portables que sur des systèmes de type Unix.

Le pipeline de build : prétraiter, compiler et lier

Chaque fichier source C++ doit être compilé dans un fichier objet. Les fichiers objets résultant de la compilation de plusieurs fichiers sources sont ensuite liés dans un exécutable, une bibliothèque partagée ou une bibliothèque statique (la dernière n'étant qu'une archive de fichiers objets). Les fichiers source C++ ont généralement les suffixes d'extension .cpp, .cxx ou .cc.

Un fichier source C++ peut inclure d'autres fichiers, appelés fichiers d'en-tête, avec la directive #include . Les fichiers d'en-tête ont des extensions telles que .h, .hpp ou .hxx, ou n'ont aucune extension comme dans la bibliothèque standard C++ et les fichiers d'en-tête d'autres bibliothèques (comme Qt). L'extension n'a pas d'importance pour le préprocesseur C++, qui remplacera littéralement la ligne contenant la directive #include par l'intégralité du contenu du fichier inclus.

La première étape que le compilateur fera sur un fichier source est d'y exécuter le préprocesseur. Seuls les fichiers source sont transmis au compilateur (pour le prétraiter et le compiler). Les fichiers d'en-tête ne sont pas transmis au compilateur. Au lieu de cela, ils sont inclus à partir des fichiers source.

Chaque fichier d'en-tête peut être ouvert plusieurs fois pendant la phase de prétraitement de tous les fichiers source, en fonction du nombre de fichiers source qui les incluent ou du nombre d'autres fichiers d'en-tête inclus à partir des fichiers source qui les incluent également (il peut y avoir plusieurs niveaux d'indirection) . Les fichiers source, en revanche, ne sont ouverts qu'une seule fois par le compilateur (et le préprocesseur), lorsqu'ils lui sont transmis.

Pour chaque fichier source C++, le préprocesseur construira une unité de traduction en y insérant du contenu lorsqu'il trouvera une directive #include en même temps qu'il supprimera le code du fichier source et des en-têtes lorsqu'il trouvera une compilation conditionnelle. blocs dont la directive est évaluée à false . Il effectuera également d'autres tâches telles que les remplacements de macros.

Une fois que le préprocesseur a fini de créer cette unité de traduction (parfois énorme), le compilateur démarre la phase de compilation et produit le fichier objet.

Pour obtenir cette unité de traduction (le code source prétraité), l'option -E peut être transmise au compilateur g++, avec l'option -o pour spécifier le nom souhaité du fichier source prétraité.

Dans le cpp-article/hello-world , il y a un exemple de fichier « hello-world.cpp » :

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

Créez le fichier prétraité en :

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

Et voyez le nombre de lignes:

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

Il a 17 588 lignes dans ma machine. Vous pouvez également exécuter make sur ce répertoire et il effectuera ces étapes pour vous.

Nous pouvons voir que le compilateur doit compiler un fichier beaucoup plus volumineux que le simple fichier source que nous voyons. C'est à cause des en-têtes inclus. Et dans notre exemple, nous n'avons inclus qu'un seul en-tête. L'unité de traduction devient de plus en plus grande au fur et à mesure que nous continuons à inclure des en-têtes.

Ce processus de prétraitement et de compilation est similaire pour le langage C. Il suit les règles C pour la compilation, et la façon dont il inclut les fichiers d'en-tête et produit le code objet est presque la même.

Comment les fichiers source importent et exportent des symboles

Voyons maintenant les fichiers dans le répertoire cpp-article/symbols/c-vs-cpp-names .

Comment les fonctions sont traitées.

Il existe un simple fichier source C (pas C++) nommé sum.c qui exporte deux fonctions, une pour ajouter deux entiers et une pour ajouter deux flottants :

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

Compilez-le (ou exécutez make et toutes les étapes pour créer les deux exemples d'applications à exécuter) pour créer le fichier objet sum.o :

 $ gcc -c sum.c

Regardez maintenant les symboles exportés et importés par ce fichier objet :

 $ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI

Aucun symbole n'est importé et deux symboles sont exportés : sumF et sumI . Ces symboles sont exportés dans le cadre du segment .text (T), ce sont donc des noms de fonction, du code exécutable.

Si d'autres fichiers source (C ou C++) veulent appeler ces fonctions, ils doivent les déclarer avant d'appeler.

La manière standard de le faire est de créer un fichier d'en-tête qui les déclare et les inclut dans le fichier source que nous voulons les appeler. L'en-tête peut avoir n'importe quel nom et extension. J'ai choisi 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

Quels sont ces blocs de compilation conditionnels ifdef / endif ? Si j'inclus cet en-tête d'un fichier source C, je veux qu'il devienne :

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

Mais si je les inclut à partir d'un fichier source C++, je veux qu'il devienne :

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

Le langage C ne sait rien de la directive extern "C" , mais C++ le sait, et il a besoin que cette directive soit appliquée aux déclarations de fonctions C. En effet, C++ modifie les noms de fonctions (et de méthodes) car il prend en charge la surcharge de fonctions/méthodes, contrairement à C.

Cela peut être vu dans le fichier source C++ nommé print.cpp :

 #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); }

Il existe deux fonctions portant le même nom ( printSum ) qui ne diffèrent que par le type de leurs paramètres : int ou float . La surcharge de fonctions est une fonctionnalité C++ qui n'est pas présente dans C. Pour implémenter cette fonctionnalité et différencier ces fonctions, C++ modifie le nom de la fonction, comme nous pouvons le voir dans leur nom de symbole exporté (je ne choisirai que ce qui est pertinent à partir de la sortie de nm) :

 $ 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

Ces fonctions sont exportées (dans mon système) en tant que _Z8printSumff pour la version float et _Z8printSumii pour la version int. Chaque nom de fonction en C++ est mutilé à moins qu'il ne soit déclaré comme extern "C" . Deux fonctions ont été déclarées avec la liaison C dans print.cpp : printSumInt et printSumFloat .

Par conséquent, ils ne peuvent pas être surchargés, sinon leurs noms exportés seraient les mêmes puisqu'ils ne sont pas mutilés. J'ai dû les différencier les uns des autres en postfixant un Int ou un Float à la fin de leurs noms.

Comme ils ne sont pas mutilés, ils peuvent être appelés à partir du code C, comme nous le verrons bientôt.

Pour voir les noms mutilés comme nous les verrions dans le code source C++, nous pouvons utiliser l'option -C (demangle) dans la commande nm . Encore une fois, je ne copierai que la même partie pertinente de la sortie :

 $ 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

Avec cette option, au lieu de _Z8printSumff nous voyons printSum(float, float) , et au lieu de _ZSt4cout nous voyons std::cout, qui sont des noms plus conviviaux.

Nous voyons également que notre code C++ appelle du code C : print.cpp appelle sumI et sumF , qui sont des fonctions C déclarées comme ayant une liaison C dans sum.h . Cela peut être vu dans la sortie nm de print.o ci-dessus, qui informe de certains symboles indéfinis (U) : sumF , sumI et std::cout . Ces symboles indéfinis sont censés être fournis dans l'un des fichiers objets (ou bibliothèques) qui seront liés avec cette sortie de fichier objet dans la phase de liaison.

Jusqu'à présent, nous venons de compiler le code source en code objet, nous n'avons pas encore lié. Si nous ne lions pas le fichier objet qui contient les définitions de ces symboles importés avec ce fichier objet, l'éditeur de liens s'arrêtera avec une erreur "symbole manquant".

Notez également que puisque print.cpp est un fichier source C++, compilé avec un compilateur C++ (g++), tout le code qu'il contient est compilé en tant que code C++. Les fonctions avec une liaison C comme printSumInt et printSumFloat sont également des fonctions C++ qui peuvent utiliser des fonctionnalités C++. Seuls les noms des symboles sont compatibles avec C, mais le code est C++, ce qui peut être vu par le fait que les deux fonctions appellent une fonction surchargée ( printSum ), ce qui ne pourrait pas arriver si printSumInt ou printSumFloat étaient compilés en C.

Voyons maintenant print.hpp , un fichier d'en-tête qui peut être inclus à la fois à partir de fichiers source C ou C++, qui permettra d' printSumInt et printSumFloat à la fois depuis C et depuis C++, et printSum d'être appelé depuis C++ :

 #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

Si nous l'incluons à partir d'un fichier source C, nous voulons juste voir :

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

printSum ne peut pas être vu à partir du code C car son nom est mutilé, nous n'avons donc pas de moyen (standard et portable) de le déclarer pour le code C. Oui, je peux les déclarer comme :

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

Et l'éditeur de liens ne se plaindra pas puisque c'est le nom exact que mon compilateur actuellement installé lui a inventé, mais je ne sais pas si cela fonctionnera pour votre éditeur de liens (si votre compilateur génère un nom mutilé différent), ou même pour le prochaine version de mon linker. Je ne sais même pas si l'appel fonctionnera comme prévu en raison de l'existence de différentes conventions d'appel (comment les paramètres sont passés et les valeurs de retour sont renvoyées) qui sont spécifiques au compilateur et peuvent être différentes pour les appels C et C++ (en particulier pour les fonctions C++ qui sont des fonctions membres et reçoivent le pointeur this en paramètre).

Votre compilateur peut potentiellement utiliser une convention d'appel pour les fonctions C++ régulières et une autre si elles sont déclarées comme ayant une liaison "C" externe. Ainsi, tromper le compilateur en disant qu'une fonction utilise la convention d'appel C alors qu'elle utilise en fait C++ car elle peut donner des résultats inattendus si les conventions utilisées pour chacune sont différentes dans votre chaîne d'outils de compilation.

Il existe des moyens standard de mélanger le code C et C++ et un moyen standard d'appeler des fonctions C++ surchargées à partir de C consiste à les encapsuler dans des fonctions avec une liaison C comme nous l'avons fait en printSum avec printSumInt et printSumFloat .

Si nous incluons print.hpp à partir d'un fichier source C++, la macro de préprocesseur __cplusplus sera définie et le fichier sera vu comme :

 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"

Cela permettra au code C++ d'appeler la fonction surchargée printSum ou ses wrappers printSumInt et printSumFloat .

Créons maintenant un fichier source C contenant la fonction principale, qui est le point d'entrée d'un programme. Cette fonction principale C appellera printSumInt et printSumFloat , c'est-à-dire appellera les deux fonctions C++ avec la liaison C. N'oubliez pas que ce sont des fonctions C++ (leurs corps de fonction exécutent du code C++) qui n'ont pas de noms mutilés C++. Le fichier est nommé c-main.c :

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

Compilez-le pour générer le fichier objet :

 $ gcc -c c-main.c

Et voyez les symboles importés/exportés :

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

Il exporte main et importe printSumFloat et printSumInt , comme prévu.

Pour lier le tout dans un fichier exécutable, nous devons utiliser l'éditeur de liens C++ (g++), car au moins un fichier que nous allons lier, print.o , a été compilé en C++ :

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

L'exécution produit le résultat attendu :

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

Essayons maintenant avec un fichier principal C++, nommé 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; }

Compilez et visualisez les symboles importés/exportés du fichier objet 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)

Il exporte la liaison C principale et importe printSumFloat et printSumInt , et les deux versions mutilées de printSum .

Vous vous demandez peut-être pourquoi le symbole principal n'est pas exporté en tant que symbole mutilé comme main(int, char**) à partir de cette source C++ puisqu'il s'agit d'un fichier source C++ et qu'il n'est pas défini comme extern "C" . Eh bien, main est une fonction spéciale définie par l'implémentation et mon implémentation semble avoir choisi d'utiliser la liaison C pour cela, qu'elle soit définie dans un fichier source C ou C++.

Lier et exécuter le programme donne le résultat attendu :

 $ 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

Comment fonctionnent les gardes d'en-tête

Jusqu'à présent, j'ai pris soin de ne pas inclure mes en-têtes deux fois, directement ou indirectement, à partir du même fichier source. Mais comme un en-tête peut inclure d'autres en-têtes, le même en-tête peut indirectement être inclus plusieurs fois. Et comme le contenu de l'en-tête est simplement inséré à l'endroit d'où il a été inclus, il est facile de se retrouver avec des déclarations dupliquées.

Voir les fichiers d'exemple dans 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

La différence est que, dans guarded.hpp, nous entourons tout l'en-tête d'une condition qui ne sera incluse que si la macro de préprocesseur __GUARDED_HPP n'est pas définie. La première fois que le préprocesseur inclut ce fichier, il ne sera pas défini. Mais, puisque la macro est définie à l'intérieur de ce code protégé, la prochaine fois qu'elle sera incluse (à partir du même fichier source, directement ou indirectement), le préprocesseur verra les lignes entre le #ifndef et le #endif et supprimera tout le code entre leur.

Notez que ce processus se produit pour chaque fichier source que nous compilons. Cela signifie que ce fichier d'en-tête peut être inclus une fois et une seule pour chaque fichier source. Le fait qu'il ait été inclus à partir d'un fichier source n'empêchera pas qu'il soit inclus à partir d'un fichier source différent lorsque ce fichier source est compilé. Cela empêchera simplement qu'il soit inclus plus d'une fois à partir du même fichier source.

Le fichier d'exemple main-guarded.cpp inclut guarded.hpp deux fois :

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

Mais la sortie prétraitée ne montre qu'une seule définition de la classe 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(); }

Par conséquent, il peut être compilé sans problème :

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

Mais le fichier main-unguarded.cpp inclut deux fois unguarded.hpp :

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

Et la sortie prétraitée montre deux définitions de la classe 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(); }

Cela posera des problèmes lors de la compilation :

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

Dans le fichier inclus à partir de 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 { ^

Par souci de concision, je n'utiliserai pas d'en-têtes protégés dans cet article si ce n'est pas nécessaire, car la plupart sont de courts exemples. Mais gardez toujours vos fichiers d'en-tête. Pas vos fichiers source, qui ne seront inclus nulle part. Juste des fichiers d'en-tête.

Passer par valeur et constance des paramètres

Regardez le fichier by-value.cpp dans 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); }

Puisque j'utilise la directive using namespace std , je n'ai pas à qualifier les noms de symboles (fonctions ou classes) à l'intérieur de l'espace de noms std dans le reste de l'unité de traduction, qui dans mon cas est le reste du fichier source. S'il s'agissait d'un fichier d'en-tête, je n'aurais pas dû insérer cette directive car un fichier d'en-tête est censé être inclus à partir de plusieurs fichiers source ; cette directive apporterait à la portée globale de chaque fichier source l'ensemble de l'espace de noms std à partir du point où ils incluent mon en-tête.

Même les en-têtes inclus après le mien dans ces fichiers auront ces symboles dans leur portée. Cela peut produire des conflits de noms car ils ne s'attendaient pas à ce que cela se produise. Par conséquent, n'utilisez pas cette directive dans les en-têtes. Utilisez-le uniquement dans les fichiers source si vous le souhaitez, et uniquement après avoir inclus tous les en-têtes.

Notez comment certains paramètres sont const. Cela signifie qu'ils ne peuvent pas être modifiés dans le corps de la fonction si nous essayons de le faire. Cela donnerait une erreur de compilation. Notez également que tous les paramètres de ce fichier source sont passés par valeur, et non par référence (&) ou par pointeur (*). Cela signifie que l'appelant en fera une copie et passera à la fonction. Ainsi, peu importe pour l'appelant qu'ils soient const ou non, car si nous les modifions dans le corps de la fonction, nous ne modifierons que la copie, pas la valeur d'origine que l'appelant a transmise à la fonction.

Étant donné que la constance d'un paramètre passé par valeur (copie) n'a pas d'importance pour l'appelant, elle n'est pas mutilée dans la signature de la fonction, comme on peut le voir après avoir compilé et inspecté le code objet (uniquement la sortie pertinente):

 $ 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> >)

Les signatures n'expriment pas si les paramètres copiés sont const ou non dans le corps de la fonction. Cela n'a pas d'importance. Il importait uniquement pour la définition de la fonction, de montrer d'un coup d'œil au lecteur du corps de la fonction si ces valeurs changeraient un jour. Dans l'exemple, seule la moitié des paramètres sont déclarés comme const, nous pouvons donc voir le contraste, mais si nous voulons être const-correct, ils devraient tous l'avoir été puisqu'aucun d'entre eux n'est modifié dans le corps de la fonction (et ils ne devrait pas).

Puisque peu importe pour la déclaration de fonction ce que voit l'appelant, nous pouvons créer l'en-tête by-value.hpp comme ceci :

 #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);

L'ajout des qualificatifs const ici est autorisé (vous pouvez même qualifier de variables const qui ne sont pas const dans la définition et cela fonctionnera), mais ce n'est pas nécessaire et cela ne fera que rendre les déclarations inutilement verbeuses.

Passer par référence

Voyons 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); }

La constance lors du passage par référence est importante pour l'appelant, car elle lui dira si son argument sera modifié ou non par l'appelé. Par conséquent, les symboles sont exportés avec leur constance :

 $ 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&)

Cela devrait également être reflété dans l'en-tête que les appelants utiliseront :

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

Notez que je n'ai pas écrit le nom des variables dans les déclarations (dans l'en-tête) comme je le faisais jusqu'à présent. Ceci est également légal, pour cet exemple et pour les précédents. Les noms de variable ne sont pas requis dans la déclaration, car l'appelant n'a pas besoin de savoir comment vous voulez nommer votre variable. Mais les noms de paramètres sont généralement souhaitables dans les déclarations afin que l'utilisateur puisse savoir en un coup d'œil ce que chaque paramètre signifie et donc ce qu'il faut envoyer dans l'appel.

Étonnamment, les noms de variables ne sont pas non plus nécessaires dans la définition d'une fonction. Ils ne sont nécessaires que si vous utilisez réellement le paramètre dans la fonction. Mais si vous ne l'utilisez jamais, vous pouvez laisser le paramètre avec le type mais sans le nom. Pourquoi une fonction déclarerait-elle un paramètre qu'elle n'utiliserait jamais ? Parfois, les fonctions (ou méthodes) font simplement partie d'une interface, comme une interface de rappel, qui définit certains paramètres qui sont passés à l'observateur. L'observateur doit créer un rappel avec tous les paramètres spécifiés par l'interface puisqu'ils seront tous envoyés par l'appelant. Mais l'observateur peut ne pas être intéressé par chacun d'eux, donc au lieu de recevoir un avertissement du compilateur concernant un "paramètre inutilisé", la définition de la fonction peut simplement le laisser sans nom.

Passer par le pointeur

 // 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); }

Pour déclarer un pointeur vers un élément const (int dans l'exemple), vous pouvez déclarer le type comme l'un des suivants :

 int const * const int *

Si vous souhaitez également que le pointeur lui-même soit const, c'est-à-dire que le pointeur ne puisse pas être modifié pour pointer vers autre chose, vous ajoutez un const après l'étoile :

 int const * const const int * const

Si vous voulez que le pointeur lui-même soit const, mais pas l'élément pointé par celui-ci :

 int * const

Comparez les signatures de fonction avec l'inspection simplifiée du fichier objet :

 $ 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*)

Comme vous le voyez, l'outil nm utilise la première notation (const après le type). Notez également que la seule constance qui est exportée, et qui importe pour l'appelant, est de savoir si la fonction modifiera ou non l'élément pointé par le pointeur. La constance du pointeur lui-même n'est pas pertinente pour l'appelant puisque le pointeur lui-même est toujours passé comme copie. La fonction ne peut faire que sa propre copie du pointeur pour pointer ailleurs, ce qui n'est pas pertinent pour l'appelant.

Ainsi, un fichier d'en-tête peut être créé comme :

 #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);

Passer par pointeur revient à passer par référence. Une différence est que lorsque vous passez par référence, l'appelant est attendu et supposé avoir passé la référence d'un élément valide, ne pointant pas vers NULL ou une autre adresse invalide, alors qu'un pointeur pourrait pointer vers NULL par exemple. Des pointeurs peuvent être utilisés à la place des références lorsque le passage de NULL a une signification particulière.

Étant donné que les valeurs C++11 peuvent également être transmises avec une sémantique de déplacement. Ce sujet ne sera pas traité dans cet article mais pourra être étudié dans d'autres articles comme Argument Passing in C++.

Un autre sujet connexe qui ne sera pas couvert ici est de savoir comment appeler toutes ces fonctions. Si tous ces en-têtes sont inclus à partir d'un fichier source mais ne sont pas appelés, la compilation et la liaison réussiront. Mais si vous voulez appeler toutes les fonctions, il y aura des erreurs car certains appels seront ambigus. Le compilateur pourra choisir plus d'une version de sum pour certains arguments, notamment lorsqu'il choisira de passer par copie ou par référence (ou référence const). Cette analyse sort du cadre de cet article.

Compiler avec différents drapeaux

Voyons maintenant une situation réelle liée à ce sujet où des bogues difficiles à trouver peuvent apparaître.

Allez dans le répertoire cpp-article/diff-flags et regardez Counters.hpp :

 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; };

Cette classe a deux compteurs, qui commencent à zéro et peuvent être incrémentés ou lus. Pour les versions de débogage, c'est ainsi que j'appellerai les versions où la macro NDEBUG n'est pas définie, j'ajoute également un troisième compteur, qui sera incrémenté à chaque fois que l'un des deux autres compteurs sera incrémenté. Ce sera une sorte d'assistant de débogage pour cette classe. De nombreuses classes de bibliothèques tierces ou même des en-têtes C++ intégrés (selon le compilateur) utilisent des astuces comme celle-ci pour permettre différents niveaux de débogage. Cela permet aux versions de débogage de détecter les itérateurs hors de portée et d'autres choses intéressantes auxquelles le fabricant de la bibliothèque pourrait penser. J'appellerai les builds de version "builds où la macro NDEBUG est définie".

Pour les versions de version, l'en-tête précompilé ressemble (j'utilise grep pour supprimer les lignes vides):

 $ 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; };

Alors que pour les versions de débogage, cela ressemblera à :

 $ 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; };

Il y a un autre compteur dans les versions de débogage, comme je l'ai expliqué plus tôt.

J'ai également créé des fichiers d'aide.

 // 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; }

Et un Makefile qui peut personnaliser les drapeaux du compilateur pour increment2.cpp uniquement :

 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

Compilons donc le tout en mode debug, sans définir NDEBUG :

 $ 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

Exécutez maintenant :

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

La sortie est juste comme prévu. Compilons maintenant un seul des fichiers avec NDEBUG défini, qui serait en mode de publication, et voyons ce qui se passe :

 $ 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.


Lectures complémentaires sur le blog Toptal Engineering :

  • Comment apprendre les langages C et C++ : la liste ultime
  • C# contre C++ : qu'y a-t-il au cœur ?