2007年12月10日星期一

Understand inline

(转载)
inline 函数在调用时不仅仅可以避免函数调用的成本。在典型情况下,编译器的优化是为了一段连续的没有函数调用的代码设计的,所以当你 inline化一个函数,你可能就使得编译器能够对函数体实行上下文相关的特殊优化。大多数编译器都不会对"outlined"函数调用实行这样的优化。


然而inline 函数背后的思想是用函数本体代替每一处对这个函数的调用,这样可能会增加你的目标代码的大小。在有限内存的机器上,过分热衷于inline 化会使得程序对于可用空间来说过于庞大。即使使用了虚拟内存,inline 引起的代码膨胀也会导致附加的分页调度,减少指令缓存命中率,以及随之而来的性能损失。

在另一方面,如果一个inline 函数本体很短,为函数本体生成的代码可能比为一个函数调用生成的代码还要小。如果是这种情况,inline化这个函数可以导致更小的目标代码和更高的指令缓存命中率!

记住,inline是向编译器发出的一个请求,而不是一个命令。这个请求能够以显式的或隐式的方式提出:

隐式方法就是在一个类定义的内部定义一个函数:
class Person {

 public:
  ...
  int age() const { return theAge; } // an implicit inline request: age is
  ... // defined in a class definition  
private:

  int theAge;
};
这样的函数通常是成员函数,不过我们知道友元函数也能被定义在类的内部,如果它们在那里,它们也被隐式地声明为 inline。

显式声明一个inline函数的方法是在它的声明和定义之前加上inline 关键字。例如,以下就是标准 max 模板(来自 )经常用到的的实现方法:
template // an explicit inline

inline const T& std::max(const T& a, const T& b) // request: std::max is
{ return a < b ? b : a } //precede by "inline"
max 是一个模板的事实引出一个观察结论:inline 函数和模板一般都是定义在头文件中的。这就使得一些程序员得出结论断定函数模板必须是 inline。这个结论是非法的而且有潜在的危害,所以它值得我们考察一下。

inline 函数一般必须在头文件内,因为大多数构建环境在编译期间进行 inline 化。为了用被调用函数的函数本体替换一个函数调用,编译器必须知道函数看 起来像什么样子。有一些构建环境可以在连接期间进行inline 化,还有少数几个--比如,基于.NET Common Language Infrastructure (CLI)的控制环境--居然能在运行时inline化。然而,这些环境都是例外,并非规则。inline 化在大多数C++程序中是一个编译时行为。
模板一般在头文件内,因为编译器需要知道一个模板看起来像什么以便用到它时对它进行实例化。同样,也不是全部如此。一些构建环境可以在连接期间进行模板 实例化。然而,编译期实例化更为普遍。

模板实例化与inline化无关。如果你写了一个模板,而且你认为所有从这个模板实例化出来的函数都应该是inline的,那么就声明这个模板为inline,这就是上面的std::max的实现被做的事情。但是如果你为没有理由要inline化的函数写了一个模板,就要避免声明这个模板为inline(无论显式的还是隐式的)。inline化是有成本的,而且你不希望在毫无预见的情况下遭遇它们。我们已经说到inline化是如何引起代码膨胀的,但是它还有其它的成本
,过一会儿我们再讨论

在做这件事之前,我们先来完成对这个结论的考察:inline是一个编译器可能忽略的请求。大多数编译器拒绝它们认为太复杂的 inline 函数(例如,那些包含循环或者递归的),而且,除了最细碎的以外的全部虚函数的调用都不会被inline化。不应该对后一个结论感到惊讶 -- 虚拟意味着“等待,直到运行时才能断定哪一个函数被调用”,而inline意味着“执行之前,用被调用函数取代调用的地方”。如果编译器不知道哪一个函数将被调用,你很难责备它们拒绝inline化这个函数本体。

所有这些加在一起得出:一个被指定的inline函数是否能真的被inline化,取决于你所使用的构建环境--主要是编译器。幸运的是,大多数编译器都有一个诊断层次,在它们不能inline化一个你提出的函数时,会导致一个警告。

有时候,即使编译器完全心甘情愿地inline化一个函数,它们还是会为这个inline函数生成outlined函数本体。例如,如果你的程序要持有一个inline函数的地址,编译器必须为它生成一个outlined 函数本体。它们怎么能生成一个指向根本不存在的函数的指针呢?再加上,编译器一般不会对通过函数指针的调用进行inline化,这就意味着,对一个inline函数的调用可能被也可能不被inline 化,依赖于这个调用是如何做成的

inline void f() {...} // assume compilers are willing to inline calls to f

void (*pf)() = f; // pf points to f
...

f(); // this call will be inlined, because it’s a "normal" call
pf(); // this call probably won’t be, because it’s through a function pointer

甚至在你从来没有使用函数指针的时候,未inline化的inline函数的幽灵也会时不时地拜访你,因为程序员并不必然是函数指针的唯一需求者。有时候编译器会生成构造函数和析构函数的 out-of-line 拷贝,以便它们能得到指向这些函数的指针,在对数组中的对象进行构造和析构时使用。

事实上,构造函数和析构函数的inline化经常会产生误解。例如,考虑下面这个类 Derived 的构造函数:

class Base {
 public:
  ...

 private:
  std::string bm1, bm2; // base members 1 and 2
};

class Derived: public Base {
 public:
  Derived() {} // Derived’s ctor is empty - or is it?
  ...

 private:
  std::string dm1, dm2, dm3; // derived members 1-3
};

这个构造函数看上去像一个inline化的极好的候选者,因为它不包含代码。但是视觉会被欺骗。

C++为对象被创建和被销毁时所发生的事情做出了各种保证。例如,当你使用new时,你动态创建对象会被它们的构造函数自动初始化,而当你使用delete时相应的析构函数会被调用。当你创建一个对象时,这个对象的每一个基类和每一个数据成员都会自动构造,而当一个对象被销毁时,则发生关于析构的反向过程。如果在一个对象构造期间有一个异常被抛出,这个对象已经完成构造的任何部分都被自动销毁。所有这些情节,C++只说什么必须发生,但没有说如何发生。那是编译器的实现者的事,但显然这些事情不会自己发生。在你的程序中必须有一些代码使这些事发生,而这些代码--由 编译器写出的代码和在编译期间插入你的程序的代码--必须位于某处。有时它们最终就位于构造函数和析构函数中,所以我们可以设想实现为上面那个声称为空的 Derived 的构造函数生成的代码就相当于下面这样:

Derived::Derived() // conceptual implementation of
{
 // "empty" Derived ctor

 Base::Base(); // initialize Base part

 try { dm1.std::string::string(); } // try to construct dm1
 catch (...) { // if it throws,
  Base::~Base(); // destroy base class part and
 throw; // propagate the exception
}

try { dm2.std::string::string(); } // try to construct dm2
catch(...) {
 // if it throws,
 dm1.std::string::~string(); // destroy dm1,
 Base::~Base(); // destroy base class part, and
throw; // propagate the exception
}

try { dm3.std::string::string(); } // construct dm3
catch(...) {
 // if it throws,
 dm2.std::string::~string(); // destroy dm2,
 dm1.std::string::~string(); // destroy dm1,
 Base::~Base(); // destroy base class part, and
throw; // propagate the exception
}
}

这些代码并不代表真正的编译器所生成的,因为真正的编译器会用更复杂的方法处理异常。尽管如此,它还是准确地反映了Derived的“空”构造函数必须提供的行为。不论一个编译器的异常多么复杂,Derived的构造函数至少必须调用它的数据成员和基类的构造函数,而这些调用(它们自己也可能是inline的)会影响它对于inline化的吸引力。

同样的原因也适用于Base的构造函数,所以如果它是inline的,插入它的全部代码也要插入 Derived的构造函数(通过 Derived 的构造函数对Base的构造函数的调用)。而且如果string的构造函数碰巧也是inline的,Derived的构造函数中将增加五个那个函数代码的拷贝,分别对应于 Derived 对象中的五个strings(两个继承的加上三个它自己声明的)。也许在现在,为什么说是否inline化Derived的构造函数并不确定就很清楚了。类似的考虑也适用于Derived的析构函数,用同样的或者不同的方法,必须保证所有被Derived的构造函数初始化的对象被完全销毁。

库设计者必须评估声明函数为inline的影响,因为为客户可见的inline函数提供二进制升级版本是不可能的。换句话说,如果f 是一个库中的一个inline函数,库的客户将函数f 的本体编译到他们的应用程序中。如果一个库的实现者后来决定修改f,所有使用了f 的客户都必须重新编译。这常常会令人厌烦。在另一方面,如果f 是一个非inline函数,对f 的改变只需要客户重新连接。这与重新编译相比显然减轻了很大的负担,而且,如果库中包含的函数是动态链接的,这就是一种对于用户来说完全透明的方法。
 
为了程序开发的目标,在头脑中牢记这些需要考虑的事项是很重要的,但是从编码期间的实用观点来看,占有支配地位的事实是:大多数调试器会与inline函数发生冲突。这不应该是什么重大的发现。你怎么能在一个不在那里的函数中设置断点呢?虽然一些构建环境设法支持 inline函数的调试,多数环境还是简单地为调试构建取消了inline化。

这就导出了一个用于决定哪些函数应该被声明为inline,哪些不应该的合乎逻辑的策略。最初,不要inline任何东西,或者至少要将你的inline化的范围限制在那些必须inline的和那些实在微不足道的函数上。通过慎重地使用inline,你可以使调试器的使用变得容易,但是你也将inline化放在了它本来应该在的地位:作为一种手动的优化。不要忘记由经验确定的80-20规则,它宣称一个典型的程序用80%的时间执行20%的代码。这是一个重要的规则,因为它提醒你作为一个软件开发者的目标是识别出能全面提升你的程序性能的20%的代码。你可以 inline 或者用其他方式无限期地调节你的函数,但除非你将精力集中在正确的函数上,否则就是白白浪费精力。

Things to Remember:

  • 将大部分inline限制在小的,调用频繁的函数上。这使得程序调试和二进制升级更加容易,最小化潜在的代码膨胀,并最大化提高程序速度的几率。
  • 不要仅仅因为函数模板出现在头文件中,就将它声明为 inline。

没有评论:

World Clocks

Endless Space Headline Animator

Mobile Ads