洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目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;           // 基类成员12

};

 

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%的这部分进行优化,从而从整体上提高程序的性能。你可以花费漫长的时间进行内联、修改函数等等,但如果你没有锁定正确的目标,那么你做再多的努力也是徒劳。

时刻牢记

仅仅对小型的、调用频率高的程序进行内联。这将简化你的调试操作,提供更灵活的目标代码可更新性,降低潜在的代码膨胀发生的可能,并且可以让程序获得更高的速度。

不要将模板声明为inline的,因为它们一般在头文件中出现。

posted on 2007-11-18 23:27 ★ROY★ 阅读(1567) 评论(1)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【读书笔记】[Effective C++第3版][第30条] 深入探究内联函数  回复  更多评论   

我怎么看ACE的源码里这么多宏,他还是高性能框架呢
2007-11-19 21:22 | evoup

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理