前注:这篇随笔是我在学习C++过程中对于内联函数的一些总结与思考。内联函数是一个看似很简单,却总是在不经意间给人带来困扰的东西。最初学习C语言的过程中,我经常被编译器的自动内联优化而搞得晕头转向,后来学习C++之时,大多书籍资料也未作详细解释。近日拜读Scott Meyer的经典之作Effective C++,其中关于内联的相关解释,颇有醍醐灌顶之感。故作此文,作为自己复习相关知识和实践技巧的机会,也希望能带给别人一些收获。本来打算一个晚上能将其写完的,后来为了确保内容尽可能正确,我参考了数本书籍中关于内联函数的部分,在VC++和GCC下做了一些实验,并且在文字上也斟酌再三;如果文中有哪些地方存在错误、有争议,或者语言文字表述不清的,敬请指出。
内联函数的前世,#define
说到内联函数,就不得不提到 #define max(a, b) (a) > (b) ? (a) : (b) 这种预处理器宏定义。在最早些的C语言中,类似上面 max 这种宏定义随处可见。因为这种宏用起来跟函数很相似,所以这种宏定义还有一个绰号,叫“宏函数”。然而与真正的函数相比,使用define定义的宏只是在预处理阶段做了简单的文本替换,替换完了再交给编译器去编译,因此它并不具备类型检查,而且在使用中也容易出现一些意想不到的错误。最容易犯的错误就是,定义宏时缺少了必要的小括号。下面便是一个使用define定义的经典错误:
1 #define square(x) x * x
上面的定义了一个求平方的宏,在使用时我们往往将其看作接受一个任意参数的函数。比如想计算 5 的平方,于是就可以调用 square(5) 来进行。倘若我们想计算2 + 7的平方呢?仅仅使用 square(2 + 7) 就可以了吗?如果是这样,意想不到的问题便就此产生了。由于宏是一个优先于编译的预处理指令,在编译之前,所有的 square(2 + 7) 都会被替换为 2 + 7 * 2 + 7,因此在编译时,所产生的源代码的语义就发生了改变。可能你会觉得2 + 7这种简单到可以口算的表达式不会在你的代码里出现,然而 a + b 这种含有变量的表达式则是颇为常见的。因此在使用define构造“函数”时,一定需要保证小括号能够保证参数是完整的,也就是说不会因为替换后的优先级问题而改变了代码的本意。
那么确保小括号不丢就可以高枕无忧了吗?我原本也以为是这样,但是似乎另有玄机。在 Exceptional C++ Style:40 New Engineering Puzzles, Programming Problems and Solutions 一书中(这书名也确实有点长了,不过内容也是值得一看的)是这样指出的,在上述求解平方的宏中,作为参数的表达式x,实际上被求解了两遍。至于同样的表达式在正式编译前被展开后,编译器会不会进行合并优化我们不得而知。例如考虑square (a++)被展开后是(a++) * (a++),a++很可能被计算了两遍,从而在后面如果使用了a的值,其运行结果可能就会受到影响,产生十分令人困惑的问题了。
纵然define有着这样那样的不足,谁也无法阻挡其横扫江湖的脚步。在C标准库放眼望去,到处都有define的身影。define如此受欢迎的最大原因,恐怕便是在于其直接展开的高效性。在IA32体系下,函数调用需要保存调用者的帧,并为被调用函数开辟新的帧,逐个压入实参,随后执行call指令跳转到所调用的函数的入口。额外的栈帧操作需要巨大的开销,而call跳转指令则会让处理器的指令预取失效。在函数体本身较为短小的情况下,这些额外的工作和跳转会带来巨大的性能损失。在这样的情况下,宏替换的优点便展现的淋漓尽致:预处理器将函数体直接展开在调用者的地方,便不再需要层层递进的函数调用,因而节约了大量的时间,提高了程序的执行效率。那么有没有可以克服宏缺点的方法呢?
inline,升级了的解决方案
为了避免宏替换所带来的缺点,同时保持宏替换所带来的高效性,标准C++引入了内联函数的概念。内联函数确实是个宝,以至于后来发布C语言的C99的标准也将其纳入C语言之中。内联函数本质上还是一个函数,它包含了先计算参数、类型检查、有作用域限制等普通函数所具有的特性,同时包含了宏直接展开而无需函数调用的高效性。没有了实际的函数调用指令,额外开销便会减少很多,在频繁调用的函数身上便会产生非常好的效果。
对于需要泛型的内联函数,在C99中即使使用inline也不大容易实现,而在C++中便显得容易得多了。下面就是一个max的实现(摘自Effective C++),能够接受任意类型的变量(指针、立即数除外,它们无法转换为引用类型)。无论是宏还是内联了的模板函数对于泛型的使用有很多陷阱,而泛型不在本文(内联函数)讨论之列,这里就不做深究了。
1 template<typename T> inline const T & std::max(const T & a, const T & b) 2 { 3 return a < b ? b : a; 4 }
编译器,你怎么看
既然内联函数如此之美好,是不是我想给某个函数内联,只要在定义处加上inline关键字就万事大吉了?不,首先你得问问编译器同不同意。
对于内联函数,使用inline关键字,只是建议编译器去用内联的方式展开该函数;但是实际是否能成功展开,还是取决于编译器的实现。在有些情况,比如递归调用,或者函数体十分庞大,或者存在函数指针需要取得该函数的地址,又或者调用者与inline定义的函数不在同一个文件,那么内联是不会有效的。有的编译器可能也会出现“妥协”的实现,即在可能的地方,对使用inline的函数使用内联式展开,而在不可能的地方(取函数地址)使用原有的办法。在不适合内联函数甚至不可能出现内联函数的地方,即使使用类似__attribute__((always_inline))(GCC)或者__forceinline(MSVC)之类的强制内联的编译器指令,也无法保证100%地能够实现内联。
在C++中,还有这么一个传统的说法:定义在类内部的成员函数,通常都是作为内联函数的。一般说来,根据通常的编码习惯,在类定义里面的函数往往都是比较短小精悍的,因而编译器会对其使用内联;然而在类定义里定义较为复杂的成员函数,情况可能就不是那样了。下面是一个例子:
1 #include <iostream> 2 #include <cstring> 3 using namespace std; 4 class inline_class1 5 { 6 private: 7 int * ptr_array = 0; 8 int arr_size = 2; 9 public: 10 inline_class1() 11 { 12 ptr_array = new int[2]; 13 } 14 inline int call_me() 15 { 16 int i = 0; 17 for (int j = 0; j < 10000; j++) 18 { 19 if (j >= arr_size - 1) 20 { 21 int * tmp_ptr; 22 tmp_ptr = ptr_array; 23 ptr_array = new int[2 * arr_size]; 24 memcpy(ptr_array, tmp_ptr, sizeof(int)*arr_size); 25 delete[] tmp_ptr; 26 arr_size *= 2; 27 } 28 ptr_array[j] = i; 29 i += j; 30 } 31 return ptr_array[arr_size / 2]; 32 } 33 int call_me(int a) 34 { 35 return a; 36 } 37 }; 38 int main() 39 { 40 int result, result2; 41 inline_class1 cls1; 42 result = cls1.call_me(); 43 result2 = cls1.call_me(result); 44 cout << result; 45 return 0; 46 }
首先简单说明一下上面的例子。上述的代码定义了一个类来演示成员函数的内联。首先需要说明的是,这是一个十分糟糕的类的设计,因为没有析构函数进行垃圾回收,也没有考虑其复制构造函数和赋值运算符,但是作为演示内联与否的示例来说是足够了。成员函数call_me()包含两个重载版本,一个较长(包括了一个循环和其他函数调用),一个较短。我们分别在main()函数中调用他们。在Microsoft Visual Studio 2015下,我启用内联函数优化,/O2速度优化,在Release x86模式下,得到这样的Intel格式(目的操作数在前,不同于AT&T格式的目的操作数在后)的汇编代码:
1 int main() 2 { 3 00FE1090 push ebp 4 00FE1091 mov ebp,esp 5 00FE1093 sub esp,8 6 int result, result2; 7 inline_class1 cls1; 8 00FE1096 push 8 9 00FE1098 mov dword ptr [ebp-4],2 10 00FE109F call operator new[] (0FE10DBh) 11 00FE10A4 add esp,4 12 00FE10A7 mov dword ptr [cls1],eax 13 result = cls1.call_me(); 14 00FE10AA lea ecx,[cls1] 15 00FE10AD call inline_class1::call_me (0FE1000h) 16 result2 = cls1.call_me(result); 17 cout << result; 18 00FE10B2 mov ecx,dword ptr [_imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A (0FE2034h)] 19 00FE10B8 push eax 20 00FE10B9 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (0FE2038h)] 21 return 0; 22 00FE10BF xor eax,eax 23 } 24 00FE10C1 mov esp,ebp 25 00FE10C3 pop ebp 26 00FE10C4 ret
上述的汇编代码显示,默认构造函数被内联进了main(),无参数的call_me()函数(体积较为庞大的)依然使用了函数调用,而带有一个参数的call_me()函数(只有一行代码的)则被展开进了main()函数。如果我们强制使用__forceinline编译指令,则MSVC也会按照我们的想法将较长的call_me()展开进main()),但是这种情况,就需要仔细考虑:是不是确实需要内联了。
同样的代码在GCC中(MinGW)编译,则得到如下的代码(与使用inline关键字前后无关):
1 0x47c120 lea 0x4(%esp),%ecx 2 0x47c124 and $0xfffffff0,%esp 3 0x47c127 pushl -0x4(%ecx) 4 0x47c12a push %ebp 5 0x47c12b mov %esp,%ebp 6 0x47c12d push %edi 7 0x47c12e push %esi 8 0x47c12f push %ebx 9 0x47c130 push %ecx 10 0x47c131 xor %edi,%edi 11 0x47c133 xor %ebx,%ebx 12 0x47c135 mov $0x2,%esi 13 0x47c13a sub $0x28,%esp 14 0x47c13d call 0x41c250 <__main> 15 0x47c142 movl $0x8,(%esp) 16 0x47c149 call 0x401fa0 <operator new[](unsigned int)> 17 0x47c14e mov %eax,%edx 18 0x47c150 mov %edi,(%edx,%ebx,4) 19 0x47c153 add %ebx,%edi 20 0x47c155 add $0x1,%ebx 21 0x47c158 cmp $0x2710,%ebx 22 0x47c15e je 0x47c1ce <main()+174> 23 0x47c160 lea -0x1(%esi),%eax 24 0x47c163 cmp %ebx,%eax 25 0x47c165 jg 0x47c150 <main()+48> 26 0x47c167 lea (%esi,%esi,1),%eax 27 0x47c16a mov %edx,-0x20(%ebp) 28 0x47c16d mov $0xffffffff,%edx 29 0x47c172 mov %eax,%ecx 30 0x47c174 lea 0x0(,%esi,8),%eax 31 0x47c17b cmp $0x1fc00000,%ecx 32 0x47c181 mov %ecx,-0x1c(%ebp) 33 0x47c184 cmovg %edx,%eax 34 0x47c187 shl $0x2,%esi 35 0x47c18a mov %eax,(%esp) 36 0x47c18d call 0x401fa0 <operator new[](unsigned int)> 37 0x47c192 mov -0x20(%ebp),%edx 38 0x47c195 mov %esi,0x8(%esp) 39 0x47c199 mov %eax,(%esp) 40 0x47c19c mov %eax,-0x20(%ebp) 41 0x47c19f mov %edx,0x4(%esp) 42 0x47c1a3 mov %edx,-0x24(%ebp) 43 0x47c1a6 call 0x425d20 <memcpy> 44 0x47c1ab mov -0x24(%ebp),%edx 45 0x47c1ae mov %edx,(%esp) 46 0x47c1b1 call 0x402030 <operator delete[](void*)> 47 0x47c1b6 mov -0x20(%ebp),%ecx 48 0x47c1b9 mov -0x1c(%ebp),%esi 49 0x47c1bc mov %ecx,%edx 50 0x47c1be mov %edi,(%edx,%ebx,4) 51 0x47c1c1 add %ebx,%edi 52 0x47c1c3 add $0x1,%ebx 53 0x47c1c6 cmp $0x2710,%ebx 54 0x47c1cc jne 0x47c160 <main()+64> 55 0x47c1ce mov (%edx,%esi,2),%eax 56 0x47c1d1 mov $0x489940,%ecx 57 0x47c1d6 mov %eax,(%esp) 58 0x47c1d9 call 0x4595a0 <std::ostream::operator<<(int)> 59 0x47c1de sub $0x4,%esp 60 0x47c1e1 lea -0x10(%ebp),%esp 61 0x47c1e4 xor %eax,%eax 62 0x47c1e6 pop %ecx 63 0x47c1e7 pop %ebx 64 0x47c1e8 pop %esi 65 0x47c1e9 pop %edi 66 0x47c1ea pop %ebp 67 0x47c1eb lea -0x4(%ecx),%esp 68 0x47c1ee ret
很明显,我们可以看出类的构造函数被展开了,除此之外,两个call_me()调用都被展开了:标志性的call <delete>和call <memcpy>。GCC在这里展现出了严格按照内联语义,展开了类定义处的内联函数的行为,即使函数体有较为庞大的循环语句(虽然这通常不是很好的做法,因为循环的执行时间是线性的,而函数调用的时间是常数的;较大的循环长度则会让循环体本身的执行时间掩盖微不足道的函数调用时间);而MSVC则认为较长的、带有复杂跳转的函数展开无益,甚至可能有害,因而即使打开了内联优化选项,它也拒绝将标识为inline的、定义在类内部的函数进行内联。
在有些时候,纵然没有写出inline关键字,编译器已经在帮你默默地进行内联优化了。看一下下面的这段简短的示例:
1 #include <iostream> 2 using namespace std; 3 int inline_test1(int a,int b){ 4 return a * b + a + b; 5 } 6 int main() 7 { 8 int m; 9 int n; 10 cin >> m >> n; 11 int r = inline_test1(m, n); 12 cout << r << endl; 13 return 0; 14 }
在上面的代码中,我并没有为函数inline_test1显式地使用inline关键字,但是在-O2的编译选项下,观察GCC为上述的C++代码编译并生成了的汇编代码(如下所示),可以发现,21~23行中,lea、imul、add指令的组合恰好就是函数inline_test1的主体,而函数调用的call指令并未出现。也就是说,在-O2的优化条件下,GCC直接将简短的函数内联进了调用者。
1 0x47c120 lea 0x4(%esp),%ecx 2 0x47c124 and $0xfffffff0,%esp 3 0x47c127 pushl -0x4(%ecx) 4 0x47c12a push %ebp 5 0x47c12b mov %esp,%ebp 6 0x47c12d push %ecx 7 0x47c12e sub $0x24,%esp 8 0x47c131 call 0x41c250 <__main> 9 0x47c136 lea -0x10(%ebp),%eax 10 0x47c139 mov $0x489a00,%ecx 11 0x47c13e mov %eax,(%esp) 12 0x47c141 call 0x456950 <std::istream::operator>>(int&)> 13 0x47c146 lea -0xc(%ebp),%edx 14 0x47c149 sub $0x4,%esp 15 0x47c14c mov %eax,%ecx 16 0x47c14e mov %edx,(%esp) 17 0x47c151 call 0x456950 <std::istream::operator>>(int&)> 18 0x47c156 mov -0xc(%ebp),%edx 19 0x47c159 sub $0x4,%esp 20 0x47c15c mov $0x489940,%ecx 21 0x47c161 lea 0x1(%edx),%eax 22 0x47c164 imul -0x10(%ebp),%eax 23 0x47c168 add %edx,%eax 24 0x47c16a mov %eax,(%esp) 25 0x47c16d call 0x4595a0 <std::ostream::operator<<(int)> 26 0x47c172 sub $0x4,%esp 27 0x47c175 mov %eax,(%esp) 28 0x47c178 call 0x478ad0 <std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)> 29 0x47c17d mov -0x4(%ebp),%ecx 30 0x47c180 xor %eax,%eax 31 0x47c182 leave 32 0x47c183 lea -0x4(%ecx),%esp 33 0x47c186 ret
因此,从上面的例子来看,具体是否产生了内联这一行为,inline这一关键字是并不能起到决定性作用的。在现在的编译器下,内联往往成为了一种编译器行为,编译器会根据具体的情况做出适当的取舍。人为地使用inline关键字,只是给了编译器一条建议:最好能把这个函数内联了。既然是建议,那就有好有坏,未必必须要遵从执行;编译器既然有权采纳你的建议,当然也有权拒绝了。
inline,就不会有坑吗
如果编译器同意使用inlining了,那么一切就会如你所愿,就是平坦的阳光大道吗?具体的答案并不明确,不过下面列出的,也许就是内联函数默默给你挖下的坑。
目标代码变得太大。内联函数展开的原理,是在调用处将整个函数体展开(栈帧操作、返回等忽略了),所以如果某个函数被反复调用,而函数体本身较长,那么目标码的体积就会急剧膨胀,减少函数调用开销带来的性能提升很可能被内存紧张而抵消掉(当程序占用内存过大时,容易引发Page Fault缺页异常导致操作系统的换页操作,这会严重降低性能。)。
出现莫名其妙的链接错误。C/C++编译器在编译时,是在预处理器展开#include指令包含的头文件后逐个编译源代码文件。在内联函数没有被包含却被跨文件调用时,某些编译器很有可能会出现“无法解析的外部符号”这一链接错误(这种情况取决于编译器本身,GCC在编译内联函数时有时候会生成独立函数的代码,这样跨文件调用、递归等情况就可以使用普通的函数调用)。
不同的编译器、语言标准对同样的关键字语义差别很大。例如在C99(GNU99)和GNU89中,extern inline、static inline、inline的语义就有所区别;而在C++中,则只有inline这一种表述方式,并没有static inline、extern inline之类的说法。编译器指令也随着编译器的不同而不同。这种混乱的使用,往往也是造成问题的罪魁祸首。
大量调试器面对内联函数束手无策。这虽然是Effective C++中的条款,而这本书出版也已经很久了,然而我使用的Visual Studio 2015的调试器,遇到内联函数时,也会报告断点无法命中。对于内联函数,当它被展开嵌入进主调函数时,编译器是无法跟踪其运行的,因此往往会出现一种“设置了某个断点,却无法命中”的情况。在显式声明的内联函数中设置断点,显然是多此一举,想必谁也不会去干这种徒劳的事。而对于编译器偷偷摸摸擅作主张的内联,就要留个心眼了。最起码,在碰到问题而断点不命中时,在心里得有这个意识:是不是编译器在后面做鬼内联,让我的断点失效了?这时候就得试着关闭编译器的内联选项,再观察断点和进一步调试。虽然导致断点失效的情况可能很多,但是内联函数确实一个很重要的原因。
内联函数无法随着程序库的升级而升级。这也是Effective C++从实际工程中给出的参考建议。理由也很简单,内联函数嵌入到了代码的各个角落,直接更新函数库并不能更新已经展开了的函数。使用普通函数可以在链接时对其进行更新,远比重新编译负担低;而动态链接则是一种更好的做法。
inline,我真的需要“强调“吗
作为本篇随笔的结尾,自然顺水推舟的给出了这个问题:在什么情况下适用inlining?是不是该我们自己inlining?
因为内联函数的本意是缩小函数调用的开销。那么函数调用的开销在什么情况会占很大比重呢?答案是显然的,只有在函数体本身足够短小精悍时,函数调用才有可能成为性能的瓶颈。因此,援引Meyer Scott在Effective C++中的建议就是,将大多数内联函数限制在小型的,被频繁调用的函数身上。个人认为,更激进的做法便是,不必要手工inline,一切交给编译器即可。如果真的发现函数调用成为性能瓶颈了,再进行内联构造也不迟。记得知乎曾经有个笑话,说是怎么写5*7最快。下面各种方法都有,然而最后道破天机的,便是直接写5*7。
2017.2.25