C++箴言:理解inline化的介入和排除
inline 函数——多么棒的主意啊!它们看起来像函数,它们产生的效果也像函数,它们在各方面都比宏好得太多太多,而您却可以在调用它们时不招致函数调用的成本。您还有什么很多的需要呢?
实际上您得到的可能比您想的很多,因为避免函数调用的成本只是故事的一部分。在典型情况下,编译器的优化是为了一段连续的没有函数调用的代码设计的,所以当您 inline 化一个函数,您可能就促使编译器能够对函数体实行上下文相关的特殊优化。大多数编译器都不会对 "outlined" 函数调用实行这种的优化。
然而,在编程中,就像在生活中,没有免费午餐,而 inline 函数也不例外。一个 inline 函数背后的思想是用函数本体代替每一处对这种函数的调用,而且不必拿着统计表中的 Ph.D. 就能够看出这种可能会增加您的目标代码的大小。在有限内存的机器上,过分热衷于 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 主要字。打个比方,下面可以标准 max 模板(来自 )经经常使用到的的做的更好做法:
max 是一个模板的实际引出一个观察结论:inline 函数和模板那么都是定义在头文件中的。这就促使多数程序员得出结论断定函数模板必须是 inline。这种结论是非法的而且有潜在的危害,所以它值得我们考察一下。 inline 函数那么必须在头文件内,因为大多数构架环境在编译期间进行 inline 化。为了用被调用函数的函数本体替换一个函数调用,编译器必须知道函数看起来像什么样子。(有多数构架环境可以在连接期间进行 inline 化,还有少数几个——打个比方,基于 .NET Common Language Infrastructure (CLI) 的控制环境——居然能在运行时 inline 化。然而,这一系列环境都是例外,并非规则。inline 化在大多数 C++ 程序中是一个编译时行为。)template // an explicit inline
inline const T& std::max(const T& a, const T& b) // request: std::max is
{ return a < b ? b : a; } // preceded by "inline"
模板那么在头文件内,因为编译器就得知道一个模板看起来像什么以便用到它时对它进行实例化。(一样,也不可能所有那么。多数构架环境可以在连接期间进行模板实例化。然而,编译期实例化更为普遍。) 模板实例化与 inline 化没有关系。可能您写了一个模板,而且您认为所有根据这种模板实例化出来的函数都应该是 inline 的,当就声明这种模板为 inline,这可以上面的 std::max 的做的更好被做的事情。但是可能您为没有理由要 inline 化的函数写了一个模板,就要避免声明这种模板为 inline(不管显式的或者隐式的)。inline 化是有成本的,而且您不希望在毫没有预见的情况下遭遇它们。我们能够说到 inline 化是如何导致代码膨胀的,但是,还有别的的成本,过一会儿我们再讨论。
在做这件事之前,我们先来完成对这种结论的考察:inline 是一个编译器可能忽略的请求。大多数编译器拒绝它们认为太复杂的 inline 函数(打个比方,那些包含循环或者递归的),而且,除了最细碎的以外的所有虚拟函数的调用都不会被 inline 化。不应该对这后一个结论感到惊讶。虚拟代表着“等待,直到运行时才能断定哪一个函数被调用”,而 inline 代表着“执行之前,用被调用函数取代调用的地方”。可能编译器不知道哪一个函数用被调用,您很难责备它们拒绝 inline 化这种函数本体。
所有这一系列加在一起,得出:一个被指定的 inline 函数是不是能真的被 inline 化,取决于您所使用的构架环境——主要是编译器。幸运的是,大多数编译器都有一个诊断层次,在它们不可以 inline 化一个您提出的函数时,会导致一个警告。
有时候,即使当编译器完全心甘情愿地 inline 化一个函数,他们或者会为这种 inline 函数生成函数本体。打个比方,可能您的程序要持有一个 inline 函数的地址,编译器必须为它生成一个 outlined 函数本体。他们怎么能生成一个指向根本不存在的函数的指针呢?再加上,编译器那么不会对通过函数指针的调用进行 inline 化,这就代表着,对一个 inline 函数的调用可能被也可能不被 inline 化,依靠于这种调用是如何做成的:
在您根据来没有使用函数指针的时候,没有 inline 化的 inline 函数的幽灵也会时不时地拜访您,因为程序员并不必然是函数指针的唯一需要者。有时候编译器会生成构造函数和析构函数的 out-of-line 拷贝,以便它们能得到指向这一系列函数的指针,在对数组中的对象进行构造和析构时使用。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 化来说经常是一个比您在不经意的检查中所能显示出来的非常糟糕的候选者。打个比方,考虑下面这种类 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 的构造函数至少必须调用它的数据成员和基类的构造函数,而且这种些调用(它们个人也可能是 inline 的)会影响它对于 inline 化的吸引力。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
}
}
一样的原因也适用于 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。