编译链接和宏本质¶
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.o
和 my_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 的难度。
那么有没有一种既继承了宏优点,又弥补了宏没有类型检查缺陷的方法呢?当然有!它就是我们下一期要讲的模板。