Jak działa C++: Zrozumienie kompilacji

Opublikowany: 2022-03-11

Język programowania C++ Bjarne'a Stroustrupa zawiera rozdział zatytułowany „Wycieczka po C++: podstawy” — Standard C++. Ten rozdział, w 2.2, na pół strony wspomina o procesie kompilacji i linkowania w C++. Kompilacja i łączenie to dwa bardzo podstawowe procesy, które zdarzają się cały czas podczas tworzenia oprogramowania w C++, ale co dziwne, nie są one dobrze rozumiane przez wielu programistów C++.

Dlaczego kod źródłowy C++ jest podzielony na pliki nagłówkowe i źródłowe? Jak każda część jest postrzegana przez kompilator? Jak to wpływa na kompilację i łączenie? Jest o wiele więcej takich pytań, o których być może myślałeś, ale które przyjąłeś jako konwencję.

Niezależnie od tego, czy projektujesz aplikację w C++, wdrażasz do niej nowe funkcje, próbujesz naprawić błędy (szczególnie niektóre dziwne błędy), czy też starasz się, aby kod C i C++ działały razem, wiedząc, jak działa kompilacja i łączenie, zaoszczędzisz dużo czasu i uczynić te zadania dużo przyjemniejszymi. W tym artykule dowiesz się dokładnie tego.

Artykuł wyjaśni, jak kompilator C++ współpracuje z niektórymi podstawowymi konstrukcjami języka, odpowie na kilka typowych pytań związanych z ich procesami i pomoże obejść niektóre powiązane błędy, które programiści często popełniają podczas programowania w C++.

Uwaga: ten artykuł zawiera przykładowy kod źródłowy, który można pobrać z https://bitbucket.org/danielmunoz/cpp-article

Przykłady zostały skompilowane na maszynie CentOS Linux:

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

Używając wersji g++:

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

Dostarczone pliki źródłowe powinny być przenośne do innych systemów operacyjnych, chociaż towarzyszące im pliki Makefile do zautomatyzowanego procesu kompilacji powinny być przenośne tylko do systemów uniksopodobnych.

Potok kompilacji: wstępne przetwarzanie, kompilacja i łączenie

Każdy plik źródłowy C++ musi być skompilowany do pliku obiektowego. Pliki obiektowe powstałe w wyniku kompilacji wielu plików źródłowych są następnie łączone w plik wykonywalny, bibliotekę współdzieloną lub bibliotekę statyczną (ostatnia z nich to tylko archiwum plików obiektowych). Pliki źródłowe C++ zazwyczaj mają rozszerzenia .cpp, .cxx lub .cc.

Plik źródłowy C++ może zawierać inne pliki, znane jako pliki nagłówkowe, z dyrektywą #include . Pliki nagłówkowe mają rozszerzenia takie jak .h, .hpp lub .hxx lub nie mają żadnego rozszerzenia, jak w standardowej bibliotece C++ i plikach nagłówkowych innych bibliotek (takich jak Qt). Rozszerzenie nie ma znaczenia dla preprocesora C++, który dosłownie zastąpi linię zawierającą dyrektywę #include całą zawartością dołączonego pliku.

Pierwszym krokiem, który kompilator wykona na pliku źródłowym, jest uruchomienie na nim preprocesora. Do kompilatora przekazywane są tylko pliki źródłowe (w celu ich wstępnego przetworzenia i skompilowania). Pliki nagłówkowe nie są przekazywane do kompilatora. Zamiast tego są dołączane z plików źródłowych.

Każdy plik nagłówkowy może być otwierany wielokrotnie podczas fazy przetwarzania wstępnego wszystkich plików źródłowych, w zależności od tego, ile plików źródłowych je zawiera lub ile innych plików nagłówkowych, które są zawarte w plikach źródłowych, również je zawiera (może być wiele poziomów pośrednich) . Z drugiej strony pliki źródłowe są otwierane tylko raz przez kompilator (i preprocesor), kiedy są do niego przekazywane.

Dla każdego pliku źródłowego C++ preprocesor zbuduje jednostkę tłumaczeniową, wstawiając do niej treść, gdy znajdzie dyrektywę #include, jednocześnie usuwając kod z pliku źródłowego i nagłówków, gdy znajdzie kompilację warunkową bloki, których dyrektywa ma wartość false . Będzie również wykonywać inne zadania, takie jak zamiany makr.

Gdy preprocesor zakończy tworzenie tej (czasem ogromnej) jednostki tłumaczeniowej, kompilator rozpoczyna fazę kompilacji i tworzy plik obiektowy.

Aby uzyskać tę jednostkę translacji (wstępnie przetworzony kod źródłowy), do kompilatora g++ można przekazać opcję -E wraz z opcją -o , aby określić żądaną nazwę wstępnie przetworzonego pliku źródłowego.

W katalogu cpp-article/hello-world znajduje się przykładowy plik „hello-world.cpp”:

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

Utwórz wstępnie przetworzony plik przez:

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

I zobacz liczbę linii:

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

W mojej maszynie jest 17 588 linii. Możesz też po prostu uruchomić make w tym katalogu, a on wykona te kroki za Ciebie.

Widzimy, że kompilator musi skompilować znacznie większy plik niż prosty plik źródłowy, który widzimy. Wynika to z dołączonych nagłówków. W naszym przykładzie uwzględniliśmy tylko jeden nagłówek. Jednostka tłumaczeniowa staje się coraz większa, ponieważ stale dołączamy nagłówki.

Ten proces wstępnego przetwarzania i kompilacji jest podobny dla języka C. Jest zgodny z zasadami kompilacji języka C, a sposób, w jaki zawiera pliki nagłówkowe i tworzy kod wynikowy, jest prawie taki sam.

Jak pliki źródłowe importują i eksportują symbole

Zobaczmy teraz pliki w katalogu cpp-article/symbols/c-vs-cpp-names .

Jak przetwarzane są funkcje.

Istnieje prosty plik źródłowy C (nie C++) o nazwie sum.c, który eksportuje dwie funkcje, jedną do dodawania dwóch liczb całkowitych i jedną do dodawania dwóch liczb zmiennoprzecinkowych:

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

Skompiluj go (lub uruchom make i wszystkie kroki, aby utworzyć dwie przykładowe aplikacje do wykonania), aby utworzyć plik obiektowy sum.o:

 $ gcc -c sum.c

Teraz spójrz na symbole eksportowane i importowane przez ten plik obiektowy:

 $ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI

Żadne symbole nie są importowane, a eksportowane są dwa symbole: sumF i sumI . Symbole te są eksportowane jako część segmentu .text (T), więc są to nazwy funkcji, kod wykonywalny.

Jeśli inne (zarówno C, jak i C++) pliki źródłowe chcą wywołać te funkcje, muszą je zadeklarować przed wywołaniem.

Standardowym sposobem, aby to zrobić, jest utworzenie pliku nagłówkowego, który je deklaruje i umieszcza w dowolnym pliku źródłowym, który chcemy je wywołać. Nagłówek może mieć dowolną nazwę i rozszerzenie. Wybrałem 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

Czym są bloki kompilacji warunkowej ifdef / endif ? Jeśli dołączę ten nagłówek z pliku źródłowego C, chcę, aby stał się:

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

Ale jeśli dołączę je z pliku źródłowego C++, chcę, aby stało się:

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

Język C nie wie nic o dyrektywie extern "C" , ale C++ wie i potrzebuje tej dyrektywy zastosowanej do deklaracji funkcji C. Dzieje się tak, ponieważ C++ zniekształca nazwy funkcji (i metod), ponieważ obsługuje przeciążanie funkcji/metod, podczas gdy C nie.

Można to zobaczyć w pliku źródłowym C++ o nazwie 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); }

Istnieją dwie funkcje o tej samej nazwie ( printSum ), które różnią się jedynie typem parametrów: int lub float . Przeciążanie funkcji jest cechą C++, która nie występuje w C. Aby zaimplementować tę funkcję i odróżnić te funkcje, C++ zniekształca nazwę funkcji, jak widać w ich wyeksportowanej nazwie symbolu (wybiorę tylko to, co jest istotne z danych wyjściowych 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

Te funkcje są eksportowane (w moim systemie) jako _Z8printSumff dla wersji float i _Z8printSumii dla wersji int. Każda nazwa funkcji w C++ jest zniekształcona, chyba że zostanie zadeklarowana jako extern "C" . Istnieją dwie funkcje, które zostały zadeklarowane z połączeniem C w print.cpp : printSumInt i printSumFloat .

Dlatego nie mogą być przeciążone, w przeciwnym razie ich wyeksportowane nazwy będą takie same, ponieważ nie są zniekształcone. Musiałem odróżnić je od siebie, dodając na końcu ich nazw Int lub Float.

Ponieważ nie są zniekształcone, można je wywołać z kodu C, jak wkrótce zobaczymy.

Aby zobaczyć zniekształcone nazwy tak, jak widzielibyśmy je w kodzie źródłowym C++, możemy użyć opcji -C (demangle) w poleceniu nm . Ponownie skopiuję tylko tę samą odpowiednią część danych wyjściowych:

 $ 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

Dzięki tej opcji zamiast _Z8printSumff widzimy printSum(float, float) , a zamiast _ZSt4cout widzimy std::cout, które są bardziej przyjazne dla człowieka.

Widzimy również, że nasz kod C++ wywołuje kod C: print.cpp wywołuje sumI i sumF , które są funkcjami C zadeklarowanymi jako posiadające powiązanie C w sum.h . Można to zobaczyć na wyjściu nm print.o powyżej, które informuje o pewnych niezdefiniowanych symbolach (U): sumF , sumI i std::cout . Te niezdefiniowane symbole powinny znajdować się w jednym z plików obiektowych (lub bibliotekach), które zostaną połączone z tym wyjściem pliku obiektowego w fazie łączenia.

Do tej pory po prostu skompilowaliśmy kod źródłowy do kodu wynikowego, jeszcze się nie połączyliśmy. Jeśli nie połączymy pliku obiektowego zawierającego definicje tych importowanych symboli razem z tym plikiem obiektowym, linker zatrzyma się z błędem „brakującego symbolu”.

Zauważ również, że ponieważ print.cpp jest plikiem źródłowym C++, skompilowanym za pomocą kompilatora C++ (g++), cały kod w nim jest skompilowany jako kod C++. Funkcje z powiązaniem C, takie jak printSumInt i printSumFloat , są również funkcjami C++, które mogą używać funkcji C++. Tylko nazwy symboli są kompatybilne z C, ale kod to C++, co widać po tym, że obie funkcje wywołują przeciążoną funkcję ( printSum ), co nie mogłoby się zdarzyć, gdyby printSumInt lub printSumFloat zostały skompilowane w C.

Zobaczmy teraz print.hpp , plik nagłówkowy, który można dołączyć zarówno z plików źródłowych C, jak i C++, co pozwoli na printSumInt i printSumFloat zarówno z C, jak i z C++, a printSum z 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

Jeśli dołączamy go z pliku źródłowego C, chcemy tylko zobaczyć:

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

printSum nie można zobaczyć z kodu C, ponieważ jego nazwa jest zniekształcona, więc nie mamy (standardowego i przenośnego) sposobu zadeklarowania go dla kodu C. Tak, mogę je zadeklarować jako:

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

I linker nie będzie narzekał, ponieważ jest to dokładna nazwa, którą wymyślił dla niego mój aktualnie zainstalowany kompilator, ale nie wiem, czy zadziała dla twojego linkera (jeśli twój kompilator wygeneruje inną zniekształconą nazwę), ani nawet dla następna wersja mojego linkera. Nie wiem nawet, czy wywołanie będzie działać zgodnie z oczekiwaniami ze względu na istnienie różnych konwencji wywoływania (w jaki sposób są przekazywane parametry i zwracane wartości), które są specyficzne dla kompilatora i mogą być różne dla wywołań C i C++ (zwłaszcza dla funkcji C++ które są funkcjami członkowskimi i otrzymują wskaźnik this jako parametr).

Twój kompilator może potencjalnie użyć jednej konwencji wywoływania dla zwykłych funkcji C++ i innej, jeśli są one zadeklarowane jako posiadające zewnętrzne połączenie „C”. Tak więc oszukiwanie kompilatora, mówiąc, że jedna funkcja używa konwencji wywoływania języka C, podczas gdy w rzeczywistości używa C++, może przynieść nieoczekiwane wyniki, jeśli konwencje używane dla każdej z nich są różne w twoim łańcuchu narzędzi kompilacyjnych.

Istnieją standardowe sposoby łączenia kodu C i C++, a standardowym sposobem wywoływania przeciążonych funkcji C++ z C jest owinięcie ich w funkcje z połączeniem C, tak jak to zrobiliśmy, owijając printSum z printSumInt i printSumFloat .

Jeśli dodamy print.hpp z pliku źródłowego C++, zostanie zdefiniowane makro preprocesora __cplusplus , a plik będzie widziany jako:

 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"

Umożliwi to kodowi C++ wywołanie przeciążonej funkcji printSum lub jej opakowań printSumInt i printSumFloat .

Teraz stwórzmy plik źródłowy C zawierający główną funkcję, która jest punktem wejścia dla programu. Ta główna funkcja C wywoła printSumInt i printSumFloat , to znaczy wywoła obie funkcje C++ z połączeniem C. Pamiętaj, że są to funkcje C++ (ich ciała funkcji wykonują kod C++), które tylko nie mają zniekształconych nazw w C++. Plik nosi nazwę c-main.c :

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

Skompiluj go, aby wygenerować plik obiektowy:

 $ gcc -c c-main.c

I zobacz importowane/eksportowane symbole:

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

Zgodnie z oczekiwaniami eksportuje main i importuje printSumFloat i printSumInt .

Aby połączyć to wszystko razem w plik wykonywalny, musimy użyć konsolidatora C++ (g++), ponieważ przynajmniej jeden plik, który połączymy, print.o , został skompilowany w C++:

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

Wykonanie daje oczekiwany rezultat:

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

Teraz spróbujmy z głównym plikiem C++ o nazwie 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; }

Skompiluj i zobacz importowane/eksportowane symbole pliku obiektowego 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)

Eksportuje główny i importuje link C printSumFloat i printSumInt oraz obie zniekształcone wersje printSum .

Być może zastanawiasz się, dlaczego główny symbol nie jest eksportowany jako zniekształcony symbol, taki jak main(int, char**) z tego źródła C++, ponieważ jest to plik źródłowy C++ i nie jest zdefiniowany jako extern "C" . Cóż, main to specjalna funkcja zdefiniowana w implementacji i wydaje się, że moja implementacja wybrała do tego celu połączenie C, bez względu na to, czy jest zdefiniowana w pliku źródłowym C czy C++.

Powiązanie i uruchomienie programu daje oczekiwany rezultat:

 $ 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

Jak działają osłony nagłówka

Do tej pory uważałem, aby nie dołączyć moich nagłówków dwa razy, bezpośrednio lub pośrednio, z tego samego pliku źródłowego. Ale ponieważ jeden nagłówek może zawierać inne nagłówki, ten sam nagłówek może być pośrednio dołączany wiele razy. A ponieważ treść nagłówka jest właśnie wstawiana w miejscu, z którego została uwzględniona, łatwo jest zakończyć zduplikowanymi deklaracjami.

Zobacz przykładowe pliki w 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

Różnica polega na tym, że w guarded.hpp otaczamy cały nagłówek warunkowo, który zostanie uwzględniony tylko wtedy, gdy nie zdefiniowano makra preprocesora __GUARDED_HPP . Gdy preprocesor po raz pierwszy dołączy ten plik, nie zostanie on zdefiniowany. Ale ponieważ makro jest zdefiniowane wewnątrz tego chronionego kodu, następnym razem, gdy zostanie włączone (z tego samego pliku źródłowego, bezpośrednio lub pośrednio), preprocesor zobaczy wiersze między #ifndef i #endif i odrzuci cały kod między ich.

Zauważ, że ten proces ma miejsce dla każdego pliku źródłowego, który kompilujemy. Oznacza to, że ten plik nagłówkowy może być dołączony raz i tylko raz dla każdego pliku źródłowego. Fakt, że został dołączony z jednego pliku źródłowego, nie uniemożliwia dołączenia go z innego pliku źródłowego podczas kompilowania tego pliku źródłowego. Uniemożliwi to po prostu uwzględnienie go więcej niż raz z tego samego pliku źródłowego.

Przykładowy plik main-guarded.cpp guarded.hpp dwukrotnie plik guarded.hpp:

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

Ale wstępnie przetworzone dane wyjściowe pokazują tylko jedną definicję klasy 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(); }

Dlatego można go bez problemu skompilować:

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

Ale plik main-unguarded.cpp zawiera dwukrotnie plik unguarded.hpp :

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

Wstępnie przetworzone dane wyjściowe pokazują dwie definicje klasy 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(); }

Spowoduje to problemy podczas kompilacji:

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

W pliku dołączonym z 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 { ^

Ze względu na zwięzłość nie będę używał strzeżonych nagłówków w tym artykule, jeśli nie jest to konieczne, ponieważ większość z nich to krótkie przykłady. Ale zawsze chroń swoje pliki nagłówkowe. Nie pliki źródłowe, które nie zostaną dołączone z dowolnego miejsca. Tylko pliki nagłówkowe.

Pomiń wartość i stałość parametrów

Spójrz na plik by-value.cpp w 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); }

Ponieważ używam dyrektywy using namespace std , nie muszę określać nazw symboli (funkcji lub klas) w przestrzeni nazw std w pozostałej części jednostki tłumaczeniowej, która w moim przypadku jest resztą pliku źródłowego. Gdyby to był plik nagłówkowy, nie powinienem był wstawiać tej dyrektywy, ponieważ plik nagłówkowy powinien być dołączany z wielu plików źródłowych; ta dyrektywa wprowadziłaby do globalnego zakresu każdego pliku źródłowego całą przestrzeń nazw std od momentu, w którym zawierają mój nagłówek.

Nawet nagłówki zawarte po moim w tych plikach będą miały te symbole w zakresie. Może to powodować konflikty nazw, ponieważ nie spodziewali się, że tak się stanie. Dlatego nie używaj tej dyrektywy w nagłówkach. Używaj go tylko w plikach źródłowych, jeśli chcesz, i dopiero po dołączeniu wszystkich nagłówków.

Zwróć uwagę, że niektóre parametry są stałe. Oznacza to, że nie można ich zmienić w ciele funkcji, jeśli spróbujemy. Dałoby to błąd kompilacji. Należy również zauważyć, że wszystkie parametry w tym pliku źródłowym są przekazywane przez wartość, a nie przez odwołanie (&) lub przez wskaźnik (*). Oznacza to, że wywołujący utworzy ich kopię i przekaże do funkcji. Tak więc dla wywołującego nie ma znaczenia, czy są one stałe, czy nie, ponieważ jeśli zmodyfikujemy je w treści funkcji, zmodyfikujemy tylko kopię, a nie pierwotną wartość, którą obiekt wywołujący przekazał do funkcji.

Ponieważ stałość parametru, który jest przekazywany przez wartość (kopia) nie ma znaczenia dla wywołującego, nie jest on zniekształcany w sygnaturze funkcji, co widać po skompilowaniu i sprawdzeniu kodu obiektu (tylko odpowiednie wyjście):

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

Sygnatury nie wyrażają, czy skopiowane parametry są const, czy nie w treści funkcji. To nie ma znaczenia. Miało to znaczenie tylko dla definicji funkcji, aby na pierwszy rzut oka pokazać czytelnikowi treści funkcji, czy te wartości kiedykolwiek się zmienią. W tym przykładzie tylko połowa parametrów jest zadeklarowana jako const, więc możemy zobaczyć kontrast, ale jeśli chcemy być const-poprawne, wszystkie powinny zostać zadeklarowane, ponieważ żaden z nich nie jest modyfikowany w ciele funkcji (a nie powinien).

Ponieważ nie ma to znaczenia dla deklaracji funkcji, którą widzi osoba wywołująca, możemy utworzyć nagłówek by-value.hpp następujący sposób:

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

Dodanie kwalifikatorów const tutaj jest dozwolone (możesz nawet zakwalifikować jako zmienne const, które nie są const w definicji i zadziała), ale nie jest to konieczne i sprawi, że deklaracje będą tylko niepotrzebnie gadatliwe.

Przekaż przez odniesienie

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

Stałość przy przekazywaniu przez referencję ma znaczenie dla wywołującego, ponieważ powie on wywołującemu, czy jego argument zostanie zmodyfikowany przez wywoływanego, czy nie. W związku z tym symbole są eksportowane z ich stałością:

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

Powinno to również znaleźć odzwierciedlenie w nagłówku, którego będą używać dzwoniący:

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

Zauważ, że nie zapisałem nazw zmiennych w deklaracjach (w nagłówku), jak to robiłem do tej pory. Jest to również legalne, zarówno w tym przykładzie, jak i w poprzednich. Nazwy zmiennych nie są wymagane w deklaracji, ponieważ osoba wywołująca nie musi wiedzieć, jak chcesz nazwać zmienną. Ale nazwy parametrów są ogólnie pożądane w deklaracjach, aby użytkownik mógł od razu wiedzieć, co oznacza każdy parametr, a zatem, co wysłać w wywołaniu.

Co zaskakujące, nazwy zmiennych również nie są potrzebne w definicji funkcji. Są potrzebne tylko wtedy, gdy faktycznie używasz parametru w funkcji. Ale jeśli nigdy go nie użyjesz, możesz pozostawić parametr z typem, ale bez nazwy. Dlaczego funkcja miałaby deklarować parametr, którego nigdy by nie użyła? Czasami funkcje (lub metody) są po prostu częścią interfejsu, jak interfejs wywołania zwrotnego, który definiuje pewne parametry, które są przekazywane do obserwatora. Obserwator musi utworzyć wywołanie zwrotne ze wszystkimi parametrami określonymi przez interfejs, ponieważ wszystkie zostaną wysłane przez wywołującego. Ale obserwator może nie być zainteresowany nimi wszystkimi, więc zamiast otrzymywać ostrzeżenie kompilatora o „nieużywanym parametrze”, definicja funkcji może po prostu pozostawić ją bez nazwy.

Przejdź przez wskaźnik

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

Aby zadeklarować wskaźnik do elementu const (w przykładzie int) możesz zadeklarować typ jako jeden z:

 int const * const int *

Jeśli chcesz, aby sam wskaźnik był stały, to znaczy, że wskaźnik nie może być zmieniony tak, aby wskazywał na coś innego, dodajesz stałą po gwieździe:

 int const * const const int * const

Jeśli chcesz, aby sam wskaźnik był stały, ale nie wskazywany przez niego element:

 int * const

Porównaj sygnatury funkcji z odszyfrowaną inspekcją pliku obiektowego:

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

Jak widać, narzędzie nm używa pierwszej notacji (const po typie). Należy również zauważyć, że jedyną eksportowaną stałością i ma znaczenie dla wywołującego, jest to, czy funkcja zmodyfikuje element wskazywany przez wskaźnik, czy nie. Stałość samego wskaźnika nie ma znaczenia dla wywołującego, ponieważ sam wskaźnik jest zawsze przekazywany jako kopia. Funkcja może jedynie tworzyć własną kopię wskaźnika, aby wskazywać gdzie indziej, co jest nieistotne dla wywołującego.

Tak więc plik nagłówkowy można utworzyć jako:

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

Przekazywanie przez wskaźnik jest jak przekazywanie przez odniesienie. Jedna różnica polega na tym, że kiedy przechodzisz przez odwołanie, oczekuje się, że wywołujący przekazał prawidłowe odwołanie do elementu, nie wskazując na NULL lub inny nieprawidłowy adres, podczas gdy wskaźnik może wskazywać na NULL. Wskaźniki mogą być używane zamiast referencji, gdy przekazywanie NULL ma specjalne znaczenie.

Ponieważ wartości C++11 można również przekazywać za pomocą semantyki ruchu. Ten temat nie zostanie omówiony w tym artykule, ale można go przestudiować w innych artykułach, takich jak przekazywanie argumentów w C++.

Innym powiązanym tematem, który nie zostanie tutaj omówiony, jest sposób wywoływania wszystkich tych funkcji. Jeśli wszystkie te nagłówki są zawarte w pliku źródłowym, ale nie są wywoływane, kompilacja i łączenie się powiedzie. Ale jeśli chcesz wywołać wszystkie funkcje, pojawią się błędy, ponieważ niektóre wywołania będą niejednoznaczne. Kompilator będzie mógł wybrać więcej niż jedną wersję sumy dla niektórych argumentów, zwłaszcza przy wyborze, czy przekazać przez kopię, czy przez odwołanie (lub stałe odwołanie). Ta analiza jest poza zakresem tego artykułu.

Kompilacja z różnymi flagami

Zobaczmy teraz prawdziwą sytuację związaną z tym tematem, w której mogą pojawić się trudne do znalezienia błędy.

Przejdź do katalogu cpp-article/diff-flags i spójrz na 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; };

Ta klasa ma dwa liczniki, które zaczynają się od zera i mogą być zwiększane lub odczytywane. W przypadku kompilacji debugowania, czyli w ten sposób będę wywoływał kompilacje, w których makro NDEBUG nie jest zdefiniowane, dodaję również trzeci licznik, który będzie zwiększany za każdym razem, gdy którykolwiek z pozostałych dwóch liczników jest zwiększany. Będzie to rodzaj pomocy debugowania dla tej klasy. Wiele klas bibliotek innych firm lub nawet wbudowanych nagłówków C++ (w zależności od kompilatora) używa takich sztuczek, aby umożliwić różne poziomy debugowania. Dzięki temu kompilacje debugowania mogą wykrywać iteratory wykraczające poza zakres i inne interesujące rzeczy, o których może pomyśleć twórca biblioteki. Nazwę kompilacje wydania „kompilacjami, w których zdefiniowane jest makro NDEBUG ”.

W przypadku kompilacji wydań prekompilowany nagłówek wygląda tak (ja używam grep do usuwania pustych linii):

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

Podczas gdy w przypadku kompilacji debugowania będzie to wyglądać tak:

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

Jak wyjaśniłem wcześniej, w kompilacjach debugowania jest jeszcze jeden licznik.

Stworzyłem też kilka plików pomocniczych.

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

Oraz Makefile , który może dostosować flagi kompilatora tylko dla increment2.cpp :

 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

Więc skompilujmy to wszystko w trybie debugowania, bez definiowania 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

Teraz uruchom:

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

Wynik jest zgodny z oczekiwaniami. Teraz skompilujmy tylko jeden z plików ze zdefiniowanym NDEBUG , który byłby trybem wydania, i zobaczmy, co się stanie:

 $ 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

Dane wyjściowe nie są zgodne z oczekiwaniami. 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.


Dalsza lektura na blogu Toptal Engineering:

  • Jak nauczyć się języków C i C++: ostateczna lista
  • C# vs. C++: co jest rdzeniem?