英文原文:
https://www.jacksondunstan.com/articles/5553
今天的文章继续这个系列,介绍了 C++ 的构建模型,它与 C# 有很大的不同。我们将进入预处理、编译、链接、头文件、单一定义规则以及我们的源代码如何构建到可执行文件中的许多其他方面。
编译和链接
使用 C#,我们将所有源代码文件 (.cs) 编译为程序集,例如可执行文件 (.exe) 或库 (.dll)。
使用 C++,我们将所有翻译单元(.cpp、.cxx、.cc、.C 或 .c++ 的源代码文件)编译为目标文件(.obj 或 .o),然后将它们链接到可执行文件(app .exe 或 app)、静态库(.lib 或 .a)或动态库(.dll 或 .so)。
如果任何源代码文件发生更改,我们将重新编译它们以生成新的目标文件,然后也使用所有未更改的目标文件运行链接器。
这个模型提出了几个问题。首先,什么是目标文件?这被称为“中间”文件,因为它既不是源代码也不是可执行文件之类的输出文件。 C++ 语言标准没有说明这个文件的格式是什么。实际上,它是一个二进制文件,特定于配置了特定设置的特定编译器的特定版本。如果编译器、版本或设置发生变化,则需要重新构建所有代码。
第二,静态库和动态库的区别是什么?动态库与C#中的动态库非常相似。它是一个机器代码库,就像一个可执行文件。然而,它可以在运行时被可执行文件或其他动态库加载和卸载。另一方面,静态库只能在编译时加载,永远不能被卸载。这样一来,它的功能更像是另一个对象文件。
因为静态库在构建时可用,链接器将它们直接构建到生成的可执行文件中。这意味着无需将单独的动态库文件分发给最终用户,无需从文件系统单独打开它,也无需通过设置 LD_LIBRARY_PATH 环境变量来覆盖其位置。
对于性能来说,所有对静态库中的函数的调用都只是普通的函数调用。这意味着没有通过运行时设置的指针,当动态库被加载时,就不会有间接的影响。这也意味着链接器可以进行 “链接时间优化”,如内联这些函数。
主要的缺点是需要静态库在编译时就已经存在。这使得它们不适合于加载用户创建的插件等任务。也许对于大型项目来说,最重要的是,它们必须在每次构建中被链接,即使只有一个小的源文件被改变。链接时间按比例增长,会阻碍快速迭代。因此,有时动态库会被用于开发构建,而静态库会被用于发布构建。
在本系列中,我们不会讨论如何运行编译器和链接器的细节。这在很大程度上取决于所使用的特定编译器、操作系统和游戏引擎。通常游戏引擎或主机供应商会为此提供文档。同样典型的是使用像 Microsoft Visual Studio 或 Xcode 这样的 IDE,它提供了一个“项目”抽象来管理源代码文件、编译器设置等。
头文件和预处理器
在 C# 中,我们添加 using 指令来引用其他文件中的代码。 C++ 在 C++20 中添加了一个类似的“模块”系统,我们将在本系列的后续文章中介绍。现在,我们将假装它不存在,只讨论 C++ 传统的构建方式。
到目前为止,头文件(.h、.hpp、.hxx、.hh、.H、.h++ 或无扩展名)是一个文件中的代码引用另一个文件中的代码的最常见方式。这些只是 C++ 源代码文件,旨在复制并粘贴到另一个 C++ 源代码文件中。复制和粘贴操作由预处理器执行。
就像在 C# 中一样,像 #if 这样的预处理器指令在编译的主要阶段之前被评估。没有必须调用单独的预处理器可执行文件来生成编译器接收的中间文件。预处理只是编译器的一个早期步骤。
C++ 使用名为#include 的预处理器指令将头文件的内容复制并粘贴到另一个头文件 (.h) 或转换单元 (.cpp) 中。这是它的样子:
// math.h
int Add(int a, int b);
// math.cpp
#include "math.h"
int Add(int a, int b)
{
return a + b;
}
#include "math.h "告诉预处理器在math.cpp所在的目录中搜索名为math.h的文件。如果找到这样的文件,它就会读取其内容,并用其替换#include指令。否则,它会搜索它所配置的 “包含路径”。C++标准库被隐含地搜索了。如果在这些位置中没有找到math.h,编译器会产生一个错误。
之后,math.cpp 看起来像这样:
int Add(int a, int b);
int Add(int a, int b)
{
return a + b;
}
回想一下上周的文章,第一个 Add 是函数声明,第二个是函数定义。由于签名匹配,编译器知道我们定义了前面的声明。
到目前为止,我们已经将声明和定义拆分为两个文件,但没有太多好处。现在让我们通过添加另一个转换单元来获得回报:
// user.cpp
#include "math.h"
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
这显示了 user.cpp 如何添加相同的 #include “math.h” 来访问 Add 的声明,结果如下:
int Add(int a, int b);
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
现在,编译器会遇到 Add 的声明,并同意 AddThree 调用它,尽管还没有 Add 的定义。它只是在它输出的对象文件(user.obj)中注明Add是一个未满足的依赖关系。
当链接器执行时,它会读入 user.obj 和 math.obj。 math.obj 包含 Add 的定义,user.obj 包含 AddThree 的定义。此时,链接器确实需要 Add 的定义,因此它使用在 math.obj 中找到的那个。
有一个常见的#include 替代版本:
#include <math.h>
此版本旨在仅搜索 C++ 标准库和编译器提供的其他头文件。例如,Microsoft Visual Studio 允许 #include <windows.h> 进行 Windows 操作系统调用。这对于消除应用程序代码库中和编译器提供的文件名的歧义很有用。想象一下这个程序:
#include "math.h"
bool IsNearlyZero(float val)
{
return fabsf(val) < 0.000001f;
}
fabsf 是 C 标准库中的一个函数,用于获取浮点数的绝对值。当预处理器使用 #include 的引号版本运行时,它会找到我们的 math.h,所以我们得到这个:
int Add(int a, int b);
bool IsNearlyZero(float val)
{
return fabsf(val) < 0.000001f;
}
然后编译器找不到 fabsf 所以它出错了。相反,我们应该使用尖括号版本的#include,因为我们正在寻找编译器提供的 math.h:
#include <math.h>
bool IsNearlyZero(float val)
{
return fabsf(val) < 0.000001f;
}
这产生了我们想要的:
float fabsf(float arg);
// ...and many, many more math function declarations...
bool IsNearlyZero(float val)
{
return fabsf(val) < 0.000001f;
}
另请注意,我们可以在#include 中指定与目录结构相对应的路径:
#include "utils/math.h"
#include <nlohmann/json.hpp>
最后,虽然它很深奥并且通常最好避免,但没有什么能阻止我们使用#include 来拉入非头文件。只要结果是合法的 C++,我们就可以#include 任何文件。有时#include 甚至被放置在函数的中间以填充它的部分主体!
ODR 和包含保护
C++ 有它所谓的“单一定义规则”,通常缩写为 ODR。这表示翻译单元中可能只有一个定义。这包括变量和函数,随着代码库的增长,这给我们带来了一些问题。想象一下,我们扩展了我们的数学库并在其上添加了一个向量数学库:
// math.h
int Add(int a, int b);
float PI = 3.14f;
// vector.h
#include "math.h"
float Dot(float aX, float aY, float bX, float bY);
// user.cpp
#include "math.h"
#include "vector.h"
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
这里我们有vector.h,使用#include 来拉入math.h。我们还有 user.cpp 使用 #include 来拉入 vector.h 和 math.h。这是一个很好的做法,因为它避免了对 math.h 的隐式依赖,如果将 vector.h 更改为删除 #include “math.h”,这种依赖会中断。尽管如此,我们将看到这会带来一个问题。让我们在预处理器替换 #include “math.h” 指令后查看 user.cpp:
int Add(int a, int b);
float PI = 3.14f;
#include "vector.h"
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
现在编译器替换了#include “vector.h”:
int Add(int a, int b);
float PI = 3.14f;
#include "math.h"
float Dot(float aX, float aY, float bX, float bY);
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
最后,它从它复制的vector.h的内容中替换#include“math.h”:
int Add(int a, int b);
float PI = 3.14f;
int Add(int a, int b);
float PI = 3.14f;
float Dot(float aX, float aY, float bX, float bY);
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
Add 函数的多个声明是可以的,因为它们不是定义,因此它们不会违反 ODR。编译器只是忽略重复的声明。
另一方面,PI 的定义无疑是一个定义。具有相同变量名的两个定义违反了 ODR,我们得到一个编译器错误。
为了解决这个问题,我们在头文件中添加了所谓的“包含保护”。这可以采用两种基本形式,但都使用预处理器。这是 math.h 中的第一种形式:
#if (!defined MATH_H)
#define MATH_H
int Add(int a, int b);
float PI = 3.14f;
#endif
这利用了#if、#define 和#endif 指令,这些指令类似于它们的C# 对应指令。在这种情况下,唯一真正的区别是在 C++ 中使用 !defined MATH_H 而不是在 C# 中仅使用 !MATH_H。
一种变体是使用仅限 C++ 的 #ifndef MATH_H 作为 #if (!defined MATH_H) 的一种简写:
#ifndef MATH_H
#define MATH_H
int Add(int a, int b);
float PI = 3.14f;
#endif
在任何一种情况下,我们都选择一个命名约定并将我们的文件名应用到它以生成文件的唯一标识符。有许多流行的形式,包括这些:
math_h
MATH_H
MATH_H_
MYGAME_MATH_H
为了避免需要提供唯一的名称,所有常见的编译器都提供了非标准的 #pragma once 指令:
#pragma once
int Add(int a, int b);
float PI = 3.14f;
无论选择何种形式,让我们看看这如何有助于避免违反 ODR。下面是 user.cpp 在所有 #include 指令被解析后的样子:(为清楚起见添加了缩进)
#ifndef MATH_H
#define MATH_H
int Add(int a, int b);
float PI = 3.14f;
#endif
#ifndef VECTOR_H
#define VECTOR_H
#ifndef MATH_H
#define MATH_H
int Add(int a, int b);
float PI = 3.14f;
#endif
float Dot(float aX, float aY, float bX, float bY);
#endif
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
在第一行 (#ifndef MATH_H),预处理器发现 MATH_H 未定义,因此它将所有代码保留到 #endif。这包括一个#define MATH_H,所以现在它被定义了。
同样,#ifndef VECTOR_H 成功并允许定义 VECTOR_H。但是,嵌套的#ifndef MATH_H 失败,因为现在定义了 MATH_H。在匹配的#endif 被删除之前的所有内容。
最后,我们得到了这样的结果:
int Add(int a, int b);
float PI = 3.14f;
float Dot(float aX, float aY, float bX, float bY);
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
PI 的重复定义已被包含保护有效地从转换单元中删除,因此我们不再收到 ODR 违规的编译器错误。
排队
即使修复了 ODR 编译器错误,我们仍然有一个问题:链接器错误。原因是vector.cpp转换单元还包含PI 的副本。这是它最初的样子:
#include "vector.h"
float Dot(float aX, float aY, float bX, float bY)
{
return Add(aX*bX, aY+bY);
}
这是在预处理器解析 #include 指令之后:
#ifndef VECTOR_H
#define VECTOR_H
#ifndef MATH_H
#define MATH_H
int Add(int a, int b);
float PI = 3.14f;
#endif
float Dot(float aX, float aY, float bX, float bY);
#endif
float Dot(float aX, float aY, float bX, float bY)
{
return Add(aX*bX, aY+bY);
}
请记住,每个转换单元都是单独编译的。在这个转换单元中,MATH_H 和 VECTOR_H 没有像在 user.cpp 转换单元中那样使用#define 设置。因此,两个包含防护措施都成功了,我们得到这个结果:
int Add(int a, int b);
float PI = 3.14f;
float Dot(float aX, float aY, float bX, float bY);
float Dot(float aX, float aY, float bX, float bY)
{
return Add(aX*bX, aY+bY);
}
这对于编译这个转换单元来说非常有用,因为没有重复的定义违反 ODR。编译会成功,但链接会失败。
链接器错误的原因是,默认情况下,我们也不能在链接时有重复的 PI 定义。如果我们想这样做,我们需要在 PI 中添加 inline 关键字来告诉编译器应该允许多个定义。这将产生这些转换单元:
// user.cpp
int Add(int a, int b);
inline float PI = 3.14f;
float Dot(float aX, float aY, float bX, float bY);
int AddThree(int a, int b, int c)
{
return Add(a, Add(b, c));
}
bool IsOrthogonal(float aX, float aY, float bX, float bY)
{
return Dot(aX, aY, bX, bY) == 0.0f;
}
// vector.cpp
int Add(int a, int b);
inline float PI = 3.14f;
float Dot(float aX, float aY, float bX, float bY);
float Dot(float aX, float aY, float bX, float bY)
{
return Add(aX*bX, aY+bY);
}
inline 是应用于变量的关键字,这似乎很奇怪。这样做的历史原因是它最初是对编译器的提示,它应该内联函数,但是,就像 register 关键字一样,这是非绑定的,几乎总是被忽略。它的意思是“允许多个定义”,因此它现在可以应用于变量和函数。
例如,我们可以在 math.h 中添加一个函数定义,只要它是内联的:
inline int Sub(int a, int b)
{
return a - b;
}
这通常是可以避免的,因为对函数的任何更改都需要直接或间接重新编译包含它的所有转换单元,这在大型代码库中可能需要相当长的时间。
Linkage
最后,今天,C++ 有了“Linkage”的概念。默认情况下,像 PI 这样的变量具有外部链接。这意味着它可以被其他转换单元引用。例如,假设我们向 math.cpp 添加了一个变量:
float SQRT2 = 1.4f;
现在假设我们要从 user.cpp 中引用它。 #include “math.h” 不起作用,因为 SQRT2 在 math.cpp 中,而不是 math.h 中。我们仍然可以使用 extern 关键字来引用它:
extern float SQRT2;
float GetDiagonalOfSquare(float widthOrHeight)
{
return SQRT2 * widthOrHeight;
}
这类似于函数声明,因为我们告诉编译器信任我们并假装存在名为 SQRT2 的浮点数。因此,当它编译 user.cpp 时,它会在 user.obj 对象文件中记录我们尚未满足 SQRT2 的依赖关系。当编译器编译 math.cpp 时,它会注意到有一个名为 SQRT2 的浮点数可用于链接。
稍后,链接器运行并读取 user.obj 以及包括 math.obj 在内的所有其他目标文件。在处理 user.obj 时,它从编译器中读取该注释,指出缺少 SQRT2 的定义,它会查看其他目标文件以找到它。瞧,它在 math.obj 中找到一个注释,指出有一个名为 SQRT2 的浮点数,因此链接器使 GetDiagonalOfSquare 引用该变量。
快速说明:extern 关键字也可以在 math.cpp 中应用,但这没有效果,因为外部链接是默认的。不过,它的外观是这样的:
extern float SQRT2 = 1.4f;
防止这种行为的一种方法是将 static 关键字添加到 SQRT2。这会将链接更改为“内部”,并阻止编译器将该注释添加到 math.obj 以说明名为 SQRT2 的浮点变量可用于链接。
static float SQRT2 = 1.4f;
现在,如果我们尝试链接 user.obj 和 math.obj,链接器在任何目标文件中都找不到任何可用的 SQRT2 定义,因此会产生错误。
extern 和 static 都可以与函数一起使用。例如:
// math.cpp
int Sub(int a, int b)
{
return a - b;
}
static int Mul(int a, int b)
{
return a * b;
}
// user.cpp
extern int Sub(int a, int b);
int SubThree(int a, int b, int c)
{
return Sub(Sub(a, b), c);
}
extern int Mul(int a, int b); // compiler error: Mul is `static`
总结
今天我们已经看到了 C++ 构建源代码的非常不同的方法。与头文件相结合的“编译然后链接”方法对 ODR、链接和包含防护具有多米诺骨牌效应。我们将进入 C++20 的模块系统,它解决了很多这些问题,并在本系列的后面部分产生了一个更像 C# 的构建模型,但头文件仍然与模块非常相关。关于 ODR 和链接还有更多细节需要介绍,但随着我们介绍更多语言概念(如模板和线程局部变量),我们将逐步介绍这些内容。