C++ 是如何工作的:理解編譯
已發表: 2022-03-11Bjarne Stroustrup 的C++ 編程語言有一章標題為“C++ 之旅:基礎知識”——標準 C++。 那一章,在 2.2 中,用半頁的篇幅提到了 C++ 中的編譯和鏈接過程。 編譯和鏈接是 C++ 軟件開發過程中一直發生的兩個非常基本的過程,但奇怪的是,許多 C++ 開發人員並沒有很好地理解它們。
為什麼 C++ 源代碼分為頭文件和源文件? 編譯器如何看待每個部分? 這對編譯和鏈接有何影響? 您可能已經考慮過更多類似的問題,但已經接受了慣例。
無論您是在設計 C++ 應用程序、為其實現新功能、嘗試解決錯誤(尤其是某些奇怪的錯誤),還是嘗試使 C 和 C++ 代碼一起工作,了解編譯和鏈接的工作方式都將為您節省大量時間和使這些任務更加愉快。 在本文中,您將準確了解這一點。
本文將解釋 C++ 編譯器如何與一些基本語言結構一起工作,回答一些與其過程相關的常見問題,並幫助您解決開發人員在 C++ 開發中經常犯的一些相關錯誤。
注意:本文有一些示例源代碼,可以從 https://bitbucket.org/danielmunoz/cpp-article 下載
這些示例是在 CentOS Linux 機器上編譯的:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
使用 g++ 版本:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
提供的源文件應該可以移植到其他操作系統,儘管伴隨它們用於自動構建過程的 Makefile 應該只能移植到類 Unix 系統。
構建管道:預處理、編譯和鏈接
每個 C++ 源文件都需要編譯成一個目標文件。 然後將多個源文件編譯產生的目標文件鏈接到可執行文件、共享庫或靜態庫(最後一個只是目標文件的存檔)。 C++ 源文件通常具有 .cpp、.cxx 或 .cc 擴展後綴。
C++ 源文件可以使用#include
指令包含其他文件,稱為頭文件。 頭文件具有 .h、.hpp 或 .hxx 之類的擴展名,或者像 C++ 標準庫和其他庫的頭文件(如 Qt)中那樣根本沒有擴展名。 擴展名對 C++ 預處理器無關緊要,它會將包含#include
指令的行替換為包含文件的全部內容。
編譯器對源文件執行的第一步是在其上運行預處理器。 只有源文件被傳遞給編譯器(預處理和編譯它)。 頭文件不會傳遞給編譯器。 相反,它們包含在源文件中。
每個頭文件在所有源文件的預處理階段都可以多次打開,這取決於有多少源文件包含它們,或者源文件中包含的其他頭文件有多少也包含它們(可以有多個間接級別) . 另一方面,當源文件被傳遞給它時,編譯器(和預處理器)只打開一次。
對於每個 C++ 源文件,預處理器將在找到#include 指令時通過在其中插入內容來構建一個翻譯單元,同時在找到條件編譯時它將從源文件和標頭中剝離代碼指令計算結果為false
的塊。 它還將執行一些其他任務,例如宏替換。
一旦預處理器完成創建(有時是巨大的)翻譯單元,編譯器就會開始編譯階段並生成目標文件。
要獲得該翻譯單元(預處理的源代碼),可以將-E
選項與-o
選項一起傳遞給 g++ 編譯器,以指定所需的預處理源文件的名稱。
在cpp-article/hello-world
目錄下,有一個“hello-world.cpp”示例文件:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
通過以下方式創建預處理文件:
$ g++ -E hello-world.cpp -o hello-world.ii
並查看行數:
$ wc -l hello-world.ii 17558 hello-world.ii
我的機器上有 17,588 行。 您也可以只在該目錄上運行make
,它會為您完成這些步驟。
我們可以看到編譯器必須編譯一個比我們看到的簡單源文件大得多的文件。 這是因為包含的標題。 在我們的示例中,我們只包含了一個標題。 隨著我們不斷包含標題,翻譯單元變得越來越大。
這種預處理和編譯過程與 C 語言類似。 它遵循 C 編譯規則,包含頭文件和生成目標代碼的方式幾乎相同。
源文件如何導入和導出符號
現在讓我們看看cpp-article/symbols/c-vs-cpp-names
目錄中的文件。
有一個名為 sum.c 的簡單 C(不是 C++)源文件,它導出兩個函數,一個用於添加兩個整數,一個用於添加兩個浮點數:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
編譯它(或運行make
和創建要執行的兩個示例應用程序的所有步驟)以創建 sum.o 目標文件:
$ gcc -c sum.c
現在查看此目標文件導出和導入的符號:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
不導入符號,導出兩個符號: sumF
和sumI
。 這些符號作為 .text 段 (T) 的一部分導出,因此它們是函數名稱、可執行代碼。
如果其他(C 或 C++)源文件想要調用這些函數,則需要在調用之前聲明它們。
執行此操作的標準方法是創建一個頭文件,該文件聲明它們並將它們包含在我們想要調用它們的任何源文件中。 標頭可以具有任何名稱和擴展名。 我選擇了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
那些ifdef
/ endif
條件編譯塊是什麼? 如果我從 C 源文件中包含這個頭文件,我希望它變成:
int sumI(int a, int b); float sumF(float a, float b);
但是如果我從 C++ 源文件中包含它們,我希望它變成:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
C 語言對extern "C"
指令一無所知,但 C++ 知道,它需要將此指令應用於 C 函數聲明。 這是因為 C++ 會破壞函數(和方法)名稱,因為它支持函數/方法重載,而 C 不支持。
這可以在名為 print.cpp 的 C++ 源文件中看到:
#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); }
有兩個同名的函數 ( printSum
),它們的參數類型不同: int
或float
。 函數重載是 C++ 中不存在的 C++ 特性。為了實現這個特性並區分這些函數,C++ 修改了函數名,正如我們在導出的符號名中看到的那樣(我只會從 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
這些函數(在我的系統中)導出為浮點版本的_Z8printSumff
和 int 版本的_Z8printSumii
。 除非聲明為extern "C"
,否則 C++ 中的每個函數名稱都會被破壞。 在print.cpp
中使用 C 鏈接聲明了兩個函數: printSumInt
和printSumFloat
。
因此,它們不能被重載,或者它們的導出名稱將是相同的,因為它們沒有被破壞。 我必須通過在它們名稱的末尾添加一個 Int 或 Float 來區分它們。
因為它們沒有被破壞,所以可以從 C 代碼中調用它們,我們很快就會看到。
要像我們在 C++ 源代碼中看到的那樣查看損壞的名稱,我們可以在nm
命令中使用-C
(demangle) 選項。 同樣,我只會復制輸出的相同相關部分:
$ 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
使用此選項,我們看到的printSum(float, float)
而不是_Z8printSumff
,我們看到的是 std::cout 而不是_ZSt4cout
,這是更人性化的名稱。
我們還看到我們的 C++ 代碼正在調用 C 代碼: print.cpp
正在調用sumI
和sumF
,它們是在sum.h
中聲明為具有 C 鏈接的 C 函數。 這可以在上面 print.o 的 nm 輸出中看到,它通知了一些未定義的 (U) 符號: sumF
、 sumI
和std::cout
。 那些未定義的符號應該在鏈接階段將與此目標文件輸出鏈接在一起的目標文件(或庫)之一中提供。
到目前為止,我們剛剛將源代碼編譯成目標代碼,我們還沒有鏈接。 如果我們不將包含這些導入符號的定義的目標文件與該目標文件鏈接在一起,則鏈接器將停止並出現“缺少符號”錯誤。
另請注意,由於print.cpp
是 C++ 源文件,使用 C++ 編譯器 (g++) 編譯,因此其中的所有代碼都編譯為 C++ 代碼。 具有 C 鏈接的函數(如printSumInt
和printSumFloat
)也是可以使用 C++ 特性的 C++ 函數。 只有符號的名稱與 C 兼容,但代碼是 C++,這可以從兩個函數都調用重載函數 ( printSum
) 的事實看出,如果printSumInt
或printSumFloat
是用 C 編譯的,則不會發生這種情況。
現在讓我們看看print.hpp
,一個可以從 C 或 C++ 源文件中包含的頭文件,這將允許從 C 和 C++ 調用printSumInt
和printSumFloat
,並從 C++ 調用printSum
:
#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
如果我們從 C 源文件中包含它,我們只想看到:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
不能從 C 代碼中看到,因為它的名稱被破壞了,所以我們沒有(標準和可移植的)方法來為 C 代碼聲明它。 是的,我可以將它們聲明為:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
並且鏈接器不會抱怨,因為這是我當前安裝的編譯器為其發明的確切名稱,但我不知道它是否適用於您的鏈接器(如果您的編譯器生成不同的損壞名稱),甚至適用於我的鏈接器的下一個版本。 我什至不知道調用是否會按預期工作,因為存在編譯器特定的不同調用約定(如何傳遞參數和返回值),並且對於 C 和 C++ 調用可能不同(尤其是對於 C++ 函數它們是成員函數並接收 this 指針作為參數)。
您的編譯器可能對常規 C++ 函數使用一種調用約定,如果它們被聲明為具有外部“C”鏈接,則可以使用另一種調用約定。 因此,通過說一個函數使用 C 調用約定而實際上使用 C++ 來欺騙編譯器,因為如果用於每個函數的約定在您的編譯工具鏈中碰巧不同,則它可能會產生意想不到的結果。
有混合 C 和 C++ 代碼的標準方法,從 C 調用 C++ 重載函數的標準方法是將它們包裝在具有 C 鏈接的函數中,就像我們通過使用printSumInt
和printSumFloat
包裝printSum
所做的那樣。
如果我們從 C++ 源文件中包含print.hpp
,則將定義__cplusplus
預處理器宏,並且該文件將被視為:
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"
這將允許 C++ 代碼調用重載函數 printSum 或其包裝器printSumInt
和printSumFloat
。
現在讓我們創建一個包含 main 函數的 C 源文件,它是程序的入口點。 此 C 主函數將調用printSumInt
和printSumFloat
,也就是說,將調用具有 C 鏈接的 C++ 函數。 請記住,這些是 C++ 函數(它們的函數體執行 C++ 代碼),只是沒有 C++ 錯位名稱。 該文件名為c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
編譯它以生成目標文件:
$ gcc -c c-main.c
並查看導入/導出的符號:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
正如預期的那樣,它導出 main 並導入printSumFloat
和printSumInt
。
要將它們全部鏈接成一個可執行文件,我們需要使用 C++ 鏈接器 (g++),因為我們要鏈接的至少一個文件print.o
是用 C++ 編譯的:
$ g++ -o c-app sum.o print.o c-main.o
執行產生預期的結果:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
現在讓我們嘗試一個名為cpp-main.cpp
的 C++ 主文件:
#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; }
編譯並查看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)
它導出 main 並導入 C 鏈接printSumFloat
和printSumInt
,以及printSum
的兩個錯位版本。
您可能想知道為什麼 main 符號沒有像main(int, char**)
那樣從這個 C++ 源代碼中導出為一個損壞的符號,因為它是一個 C++ 源文件並且它沒有被定義為extern "C"
。 好吧, main
是一個特殊的實現定義函數,無論它是在 C 還是 C++ 源文件中定義的,我的實現似乎都選擇使用 C 鏈接。
鏈接並運行程序給出了預期的結果:
$ 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
Header Guards 的工作原理
到目前為止,我一直小心不要從同一個源文件中直接或間接地兩次包含我的標題。 但是由於一個標頭可以包含其他標頭,因此同一標頭可以間接包含多次。 而且由於標題內容只是插入到包含它的位置,因此很容易以重複聲明結束。
請參閱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
不同之處在於,在guarded.hpp 中,我們將整個標頭包含在一個條件中,只有在__GUARDED_HPP
預處理器宏時才會包含該條件。 預處理器第一次包含這個文件時,它不會被定義。 但是,由於宏是在受保護的代碼中定義的,因此下次包含它時(直接或間接來自同一個源文件),預處理器將看到 #ifndef 和 #endif 之間的行,並將丟棄之間的所有代碼他們。
請注意,我們編譯的每個源文件都會發生此過程。 這意味著這個頭文件可以被包含一次,並且每個源文件只能包含一次。 它被包含在一個源文件中這一事實不會阻止它在編譯該源文件時被包含在另一個源文件中。 它只會阻止它從同一個源文件中多次包含。
示例文件main-guarded.cpp
包含兩次guarded.hpp
:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
但是預處理後的輸出只顯示了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(); }
因此,它可以毫無問題地編譯:
$ g++ -o guarded main-guarded.cpp
但是main-unguarded.cpp
文件包含unguarded.hpp
兩次:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
預處理後的輸出顯示了 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(); }
這會在編譯時出現問題:
$ g++ -o unguarded main-unguarded.cpp
在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 { ^
為簡潔起見,如果沒有必要,我不會在本文中使用受保護的標頭,因為大多數都是簡短的示例。 但始終保護你的頭文件。 不是您的源文件,它不會從任何地方包含在內。 只是頭文件。
按值傳遞和參數的常量
查看cpp-article/symbols/pass-by
中的by-value.cpp
文件:
#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); }
由於我使用using namespace std
指令,因此我不必在翻譯單元的其餘部分(在我的例子中是源文件的其餘部分)中限定 std 命名空間內的符號(函數或類)的名稱。 如果這是一個頭文件,我不應該插入這個指令,因為一個頭文件應該包含在多個源文件中; 該指令將從包含我的標頭的點開始將整個 std 命名空間帶入每個源文件的全局範圍。

甚至那些文件中包含在我之後的標題也會在範圍內包含這些符號。 這可能會產生名稱衝突,因為他們沒想到會發生這種情況。 因此,請勿在標頭中使用此指令。 如果需要,僅在源文件中使用它,並且僅在包含所有標題之後使用。
注意一些參數是 const 的。 這意味著如果我們嘗試,它們不能在函數體中更改。 它會給出一個編譯錯誤。 另外,請注意,此源文件中的所有參數都是按值傳遞的,而不是按引用 (&) 或指針 (*) 傳遞的。 這意味著調用者將復制它們並傳遞給函數。 因此,調用者是否為 const 並不重要,因為如果我們在函數體中修改它們,我們只會修改副本,而不是調用者傳遞給函數的原始值。
由於通過值(副本)傳遞的參數的常量性對調用者無關緊要,因此它不會在函數簽名中受到破壞,因為它可以在編譯和檢查目標代碼後看到(僅相關輸出):
$ 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> >)
簽名不表示複製的參數在函數體中是否為 const。 沒關係。 它只對函數定義很重要,向函數體的讀者展示這些值是否會改變。 在這個例子中,只有一半的參數被聲明為 const,所以我們可以看到對比,但如果我們想要 const 正確,它們都應該被聲明為 so,因為它們都沒有在函數體中被修改(而且它們不應該)。
由於調用者看到的函數聲明並不重要,我們可以像這樣創建by-value.hpp
標頭:
#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);
允許在此處添加 const 限定符(您甚至可以將其限定為定義中不是 const 的 const 變量並且它會起作用),但這不是必需的,它只會使聲明變得不必要地冗長。
通過引用傳遞
讓我們看看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); }
通過引用傳遞時的常量對調用者很重要,因為它會告訴調用者它的參數是否會被被調用者修改。 因此,符號以其常量導出:
$ 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&)
這也應該反映在調用者將使用的標題中:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
請注意,到目前為止,我沒有在聲明中(在標題中)寫變量的名稱。 這也是合法的,對於這個例子和前面的例子。 聲明中不需要變量名,因為調用者不需要知道你想如何命名你的變量。 但是參數名稱通常在聲明中是可取的,這樣用戶就可以一目了然地知道每個參數的含義,從而知道在調用中發送什麼。
令人驚訝的是,函數定義中也不需要變量名。 僅當您實際使用函數中的參數時才需要它們。 但是,如果您從不使用它,您可以將參數保留為類型但不保留名稱。 為什麼函數要聲明一個它永遠不會使用的參數? 有時函數(或方法)只是接口的一部分,比如回調接口,它定義了傳遞給觀察者的某些參數。 觀察者必須使用接口指定的所有參數創建一個回調,因為它們都將由調用者發送。 但是觀察者可能對所有這些都不感興趣,因此函數定義可以不給它命名,而不是收到關於“未使用參數”的編譯器警告。
通過指針
// 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); }
要聲明指向 const 元素(示例中為 int)的指針,您可以將類型聲明為:
int const * const int *
如果您還希望指針本身為 const,即不能將指針更改為指向其他內容,則在星號後添加一個 const:
int const * const const int * const
如果您希望指針本身是 const,而不是它所指向的元素:
int * const
將函數簽名與目標文件的去錯檢查進行比較:
$ 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*)
如您所見, nm
工具使用第一個表示法(類型後的 const)。 另外,請注意,導出的唯一常量,對調用者很重要,是函數是否會修改指針指向的元素。 指針本身的常量性與調用者無關,因為指針本身總是作為副本傳遞。 該函數只能製作自己的指針副本以指向其他地方,這與調用者無關。
因此,可以將頭文件創建為:
#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);
通過指針傳遞就像通過引用傳遞一樣。 一個區別是,當您通過引用傳遞時,調用者應該並假定傳遞了一個有效元素的引用,而不是指向 NULL 或其他無效地址,而指針可以指向 NULL 例如。 當傳遞 NULL 具有特殊含義時,可以使用指針代替引用。
由於 C++11 值也可以通過移動語義傳遞。 此主題不會在本文中討論,但可以在其他文章中研究,例如 C++ 中的參數傳遞。
這裡不會涉及的另一個相關主題是如何調用所有這些函數。 如果所有這些頭文件都包含在源文件中但未被調用,則編譯和鏈接將成功。 但是如果你想調用所有的函數,就會出現一些錯誤,因為有些調用會模棱兩可。 編譯器將能夠為某些參數選擇多個版本的 sum,尤其是在選擇是通過複製還是通過引用(或 const 引用)傳遞時。 該分析超出了本文的範圍。
使用不同的標誌編譯
現在,讓我們看看與該主題相關的真實情況,其中可能會出現難以發現的錯誤。
轉到目錄cpp-article/diff-flags
並查看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; };
這個類有兩個計數器,它們從零開始,可以遞增或讀取。 對於調試構建,這就是我將調用未定義NDEBUG
宏的構建的方式,我還添加了第三個計數器,每當其他兩個計數器中的任何一個遞增時,它都會遞增。 這將是此類的一種調試助手。 許多第三方庫類甚至內置的 C++ 頭文件(取決於編譯器)使用這樣的技巧來允許不同級別的調試。 這允許調試構建來檢測超出範圍的迭代器以及庫創建者可以考慮的其他有趣的事情。 我將發布版本稱為“定義了NDEBUG
宏的版本”。
對於發布版本,預編譯的標頭看起來像(我使用grep
刪除空行):
$ 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; };
對於調試版本,它看起來像:
$ 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; };
正如我之前解釋的,在調試版本中還有一個計數器。
我還創建了一些幫助文件。
// 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; }
還有一個可以為increment2.cpp
自定義編譯器標誌的Makefile
:
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
所以,讓我們在調試模式下編譯它,而不定義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
現在運行:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
輸出與預期一樣。 現在讓我們只編譯一個定義了NDEBUG
的文件,這將是發布模式,看看會發生什麼:
$ 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
輸出不如預期。 increment1
函數看到了 Counters 類的發布版本,其中只有兩個 int 成員字段。 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.
進一步閱讀 Toptal 工程博客:
- 如何學習 C 和 C++ 語言:終極清單
- C# 與 C++:核心是什麼?