Effective C++读书笔记(19)

条款30:透彻了解inlining的里里外外

Understand the ins and outs of inlining

inline函数看起来像函数,产生的效果也像函数,比宏好得多,而你却可以在调用它们时不招致函数调用的成本。实际上你得到的可能比你想的更多,因为避免函数调用的成本只是inline好处的一部分。当你 inline 化一个函数,编译器或许有能力对函数本体执行语境相关最优化。

然而,编程和生活一样没有免费午餐,inline函数也不例外。一个 inline 函数背后的思想是用函数本体代替每一处对这个函数的调用,这样可能会增加目标代码的大小。在有限内存的机器上,过分热衷于 inline 化会使得程序对于可用空间来说过于庞大。即使使用了虚拟内存,inline 引起的代码膨胀也会导致额外的换页行为(paging),降低指令高速缓存装置的命中率,以及伴随而来的效率损失。

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

记住,inline 是向编译器发出的一个请求,而不是一个命令。这个请求能够以显式或隐式方式提出。隐式的方法就是在类定义的内部定义一个函数:

class Person {
public:
    ...
    int age() const { return theAge; }
    // 一个隐喻的inline申请:age被定义于类定义式内
    ...
private:
    int theAge;
};

这样的函数通常是成员函数。而友元函数也能被定义在类的内部,如果真是那样,它们也被隐式地声明为 inline。

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

template<typenameT> // 显式inline
inline const T& std::max(const T& a, constT& b) { return a < b ? b : a; }
// std::max之前有关键字inline

inline 函数和模板一般都是定义在头文件中的。

inline 函数一般必须在头文件内,因为大多数建置环境在编译期间进行 inline 化。为了函数本体替换一个函数调用,编译器必须知道函数看起来像什么样子。模板一般在头文件内,因为编译器需要知道一个模板看起来像什么以便用到它时对它进行实例化(某些建置环境可以在连接期间进行inline和模板实例化,然而编译期实例化更为普遍)。

如果你写了一个模板,且认为所有从这个模板实例化的函数都应该是 inline 的,那么就声明这个模板为inline,这就是上面的 std::max 的实现被做的事情。但是如果你写的模板并非要求它所具现的每一个函数都是inline,就要避免声明这个模板为inline(无论显式的还是隐式的)。inline 化是有成本的,会引起代码膨胀。

大多数编译器拒绝它们认为太复杂的 inline 函数(例如包含循环或递归的),而且所有virtual函数(除最简单的外)都不会被 inline 化,因为virtual意味着“直到运行时才能断定哪一个函数被调用”,而 inline 需有“执行之前先将调用动作替换为被调用函数的本体”。

小结:一个被指定的 inline 函数是否能真的被inline 化,取决于你的构建环境和(主要是)编译器。幸运的是,大多数编译器在它们不能 inline 化一个函数时,会发出一个警告。

 

编译器一般不会对通过函数指针的调用进行 inline 化,这就意味着,对一个inline 函数的调用可能被也可能不被 inline 化,取决于这个调用是如何做成的:

inlinevoid f() {...} // 假设编译器有意愿inline“对f的调用”

void(*pf)() = f; // pf指向f

...

f();   // 正常调用,会被inline
pf();  // 通过函数指针的调用,不会被inline

 

事实上,构造函数和析构函数往往是inline 化的糟糕候选人。例如,考虑下面这个类 Derived 的构造函数:

class Base{
public:
    ...
private:
    std::string bm1, bm2; // base成员1和2
};

classDerived: public Base {
public:
    Derived() {} // Derived的构造函数看起来是空的
    ...
private:
    std::string dm1, dm2, dm3; // derived成员1,2,3
};

这个构造函数看上去像一个 inline 化的极好的候选者,因为它不包含代码。但是这个看起来是空的 Derived 构造函数生成的代码就相当于下面这样:

Derived::Derived()// “空白Derived构造函数”的观念性实现
{

    Base::Base(); // 初始化Base成分

    try { dm1.std::string::string(); }
    catch (...) {
        Base::~Base();
        throw;
    }

    try { dm2.std::string::string(); }
    catch(...) {
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }

    try { dm3.std::string::string(); }
    catch(...) {
        dm2.std::string::~string();
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
}

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

同样的原因也适用于 Base 的构造函数,所以如果它是 inline 的,插入它的全部代码也要插入 Derived 的构造函数(通过 Derived 的构造函数对 Base 的构造函数的调用)。类似的考虑也适用于 Derived 的析构函数,在那儿我们必须看到“被Derived构造函数初始化的所有对象”被一一销毁,无论以哪种方式进行。

库设计者必须评估声明函数为 inline 的影响。如果f是库中的一个inline函数,库客户将函数 f 的本体编译到他们的应用程序中。如果库实现者后来决定修改 f,则所有使用了 f 的客户都必须重新编译,这往往是大家不愿见到的。在另一方面,如果f是一个非 inline 函数,对 f 的改变只需要客户重新连接,这与重新编译相比显然减轻了很大的负担。

为了程序开发的目标,在头脑中牢记这些需要考虑的事项是很重要的,但是从实用观点来看:大多数调试器会与 inline 函数发生冲突。你无法在一个并不存在的函数中设置断点。多数建置环境仅仅只能“在调试版程序中禁止发生inline化”。

我们的策略:最初不要inline任何东西,或者至少要将你inline化的范围限制在那些必须inline 的和非常简单的函数上。通过慎重地使用inline,你可以使调试器的使用变得容易,不过这么一来也等于把自己推向手工最优化之路。不要忘记80-20 经验法则:一个典型的程序用 80%的时间执行20%的代码。这是一个重要的规则,因为它提醒你作为一个软件开发者的目标是识别出能全面提升你的程序性能的20%的代码。你可以用inline或其他方式无限期地调节你的函数,但除非你将精力集中在正确的函数上,否则就是白白浪费精力。

·    将大部分inline 限制在小的,调用频繁的函数上。这使得程序调试和二进制升级更加容易,最小化潜在的代码膨胀,并最大化提高程序速度的几率。

·    不要仅仅因为函数模板出现在头文件中,就将它声明为 inline。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值