跳转至

编译链接和宏本质

🌏 Bilibili视频传送门: C++新标准007_程序员的自我修养:编译链接和宏本质 🌏

本期我们要回顾一个非常经典的内容 C/C++ 程序的编译、链接、运行 ,这有助于理解之后要讲的模板的高级特性相关内容。这一部分内容虽然经典,但是考虑到有部分读者没有了解过,还是有必要专门讲解的。如果想要更深入理解,可以去 2021 年的 cpp conference,有关于编译和链接的专题视频

本期文章主要分为两个部分:

  • C 程序的编译、链接和运行
  • 宏的本质和优缺点

C 程序的编译、链接和运行

先从 C++ 程序的习惯谈起,相信大家在编写 C/C++ 程序时都会被告知这样的“约定”。

比如:

  • 声明要写在头文件中
  • 实现要放在源文件 cpp 文件中
  • 全局变量的声明要放在头文件中,并且要加上 extern 关键字,还不能赋值

但是,在 C++ 标准中,并没有对哪些声明应该放在头文件,哪些声明应该放在源文件中有标准规定,这种“约定”只是工程的最佳实践。

实际上,编译时的基本单位是源文件 。那么头文件到底有什么作用呢?我们用 helloworld 这个程序来解析解析这背后的原理。因为 IDE 和 cmake 这类构建工具会屏蔽原理,接下来我们就以纯手工 gcc 的方式来编译、链接一个程序。

main.cpp 文件中编写如下代码:

```c++

include

int main(int argc, char* argv[]) { printf("Hello World\n"); return 0; } ``` 我们可以通过 gcc 来将这个 cpp 文件编译成一个可执行程序,使用如下命令:

gcc main.cpp -o ttlarva.exe 即可在当前目录生成 ttlarva.exe 文件,并且运行该文件就可以得到 Hello World 的打印结果。

不过这样依然屏蔽了程序诞生的细节。实际上,这一行命令 gcc 帮我们完成了三件事情:预编译、编译、链接。

接下来我们通过对 gcc 设置编译选项,就可以看到这几个过程的细节。

预编译

gcc 中的 -E 选项可以显示预编译的结果,所谓预编译就是把预编译指令给转化为纯 C 代码。通过运行如下命令进行 预编译

gcc -E main.cpp > main.i 这时候我们就获得了一个 main.i 文件。打开该文件我们可以看到,原本简单几行的 helloworld 代码变成了近千行。且在代码的最末尾部分,就是我们在 main.cpp 中写的 main 函数。

c++ 883 # 3 "main.cpp" 884 int main(int argc, char* argv[]) 885 { 886 printf("Hello World\n"); 887 return 0; 888 } 那上面多出的几百行代码是怎么来的呢?其实对比可以发现,原本 #include <stdio.h> 在的位置变成了那些新的代码。这些代码就是 #include <stdio.h> 这一条预编译指令得来的。

include 的作用大家可以简单理解为:找到对应的文件,然后把文件的内容复制粘贴到当前的源文件中。大家可以验证 main.i 上方的代码就是 stdio.h 中的内容。

通过以上内容,相信大家应该对 include 的本质有了大致的了解了。那么理解了以上原理之后呢,相信大家也应该能理解我们开篇所说的那一些编程习惯了。

  • Q 1:为什么只有源文件是编译基本单位?
  • A 1:因为头文件其实是以复制粘贴的方式,被粘贴到了源文件中再编译。
  • Q 2:为什么函数的定义一般不放在头文件中?
  • A 2:因为如果把函数的定义放在头文件中,那么头文件被多个源文件 include 时,可能出现函数被多次重复定义,造成冲突。

编译

预编译得到的纯 C 代码文件经过编译就可以得到了 object 文件。object 文件中包含了对应的机器码。

我们可以使用如下命令将 main.cpp 编译main.o 文件。 gcc - c main.cpp

main.o 文件其实就是一个二进制文件,包含了程序功能的机器码。不过它并不能直接运行,需要链接才可以。

链接

为什么我们编译得到的机器码不能直接运行,而是要经过链接才能成为程序呢?

这其实是因为当前编译单元(即当前源文件)所调用到的函数,它的实现不一定在当前的编译单元中,可能在其他 cpp 中实现。就像我们前面调用的 printf 函数,它的实现也并不是我们实现的,而是库函数实现的。

链接的责任就是把各个编译单元给“拼接”到一起,“打通”各个 object 文件之间的调用关系。

我们通过下面的例子获得更清楚的理解。

首先我们创建一个 my_math.cpp文件。 c++ int my_add(int a, int b) { retrun a+b; } 再在 main.cpp 文件中调用这个函数。

```c++

include

int my_add(int a, int b)

int main(int argc, char* argv[]) { printf("5+9=%d\n", my_add(5,9)); return 0; } ``` 然后分别编译这两个文件。

gcc - c main.cpp gcc - c my_math.cpp 编译完成后,我们就得到了 main.omy_math.o 两个 object 文件。

接着我们使用如下命令将两个文件 链接 到一起。 gcc main.o my_math.o -o ttlarva.exe 我们就获得了类似之前的可执行程序 ttlarva.exe,运行该程序就得到了 5+9=14 的计算打印结果。

对这种多个源文件的程序,我们就不能使用 gcc main.cpp 直接获得结果,会报错找不到 my_add(int, int) 函数的引用。这也是因为链接过程中找不到 my_add 的实现。

宏的本质和优缺点

宏和 C++ 中的模板息息相关,可以这样说,说明模板的目的就是为了集成宏的优点,解决宏的缺点。

宏的本质

宏的本质就是 查找替换 ,所以大家应该有看到使用宏来代替函数的场景。

```c++

define MYMAX(x,y)((x)<(y)?(y):(x))

```

宏的优缺点

优点: - 使用宏来代替函数能提高效率。因为使用宏的话,那么就可以省去函数调用和返回时的开销了。

缺点: - 宏是没有类型检查的

我们通过下面的例子获得更清楚的理解。

```c++

include

define MYMAX(x,y)((x)<(y)?(y):(x))

int main(int argc, char* argv[]) { printf("%d\n", MYMAX(10, 5)); printf("%1f\n", MYMAX(3.14, 2.73)); printf("%s\n", MYMAX("short string","very very long string")); return 0; } ```

这里定义了一个 MYMAX 的宏,它的作用是取 x 和 y 中的较大者。

运行后发现如下输出:

10 3.140000 very very long string 运行没有报错,咋一看输出结果好像也并没有什么问题,但其实我们最后一次调用是有问题的。

我们输出 very very long string 并不是因为这个字符串比较长,只是我们运气好。

当我们调换最后一次调用两个参数的位置时:

```c++

include

define MYMAX(x,y)((x)<(y)?(y):(x))

int main(int argc, char* argv[]) { printf("%d\n", MYMAX(10, 5)); printf("%1f\n", MYMAX(3.14, 2.73)); //printf("%s\n", MYMAX("short string","very very long string")); printf("%s\n", MYMAX("very very long string","short string"));
return 0; } 运行结果如下: 10 3.140000 short string `` 第三次调用的结果不再是very very long string` 。

为什么会这样呢?我们根据 MYMAX 宏的定义可以知道,在传递字符串的时候,字符串的比较实际上是其内存中地址的比较,而两者地址的比较在语言标准中是没有去做过明确规定的。

综上所述,我们编写了一段意义不大的代码,它的运行结果和业务逻辑完全不相关。更恐怖的是,这类代码在编译时,连个报错或者是警告都没有。这直接拔高了我们以后查找 bug 的难度。

那么有没有一种既继承了宏优点,又弥补了宏没有类型检查缺陷的方法呢?当然有!它就是我们下一期要讲的模板

Back to top