内联函数那些事情

2018-06-17 23:18:26来源:未知 阅读 ()

新老客户大回馈,云服务器低至5折

前注:这篇随笔是我在学习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)或者__forceinlineMSVC)之类的强制内联的编译器指令,也无法保证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行中,leaimuladd指令的组合恰好就是函数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 inlinestatic inlineinline的语义就有所区别;而在C++中,则只有inline这一种表述方式,并没有static inlineextern inline之类的说法。编译器指令也随着编译器的不同而不同。这种混乱的使用,往往也是造成问题的罪魁祸首。

  大量调试器面对内联函数束手无策。这虽然是Effective C++中的条款,而这本书出版也已经很久了,然而我使用的Visual Studio 2015的调试器,遇到内联函数时,也会报告断点无法命中。对于内联函数,当它被展开嵌入进主调函数时,编译器是无法跟踪其运行的,因此往往会出现一种“设置了某个断点,却无法命中”的情况。在显式声明的内联函数中设置断点,显然是多此一举,想必谁也不会去干这种徒劳的事。而对于编译器偷偷摸摸擅作主张的内联,就要留个心眼了。最起码,在碰到问题而断点不命中时,在心里得有这个意识:是不是编译器在后面做鬼内联,让我的断点失效了?这时候就得试着关闭编译器的内联选项,再观察断点和进一步调试。虽然导致断点失效的情况可能很多,但是内联函数确实一个很重要的原因。

  内联函数无法随着程序库的升级而升级。这也是Effective C++从实际工程中给出的参考建议。理由也很简单,内联函数嵌入到了代码的各个角落,直接更新函数库并不能更新已经展开了的函数。使用普通函数可以在链接时对其进行更新,远比重新编译负担低;而动态链接则是一种更好的做法。

inline,我真的需要“强调“吗

  作为本篇随笔的结尾,自然顺水推舟的给出了这个问题:在什么情况下适用inlining?是不是该我们自己inlining

  因为内联函数的本意是缩小函数调用的开销。那么函数调用的开销在什么情况会占很大比重呢?答案是显然的,只有在函数体本身足够短小精悍时,函数调用才有可能成为性能的瓶颈。因此,援引Meyer ScottEffective C++中的建议就是,将大多数内联函数限制在小型的,被频繁调用的函数身上。个人认为,更激进的做法便是,不必要手工inline,一切交给编译器即可。如果真的发现函数调用成为性能瓶颈了,再进行内联构造也不迟。记得知乎曾经有个笑话,说是怎么写5*7最快。下面各种方法都有,然而最后道破天机的,便是直接写5*7

2017.2.25

 

 

标签:

版权申明:本站文章部分自网络,如有侵权,请联系:west999com@outlook.com
特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系,版权归原作者所有

上一篇:Qt 中QString 字符串操作:连接、组合、替换、去掉空白字符

下一篇:bzoj2006 [ NOI2010 ] &amp;&amp; bzoj3784 --点分治+线段树+堆