六、内联函数 (重要)
1、内联函数的概念
在 函数栈帧的创建和销毁 一节中我们知道:一个函数在开始调用时会建立函数栈帧,结束调用时会销毁函数栈帧,而函数栈帧的建立与销毁是有空间和时间上的开销的;
那么,对于功能简单、调用次数非常多的函数来说,每次调用都重新开辟栈帧势必就会造成效率的降低;比如 hoare 法的快速排序中,我们仅仅是每次单趟排序都会调用很多次 Swap 函数,更别说单趟排序也会被递归调用很多次,而 Swap 函数本身的功能恰好十分简单,那么该如何来对其进行优化呢?
在C语言中,我们使用宏函数来解决这个问题:我们直接将 Swap 函数写成宏函数,这样使得程序在预处理阶段直接将 Swap 函数替换成相应的代码,从而不再建立函数栈帧。
//源代码 #include <stdio.h> #define Add(x,y) ((x)+(y)) //宏函数 int main() { int ret = Add(2, 3); printf("%d\n", ret); } //经过预处理之后的代码 { //...此处是 stdio.h 展开的内容 } int main() { int ret = ((2)+(3)); printf("%d\n", ret); }
但是宏有如下主要缺点:
- 宏不能调试;
- 宏没有类型安全检查;
- 宏非常容易写错;
至于为什么宏有这些缺点以及这些缺点的具体体现场景,我在 程序环境和预处理 中已经有过介绍,这里就不再赘述。
基于C语言宏函数的这些缺陷,C++设计了内联函数:
以 inline 关键字修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开 (用函数体替换函数的调用),没有函数调用建立栈帧的开销,内联函数可以提升程序运行的效率;
内联函数的编写和正常函数一样,仅仅是在函数的返回值类型前添加一个 inline 关键字 (这样就解决了C语言宏函数容易写错以及没有类型安全检查的缺陷);
同时,在 debug 模式下,内联函数不会自动展开,需要我们对编译器进行相关设置;在 release 模式下,内联函数会自动展开 (这样解决了C语言宏函数无法调试的缺陷);
所以说:内联函数在继承了C语言宏函数优点的同时几乎避免了其所有的缺陷。
2、内联函数定义及查看
内联函数的定义
//普通函数 int Add(int x, int y) { return x + y; } //内联函数--添加inline关键字 inline int Add(int x, int y) { return x + y; }
内联函数的查看
1、 在 release 模式下,编译器会自动将内联函数展开,但由于 release 模式无法调试,所以我们这里无法观察;
2、 在 debug 模式下,需要在 项目->属性 中对编译器进行如下设置,否则不会展开 (因为 debug 模式下,编译器默认不会对代码进行优化,以下给出 VS2019 的设置方式)
在完成上述设置后我们 F10 进入调试,然后单击右键转到反汇编查看汇编代码:
普通函数的汇编代码
内联函数的汇编代码
注:大家在测试完成之后记得把编译器设置还原
3、内联函数的特性
特性1
inline 对于编译器而言只是一个建议 (类似于C语言的 register 关键字),不同编译器关于 inline 的实现机制可能不同,一般建议将具有如下特点的函数采用 inline 修饰:
- 函数规模较小 (即函数不是很长,具体没有准确的说法,取决于编译器内部实现);
- 不是递归;
- 频繁调用;
下图为 《C++prime》第五版对于 inline 的建议:
下图为 《C++prime》第五版对于 inline 的建议:
//把Add的内部逻辑复杂化 inline int Add(int x, int y) { int sum = x + y; sum += x + y; sum = x + y; sum /= x + y; sum = x + y; sum = x + y; sum *= x + y; sum = x + y; sum = x + y; sum -= x + y; sum = x + y; sum += x + y; return sum; }
Add 函数的汇编代码:
我们可以看到,当我们将 Add 函数的内部逻辑复杂化之后,尽管我们使用了 inline 关键字修饰 Add 函数,但是 Add 函数并没有被展开,而是和正常函数一样调用、建立栈帧。
特性2
nline 是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段会用函数体替换函数调用;这样做的优势是减少了栈帧建立的开销,提高了程序运行效率;缺陷是可能会使目标文件变大。
需要注意的是,这里的空间指并不是指程序运行时占用的内存空间,而是指经过编译链接后得到的可执行程序 (.exe/.o文件) 所占用的空间;对于可执行程序变大的原因,我们以下面这个例子为例:
假设一个Func函数的汇编指令有50条,且这个函数要被重复调用1W次;
那么对于普通Func函数来说:我们每次调用Func都要转换出一条 call 汇编代码,调用1W次就有1W条汇编指令;但是Func函数本身只会在函数定义处被转换为汇编代码;所以普通Func函数经过编译之后的汇编指令一共有 1W+50 条;
而对于 inline 函数来说,由于 inline 函数会在所有调用的地方展开,也就是说,每调用一次Func函数,就会转换成50条对应的汇编代码;所以 inline Func函数经过编译之后的汇编指令一共有 50W 条;
而汇编指令的增多可能会导致我们编写的静态库/动态库增大,也有可能导致编写的 .exe 增大;这其实就是所谓的 “代码膨胀”,这也在一定程度上解释了为什么当内联函数过长时编译器不进行展开。
特性3
inline 不建议声明和定义分离,分离会导致链接错误,具体原因如下:
在 程序环境和预处理 中我们知道:程序在编译阶段进行符号汇总,汇编阶段生成符号表,链接阶段进行符号表的合并和重定位;
对于定义在本文件内的函数来说,编译器在汇编阶段会直接调用该函数,在调用过程中会生成对应的符号表,且此符号表中的地址一定是有效的,所以程序不会进行后续的链接操作;
而对于定义在其他文件中的函数,编译器会先在本文件内寻找该函数的声明,且声明生成的符号表中的地址是无效的;此时编译器会继续后续的链接操作;
链接过程中符号表的合并会将汇编阶段生成的所有符号表合并到一起,合并的意思是如果两个符号表中的函数名相同,那么编译器会选取与有效地址相关联的符号表,丢弃掉另一个与无效地址关联的;这样同时具有声明和定义的函数经过链接就只有一个符号表了;
而如果一个函数只有声明,而没有定义的话,那么它经过符号表的合并之后关联的仍然是一个无效地址,则在进行符号表的重定位时就会发生链接性错误;如果符号表中关联的是一个有效地址,重定位时编译器就会根据这个地址来调用函数,这样就可以实现跨文件调用函数;
对于 inline 函数来说,如果我们将函数的定义和声明分离,那么函数的声明在汇编阶段会生成一个符号表,里面关联的是一个无效的地址;但是由于 inline 函数是直接展开的,所以函数定义部分在汇编阶段并不会生成符号表;这时候就出现了上面的问题,程序经过符号表的合并之后 inline 函数仍然关联一个无效地址,会在重定位的时候发生链接性错误。如下:
图中,我们将 inline 函数的定义放在 Add.h 中,将其实现放在 Add.cpp 中,然后在 test.cpp 中包含 Add.h,这样经过预处理之后,test.cpp 中就包含了 inline 函数的声明;那么经过汇编,Add.cpp 中的 Add 函数由于是内联函数,会直接展开,所以不会生成符号表;
而在 test.cpp 中,经过汇编,Add 函数的声明会生成一个符号表,且符号表中的地址是无效的;而在链接阶段,Add 声明对应的符号表又不能匹配到有效的地址 (因为 test.cpp 中并没有生成 Add 函数的符号表),所以重定位时发生链接型错误 (LNK 错误);
正确的使用方法如下:如果有 .h 文件,将 inline 函数的定义直接放在 .h 文件中;如果没有 .h 文件,就直接放在本文件内部;
注:我们在C语言中学习的 函数栈帧的创建和销毁 和 程序环境和预处理 这两节内容虽然很难,但是对于理解C/C++的底层逻辑非常重要,希望大家能多花一点时间来理解它们。