[Effective C++系列]-透彻了解inlining的里里外外

Understand the ins and outs of inlining.
 
  • [原理]
Inline函数背后的做法是将“对函数的每一个调用”都用函数本体(function body)替换之。其好处是:
  1. 可以消除函数调用所带来的开销。
  2. 编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,因此当你inline某个函数,或许编译器有能力对它(函数本体)执行语境相关最优化。大部分编译器不会为一个“outlined函数调用”执行这种最优化动作。
然而inline函数这些美好的一面也伴随着代价:过多的inline替换可能会增加程序的目标码(object code)大小。在一台内存有限的机器上,过度inlining会造成加载至内存中的程序体积太大,即使拥有虚拟内存,inline造成的代码膨胀会导致额外的换页行为(paging),降低高速缓存装置的击中率,导致效率损失。(如果inline函数的本体很小,编译器针对“函数本体”产生的目标码可能比“函数调用”所产生的目标码更小,那么inlining确实可能导致更小的程序目标码和较高的指令高速缓存装置击中率。)
 
但这不是inline的全貌,inline只是对编译器的一个申请,不是强制命令。这项申请可以隐式声明,也可以显式声明。这意味着两方面:
  1. 当你申请(隐式或者显式)对一个函数进行inlining时,编译器未必真的这么做了,编译器自己会根据具体情况作出判断。
  2. 有些你没注意到的写法可能导致一个函数被隐式inlining,例如将函数的声明和实现均放在头文件中。

 

  • [详解]
1. 隐式的inline申请:在头文件中声明class成员函数(或者friend函数)时同时实现该函数。
class person
{
public:
     ...
     int age() {return m_age;} // 该函数会被隐式申请为inline函数
     ...
private:
     int m_age;
};

这样的函数通常是成员函数,但是如果把friend函数的实现也放在头文件内,那么该friend函数也会被隐式申请为inline。

例如:

class dummy
{
public:
    explicit dummy(int i) : m_data(i)
    {}
private:
    int m_data;

    friend void swap(dummy& lhs, dummy& rhs)
    {
        int temp = lhs.m_data;
        lhs.m_data = rhs.m_data;
        rhs.m_data = temp;
    }
}; 
2. 显式申请inline函数的做法是在其定义前加上inline关键字。
template <typename T>
inline const T&  max(const T& a, const T& b)
{return a >b ? a : b;}
 
3. inline函数与template函数通常都被定义于头文件内,这会造成误解:function templates 一定必须是inline。
但这个结论不仅无效且可能有害。因为:
inline函数之所以一般被置于头文件内,那是因为大多数编译环境(building enviroment)是在编译过程中进行inlining,为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道该函数的实现内容。虽然有些编译环境可以在链接期完成inlining,甚至.NET CLI的托管环境可以在运行期完成inlining,但大多数C++编译环境是在编译期完成inlining行为的。
 
同样,template函数的实现一般被放在头文件中也是因为模版函数的实例化一般也是在编译期完成的,因而编译器需要知道函数的实现内容(某些编译环境可以在链接期完成模板实例化,但这不常见)。
 
因此,template与inline无必然联系,如果你想让根据template函数实例化出的所有函数都应该是inlined,那么你就需要将此template函数声明为inline。否则,你应该避免这么做,因为inlining需要成本(如引发代码膨胀等等)。
 
4. 编译器拒绝将过于复杂(带有循环或者递归)的函数inlining,并且所有的virtual函数的调用都会使inlining落空。
因为virtual函数意味在运行期才能动态地决定哪一个函数被调用,但是inline意味着在编译器就需要将被函数的调用替换成函数的本体。
 
5. 另外,如果程序中要取得某个inline函数的地址,编译器通常会为此函数生成一个outlined函数本体。
因为编译器没有能力产生一个指针指向并不存在的函数。同时,编译器通常不对“通过函数指针而进行的调用”实施inlining动作,即:对inline函数的调用有可能被inlined,也有可能不被inlined,这取决于该调用是如何实施的:
inline void fn() {…} // 假设编译器愿意inline“对fn的调用”
void (*pf) () = fn; // pf指向fn
...
fn(); // 该调用会被inlined,因为这是一个正常调用。
pf(); // 该调用不会被inlined, 因为它是通过函数指针实施。
 
所以,一个inline函数是否真的被inlining,取决于编译器的判断。
 
6. 即使程序员自己从未使用函数指针,编译器有时候还是会生成构造函数和析构函数的outline副本。
这样一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构中使用。
 
实际上构造函数和析构函数往往是不适合被inlined的。
因为虽然程序员定义了一个空的构造/析构函数,但并不意味着编译后,该构造/析构函数的实现一定是空的,因为编译器会在编译器间产生并安插代码到构造函数或者析构函数中。
因为C++指出,当创建一个对象时,每一个base class及其每一个成员变量都会被构造;当销毁一个对象时,反向的析构动作也会发生。如果有异常在对象构造期间被抛出,该对象已构造好的那一部分会被自动销毁。这些动作的实现代码就会又编译器代为产生并安插到derived class的构造或者析构函数中。
不管编译器具体产生了什么样的代码,derived构造函数一定会调用其自身成员变量和base class两者的构造函数,而这些调用会影响编译器是否对此“空白构造/析构函数”进行inlining。
 
7. 将函数声明为inline还会为程序开发过程带来冲击。
 
inline函数无法随着程序库的升级而升级,如果fn是程序库中的一个inline函数,所有调用了fn的“客户程序”都会将fn函数本体编译到其程序中,一旦程序库设计者改变了fn,所有用到fn的“客户程序”都需要被重新编译。
如果fn不是inline函数,一旦它有任何修改,“客户程序”只需要重新链接就好(如果是静态链接),远比重新编译负担少。如果程序库使用静态链接,fn的改动甚至不会被“客户程序”察觉。
 
另外,inline函数会给调试带来麻烦,因为无法在一个并不存在的函数中设立断点,从而导致许多编译环境会选择在调试版程序(DEBUG)中禁止发生linlining。
 
  • [总结]
  1. 一个合乎逻辑的策略是:一开始不要讲任何函数声明为inline,或至少将inlining局限于小型的、被频繁调用的函数身上。这会使得日后的调试和二进制升级更容易,也可使代码膨胀的问题最小化,使程序的速度提升机会最大化。
  2. 不要只因为function template出现在头文件中,就将它们声明为inline。
  • [补充]

From:http://www.cnblogs.com/xkfz007/archive/2012/03/27/2420166.html

不恰当地使用inline导致编译器拒绝进行inlining是会带来副作用的,这会带来代码膨胀(目标码膨胀)和可能极难察觉的bug。因为编译器对普通函数(没有声明为inline)的实现与对inlining失败的函数的实现是不同的。

普通函数在编译时被单独编译为一个对象,包含在相应的目标文件中。目标文件在链接时,对该函数的调用会被链接到该对象上。

若一个函数被声明为inline,那么编译器即使遇到该函数的声明也不会为该函数编译一个对象,因为inline函数是在调用的地方进行展开的。但是如果在调用的地方发现该inline函数不适合被展开怎么办?一种做法是在调用该内联函数的目标文件中为该内联函数编译一个对象。这么做的直接后果是:若在多个文件调用了内联失败的函数,其中每个文件对应的目标文件中都会包含一份该内联函数的目标代码。

如果编译器真的选择了上面的做法对待内联失败的函数,那么目标代码的体积膨胀得与成功内联的目标代码一样,但目标代码的效率确和没内联一样。

更糟的是由于存在多份函数目标代码带来一些程序bug。最明显的例子是:内联失败的函数内的静态变量实际上就不在只有一份,而是有若干份。这显然是个错误,但是如果不了解内幕就很难找到原因。 

posted @ 2015-03-28 17:01  Eric Z  阅读(741)  评论(0编辑  收藏  举报