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++:核心是什么?