第30条: 深入探究内联函数
内联函数——多么振奋人心的一项发明!它们看上去与函数很相像,它们拥有与函数类似的行为,它们要比宏(参见第 2 条)好用的多,同时你在调用它们时带来的开销比一般函数小得多。可谓“内联在手,别无他求。”
你得到的远远比你想象的要多,因为节约函数调用的开销仅仅是冰山一角。编译器优化通常是针对那些没有函数调用的代码,因此当你编写内联函数时,编译器就会针对函数体的上下文进行优化工作。然而大多数编译器都不会针对“外联”函数调用进行优化。
然而,在你的编程生涯中,“没有免费的午餐”这句生活哲言同样奏效,内联函数不会幸免。内联函数背后蕴含的理念是:用代码本体来取代每次函数调用,这样做很可能会是目标代码的体积增大不少,这一点并不是非要统计学博士才能看得清。对于内存空间有限的机器而言,过分热衷于使用内联则会造成函数占用过多的空间。即使在虚拟内存中,那些冗余的内联代码也会带来不少无谓的分页,从而使缓存读取命中率降低,最终带来性能的牺牲。
另一方面,如果一个内联函数体非常的短,那么为函数体所生成代码的体积就会比为函数调用生成的代码小一些。此时,内联函数才真正做到了减小目标代码和提高缓存读取命中率的目的。
我们要时刻保持清醒, Inline 是对编译器的一次请求,而不是一条命令。这种请求可以显式提出也可以隐式提出。隐式请求的途径就是:在类定义的内部定义函数:
class Person {
public:
...
int age() const { return theAge; }// 隐式内联请求 :
... // 年龄 age 在类定义中做出定义
private:
int theAge;
};
这样的函数通常是成员函数,但是类中定义的函数也可以是友元(参见第 46 条),如果函数是友元,那么也应隐式将它们定义为内联函数。
显式声明内联函数的方法为:在函数定义之前添加 inline 关键字。比如说,下面是标准 max 模板(来自 <algorithm> )通常的定义方式:
template<typename T> // 显式内联请求:
inline const T& std::max(const T& a, const T& b)
{ return a < b ? b : a; } // 在 std::max 的前边添加 ”inline”
max 是一个模板这一事实,让我们不免得出这样的推论:内联函数和模 板都应该在头文件中定义。这就使一些程序员做出“函数模板必须为内联函数”的论断。这一结论不仅不合法,而且也存在潜在的害处,所以这里我们还是要大略的了解一下。
由于大多数构建环境都是在编译过程中进行内联,因此内联函数一般情况下都应该定义在头文件中。编译器必须首先了解函数的大致情况,以便于用所调用函数体来代替这次函数调用。(一些构建环境在连接过程中进行内联,还有个别基于 .NET 通用语言基础结构( CLI )的托管环境甚至是在运行时进行内联。这样的环境仅仅属于例外,而不是守则。在大多数 C++ 程序中,内联是一个编译时行为。)
模板通常保存在头文件中,但是编译器还需要了解模板的大致情形,以便于在用到时进行正确的实例化。(然而,这并不是一成不变的。一些构建环境在连接时进行模板实例化。但是编译时实例化才是更通用的方式。)
模板实例化相对于内联是独立的。如果你正在编写一个模板,而你又确信由这个模板所实例化出的所有函数都应该是内联的,那么这个模板就应该添 加 inline 关键字;这也就是上文中 std::max 实 现的做法。但是如果你正在编写的模板并不需要实例化内联函数,那么就不需要声明内联模板(无论是显式还是隐式)。内联也是有开销的,不假思索就引入内联的开销的做法并不明智。我们已经介绍过了内联是如何使代码膨胀起来的(对于模板的作者而言,还应该做更周密的考虑——参见第 44 条),但是内联还会带来其他的开销,这就是下文中我们将要讨论的问题。
inline 是对编译器的一次请求,但编译器可能会忽略它。在我们的讨论开 始之前,我们首先要弄清这一点。大多数编译器如果认为当前的函数过于复杂(比如包括循环或递归的函数),或者这个函数是虚函数(即使是最平常的虚函数调用),就会拒绝将其内联。后一个结论很好理解。因为 virtual 意味着“等到运行时再指出要调用哪个程序,”而 inline 意味着“在执行程序之前,使用要调用的函数来代替这次调用。”如果编译器不知道要调用哪个函数,那么它们拒绝内联函数体的做法就无可厚非了。
综上所述,我们得出下面的结论:一个给定的函数是否得到内联,取决于你正在使用的构建环境——主要是编译器。幸运的是,大多数编译器拥有诊断机制,如果编译器在内联函数时失败了,那么它们将会做出警告(参见第 53 条)。
有些时候,即使编译器认为某个函数非常适合进行内联,可是还是会为它提供一个函数体。举例说,如果你的程序要取得某个内联函数的地址,那么编译器必须用典型的方法为其创建一个外联的函数体。那么编译器又怎样让一个指针去指向一个不存在的函数呢?再加上编译器一般不会通过对函数指针的调用进行内联这一事实,更能肯定这一结论:对于一个内联函数的调用是否应该得到内联,取决于这一调用是如何进行的:
inline void f() {...} // 假设编译器乐意于将 f 的调用进行内联
void (*pf)() = f; // pf 指向 f
...
f(); // 此调用将被内联,因为这是一次“正常”的调用
pf(); // 此调用很可能不会被内联,
// 因为它是通过一个函数指针进行的
即使你从未使用函数指针,未得到内联的函数依然“阴魂不散”,这是因为需求函数指针的不仅仅是程序员。比如,编译器在为对象的数组进行构造或析构时,也会生成构造函数和析构函数的外联副本,从而使它们可以得到这些函数的指针以便使用。
实际上,为构造函数和析构函数进行内联通常不是一个好的选择,这两者甚至不如一些随意挑选的“选手”。请看下面示例 中 Derived 类的构 造函数:
class Base {
public:
...
private:
std::string bm1, bm2; // 基类成员 1 和 2
};
class Derived: public Base {
public:
Derived() {} // 派生类的构造函数为空 — 还有别的可能 ?
...
private:
std::string dm1, dm2, dm3;// 派生类成员 1–3
};
乍看上去,将这个构造函数进行内联再适合不过了,因为它不包含任何代码。其实你的眼睛欺骗了你。
C++ 对于在创建和销毁对象的过程中发生的事件进行了多方面的保证。比如,当你使用 new 时,你动态创建的对象的构造函数就会自动将其初始化;当你使用 delete 时,将调用相关的析构函数。当你创建一个对象时。每个基类和该对象中的每个数据成员将自动得到构造,在销毁这个对象时,针对两者的析构过程将会自动进行。如果在对象的构造过程中有异常抛出,那么对象中已经得到构造的部分将统统被自动销毁。在所有这些场景中, C++ 告诉你什么一定会发生,但它没有说明如何发生。这一点取决于编译器的实现者,但是必须要清楚的一点是,这些事情并不是自发的。你必须要在程序中添加一些代码来实现它们。这些代码一定存在于某处,它们由编译器代劳,用于在编译过程中插入你的程序中。一些时候它们就存在于构造函数和析构函数中,所以,对于上文中 Derived 的空构造函数,我们可以将具体实现中生成的代码等价看作:
Derived::Derived() // Derived 空构造函数的抽象实现
{
Base::Base(); // 初始化 Base 部分
try { dm1.std::string::string(); } // 尝试构造 dm1
catch (...) { // 如果抛出异常 ,
Base::~Base(); // 销毁基类部分 ,
throw; // 并且传播该异常
}
try { dm2.std::string::string(); } // 尝试构造 dm2
catch(...) { // 如果抛出异常 ,
dm1.std::string::~string(); // 销毁 dm1,
Base::~Base(); // 销毁基类部分 ,
throw; // 并且传播该异常
}
try { dm3.std::string::string(); } // 尝试构造 dm3
catch(...) { // 如果抛出异常 ,
dm2.std::string::~string(); // 销毁 dm2,
dm1.std::string::~string(); // 销毁 dm1,
Base::~Base(); // 销毁基类部分 ,
throw; // 并且传播该异常
}
}
这段代码并不能完全真实反映出编译器所做的事情,因为真实的编译器采用的做法更加复杂。然而,上面的代码可以较为精确地反映出 Derived 的“空”构造函数必须要提供的内容。无论编译器处理异常的实现方式多么复杂, Derived 的构造函数必须至少为其数据成员和基类调用构造函数,这些调用(可能就是内联的)会使 Derived 显得不那么适合进行内联。
这 一推理过程对于 Base 的构造函数同样适用,因此如果将 Base 内联,所有添加进其中的代码同样也会添加进 Derived 的构造函数中(通过 Derived 构造函数调用 Base 构造函数的过程)。同时,如果 string 的构造函数恰巧被内联了,那么 Derived 的构造函数将为其复制出五份副本,分别对应 Derived 对象中包含的五个字符串(两个继承而来,另外三个系对象本身包括)。现在,“ Derived 的构造函数是否应该内联不是一个纯机械化问题”就很容易理解了。对于 Derived 的析构函数也一样,你必须亲自关注 Derived 的构造函数初始化的对象是否全部恰当的得到销毁,这一点机器无法代替。
库设计者必须估算出将函数内联所带来的影响,因为你根本无法为库中客户端程序员可见的内联函数提供底层的升级。换句话说,如果 f 是库中的一个内联函数,那么库的客户端程序员就会将 f 的函数体编译进他们的程序中。随后,如果一个库实现者修改了 f 的内容,那么所有曾经使用过 f 的客户端程序员必须要重新编译他们的代码。这一点是我们所不希望看到的。另一个角度讲,如果 f 不是内联函数,那么修改 f 只需要客户端程序员重新连接一下就可以了。这样要比重新编译减少很多繁杂的工作,并且,如果库中需要使用的函数是动态链接的,那么它对于客户端程序员就是完全透明的。
我们的目标是开发优质的程序,因此要将这些重要问题牢记在心。但是以编写代码实际操作的角度来说,这一个事实将淹没一切:大多数调试人员面对内联函数时会遇到麻烦。这并不会令人意外,因为你无法为一个尚不存在的函数设定一个跟踪点。一些构建环境试图支持内联函数的调试,但是几乎都失败了,大多数环境都是在调试过程中直接禁止内联。
对于“哪个函数应该声明为 inline 而哪些不应该”这一问题,我们可以由上文中引出一个逻辑上的策略。起初,不要内联任何内容,或者仅挑选出那些不得不内联的函数(参见第 46 条)或者那些确实是很细小的程序(比如本节开篇处出现的 Person::age )进行内联。谨慎引入内联,你就为调试工作提供了方便,但是你仍然要为内联摆正位置:它属于手工的优化操作。不要忘记 80-20 经验决定主义原则:一个典型的程序将花去 80% 的时间仅仅运行 20% 的代码。这是一个非常重要的原则,因为它时时刻刻提醒我们,软件开发者的目标是:找出你的代码中 20% 的这部分进行优化,从而从整体上提高程序的性能。你可以花费很长的时间进行内联、修改函数等等,但如果你没有锁定正确的目标,那么你做再多的努力也是徒劳。
铭记在心
l 仅仅对小型的、调用频率高的程序进行内联。这将简化你的调试操作,为底层更新提供方便,降低潜在的代码膨胀发生的可能,并且可以让程序获得更高的速度。
l 不要将模板声明为 inline 的,因为它们一般在头文件中出现。