Fork me on GitHub

读书笔记 effective c++ Item 30 理解内联的里里外外 (大师入场啦)

最近北京房价蹭蹭猛涨,买了房子的人心花怒放,没买的人心惊肉跳,咬牙切齿,楼主作为北漂无房一族,着实又亚历山大了一把,这些天晚上睡觉总是很难入睡,即使入睡,也是浮梦连篇,即使亚历山大,对C++的热情和追求还是不减,应该是感动了周公吧,梦境从此处开始,大师入场来给我安慰了。。。

11点躺在床上了,脑子里总结一下最近的工作:最近的开发用到inline函数比较多,众所周知,inline的使用是为了提高程序性能,可结果却总不尽如人意,这个捉急啊,嗯?怎么突然到了山脚下,周边树木林立,郁郁葱葱,鸟儿委婉啼叫,花儿盛开绽放,好惬意啊,向远处望去,青山耸入云霄,山脚下有一石门,突然发现旁边坐着一位白衣人,像是在练太极,走近一看,怎么是蓝眼睛,黄头发,再一定睛,我靠。。这不是传说中的斯考特大师么?我快步向前,用自己蹩脚的英文问候了一句:

我:Hello,are you Scott Meyers?

大师:是的,恨高星认识你,我认识你,你是在博客园上又把我的书籍重新翻译了一遍的那个,你是HarlanC。你是不是有问题要问我呢?

我:(心理鸡冻难耐,斯考特竟然会中文)Y..yes(不要结巴了),I have one question…..

大师:你还是用中文吧。

我:好吧,最近使用inline比较多,但效率却总是不尽人意,您说是用inline好呢还是不用好呢。

大师:跟我来。

打开山门,一个胖胖的女人站在院子里,她前面有一张桌子,桌子上面放了两个盒子,盒子上都写着字和标点符号。一个是:巧克力?,一个是:蔬菜?大师望了我一眼,欲言又止。

我心里突然一亮,马上回复:大师,您的意思是让胖女人猜测,猜出哪个吃那个?

大师伸出食指,面带微笑,边摇边说:No.No,No.胖女人代表程序,盒子里的食物代表inline后的函数,你需要自己判断这个函数是“巧克力”还是“蔬菜”,巧克力会让胖女人的身材更加臃肿,蔬菜能够让胖女人瘦身。

我问道:看您在练太极,是不是气功能看穿盒子,教教我吧。

大师看了看我,扎下了马步,开始运气了。这是要发功了吧。我心想。

运气完毕,大师走向盒子,用手抓住它们,用力一撕,盒子打开了,大师回头望了我一眼,说到:

要多动手。

 

自己意淫了一把,现在开始进入正题:

1. inline函数的优缺点

内联函数——一个多么美妙的想法!它们看上去像函数,行为表现也像函数,它们总是比宏要优秀许多(Item 2),你能调用它们却没有引入函数调用的开销,行为表现如此,夫复何求?

你实际上比你想象的要获取的更多,因为避免函数调用的开销只是这个故事的一部分。编译器最优化是为了浓缩没有函数调用的代码而设计,所以当你inline一个函数时,你可能使编译器在函数体上执行特定场景下的优化操作。大多数编译器不会在outlined的函数调用上执行这样的优化。

然而编程犹如生活,没有免费的午餐,inline函数也不例外。Inline函数的内部机制是用函数体替换函数调用,即使没有统计学的博士学位你也能看到这似乎增加了你的目标码的大小。在内存有限的机器上,过度的inlining会造成占用空间过大的问题。即使使用虚拟内存,inline代码造成的膨胀也会导致额外的分页,指令缓存命中率降低以及随之而来的性能损耗。

另一方面,如果inline函数体非常短小,函数体本身生成的代码可能比函数调用生成的代码体积要小。如果是这种情况,inlining函数使目标代码体积更小以及指令缓存命中率更高!

2. Inline函数的显示和隐式实现方式

需要注意的是inline是对编译器的请求而不是命令。请求可以显示或者隐式的提出来。隐式的方法通过在类定义内定义一个函数:

1 class Person {
2 public:
3 ...
4 int age() const { return theAge; } // an implicit inline request: age is
5 ... // defined in a class definition
6 private:
7 int theAge;
8 };

 

这样的函数通常是成员函数,但是Item 46中解释道friend函数也能在类中定义。如果是这样,它们也会被隐式声明成inline。

显示的声明一个inline函数的方法是在函数定义之前加上关键字inline。举个例子,下面是标准的max模板实现方式:

1 template<typename T> // an explicit inline
2 inline const T& std::max(const T& a, const T& b) // request: std::max is
3 { return a < b ? b : a; } // preceded by “inline”

 

3. 函数模板必须inline么?

max被实现为模板函数的事实让我们联想到inline函数和模板都是需要被定义在头文件中的。因此一些程序要就下结论函数模板就必须是inline的。这个结论既无效并可能会有潜在的危害,让我们分析分析吧。

Inline函数是必须被定义在头文件中的,因为大多数编译环境在编译时执行函数的内联。为了将函数调用替换为函数体,编译器必须了解这个函数长成什么样子。(一些编译环境能够在链接的时候执行内联,甚至有一些能够在运行时进行内联(如基于.NET CLI的托管环境),这样的环境都是例外,但不是通用规则。在大多数C++程序中inline是编译时活动。)

模板也是被定义在头文件中的,因为编译器为了对其进行实例化时需要知道这个模板是什么样子的。(这种情况也有例外,一些编译环境在链接期间执行模板实例化。然而编译时实例化是最常见的。)

模板实例化和inline是相互独立的。如果你实现一个函数模板,而需要此模版实例化的所有函数都是inline的,那么将其声明成inline。上面的std::max就是这么实现的。但如果你将函数实现成模板,而此函数不需要inline,那么避免将模板声明成inline(无论是显示的还是隐式的)。使用inline是有代价的,不要在没有进行考虑周详之前使用inline。我们已经提及了inline是如何导致代码膨胀的(Item 44中为模板作者描述了一个特别重要的注意点),但也会有其他的开销,我们一会讨论。

4. 深入理解inline

在我们进行讨论之前,先让我们了解如下事实:inline只是一个对编译器的请求,而编译器可能会将其忽略。大多数编译器会拒绝为看上去特别复杂的函数进行inline(例如,包含循环或者迭代的函数),需要调用虚函数的函数也不能进行inline,不要感到吃惊。virtual意味着“只有在运行时才能决定调用哪个函数,”而inline意味着“执行程序之前,在调用点处用函数体进行替换”。如果编译器不知道将会调用哪个函数,你就不能因为拒绝为函数体内联而责备它。

我们总结一下:一个定义成inline的函数是否真正被inline取决于你所使用的编译环境——而这个编译环境主要是只编译器。幸运的是,编译器会对这个过程进行诊断,如果inline一个函数失败了,它会发出一个警告(Item 53)。

有时候即使编译器迫切的希望对函数进行inline,它们也会为其生成一个单独的函数体。例如,如果你的程序需要获知内联函数的地址,编译器就必须为其生成一个outline的函数体。它们不能使用一个不存在的函数指针吧?加上如下事实:编译器使用函数指针进行函数调用时不会为其进行inline,这意味着对内联函数的调用可能会被内联也可能不会,取决于函数调用是如何进行的:

1 inline void f() {...}       // assume compilers are willing to inline calls to f
2 
3 void (*pf )() = f;          // pf points to f
4 
5 ...
6 f();                   // this call will be inlined, because it’s a “normal” call
7 
8 pf(); // this call probably won’t be, because it’s through
9 // a function pointer

未被inline的inline函数会像幽灵一样萦绕在你周围,即使你从未使用函数指针也是如此,因为并不是只有程序员才会需要函数指针。有时候编译器也会为构造函数和析构函数生成一份out-of-line函数体,因为对数组中的对象进行构造和析构时需要使用指向它们的指针。

 

5. 构造函数和析构函数该不该被inline?

事实上,构造函数和析构函数通常情况下是inline函数的槽糕候选人,而不像表面看上去那样,考虑类Derived类的构造函数:

 1 class Base {
 2 public:
 3 ...
 4 private:
 5 
 6 std::string bm1, bm2;              // base members 1 and 2
 7 
 8 };                                            
 9 
10 class Derived: public Base {    
11 
12 public:                                    
13 
14 Derived() {}                             // Derived’s ctor is empty — or is it?
15 
16 ...                                            
17 
18 private:                                  
19 
20  
21 
22 std::string dm1, dm2, dm3;    // derived members 1–3
23 
24 };       

                      

 这个构造函数看上去像是inline函数的杰出候选人,因为它不包含任何代码。但是不要被表面现象蒙蔽。

 

当对象被创建或者析构的时候C++必须保证一些事情的发生。例如,当你使用new的时候,你的动态创建的对象由它们的构造函数自动初始化;当你使用delete时,对应的析构函数要被触发。当你创建一个对象时,对象的基类部分和它的每个数据成员都会被自动构建,当对象被销毁的时候相反的过程也就是自动析构就会发生。如果在构造或者析构的时候抛出异常,已经被构建出来的对象的任何部分都应该被自动释放。在所有这些场景中,c++指出什么必须发生,但没说明如何发生。这就是编译器实现人员要做的了,但是应该清楚的是这些事情是不会自己发生的。你必须在你的程序中写一些代码来让这些事情发生,这些在编译过程中一定会插入到你的代码的某些地方。有时候在构造函数和析构函数的结尾处,所以我们可以想象一个空的Derived 构造函数实际上会是什么样子的:

 1 Derived::Derived() // conceptual implementation of
 2 { // “empty” Derived ctor
 3 
 4 Base::Base();                       // initialize Base part
 5 
 6 try { dm1.std::string::string(); }          // try to construct dm1
 7 
 8 
 9 catch (...) { // if it throws,
10 Base::~Base(); // destroy base class part and
11 throw; // propagate the exception
12 }
13 try { dm2.std::string::string(); } // try to construct dm2
14 catch(...) { // if it throws,
15 dm1.std::string::~string(); // destroy dm1,
16 Base::~Base(); // destroy base class part, and
17 
18 throw;                     // propagate the exception
19 
20 }                             
21 
22 
23 try { dm3.std::string::string(); } // construct dm3
24 catch(...) { // if it throws,
25 dm2.std::string::~string(); // destroy dm2,
26 dm1.std::string::~string(); // destroy dm1,
27 Base::~Base(); // destroy base class part, and
28 throw; // propagate the exception
29 }
30 }

 

这么写并不代表着编译器一定会这么做,因为编译器处理异常的方式更加复杂。但是这精确的反映出Derived的空构造函数必须提供什么。不管编译器对异常处理的实现多么复杂,Derived的构造函数必须为其数据成员和基类调用构造函数,这些调用(可能它们本身是inline的)会影响inline的吸引力。

 

同样的原因适用于基类构造函数,因此如果它被inline了,它里面的代码同样会被插入到Derived构造函数中(Derived构造函数会调用基类构造函数。)。并且如果string构造函数恰恰也被inline了,Derived构造函数会增加5份函数代码的拷贝(对应Derived中的5个string),现在你应该明白了为什么对Derived构造函数进行inline是一个没脑子的决定。同样的考虑也适用于Derived析构函数,我们必须看到被Derived构造函数初始化的对象被合适的销毁掉。

 

6. Inline对客户造成的影响

 

库设计者必须估计将函数声明成inline会造成的影响,因为在一个库中为客户可见的inline函数提供二进制更新(binary upgrade)是不可能的。用其他的话来说,如果f是一个库中的inline函数,这个库的客户将f这个函数体编译进了自己的应用中。如果库实现者过后决定修改f,所有使用f的客户都必须重新编译。这是不受欢迎的做法。另外一方面,如果f不是inline函数,对f的修改只需要重新链接就可以了。这实际上比重新编译减少了负担,如果包含这个函数的库是被动态链接的,更新版本会被客户不知不觉的吸收。

 

7. Inline对调试器(debugger)产生的影响

 

为了更好的开发程序,将上面的考虑都记在脑海中,但在编码过程中从实用的角度来说,一个事实支配了其他所有问题:大多数调试器不能很好的应用在inline函数上。这应该也不是什么出乎意料的事。你如何才能在一个并没有那里的函数设定断点呢?虽然一些编译环境支持对inline函数的调试,许多编译环境只是在生成调试版本的时候禁止inline。

 

8. 总结

决定哪些函数应该被声明为inline的哪些不应该是一个逻辑策略问题。首先,不要inline任何东西,或者只将inline限定在那些必须被inline的函数(Item 46)或者很小的函数上面。通过谨慎的使用inline,你能很好的使用你的调试器,但是这样做你也同样将inline放在了合适的位置:作为手工优化的方法。不要忘记80-20法则,这意味着一个特定的程序会用80%的时间来执行20%的代码。这是个重要法则,因为它提醒了你,作为一个软件工程师识别这20%的代码并进行优化会对程序的性能有整体的提升。你可以对你的函数进行inline或者去掉inline,直到性能满足要求,当然这需要你在那20%的函数上努力,否则就是浪费精力。

 

需要你记住的: 

    • 将inline限定在最小的,最频繁调用的函数上面。这会使你的调试,二进制升级变得容易,并能将潜在的代码膨胀问题最小化,提高程序运行速度可能性最大化。
    • 不要仅仅因为函数模板出现在头文件中就将其声明成内联函数。
posted @ 2017-03-08 21:36  HarlanC  阅读(1204)  评论(0编辑  收藏  举报