Effective C++条款30:透彻了解inlining的里里外外(Understand the ins and outs of inlining)
- 条款30:透彻了解inlining的里里外外
- 1、inline函数的优缺点
- 2、隐式内联和显式内联
- 2.1 隐式内联
- 2.2 显式内联
- 3、函数模板必须inline么?
- 4、编译器忽略内联的情况
- 5、构造函数和析构函数是否要被inline?
- 6、牢记
- 总结
《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:
第5章:实现
条款30:透彻了解inlining的里里外外
1、inline函数的优缺点
优点
- 避免函数调用的开销
实际上比你想象的要获取的更多,因为避免函数调用的开销只是这个故事的一部分。编译器最优化是为了浓缩没有函数调用的代码而设计,所以当你inline一个函数时,你可能使编译器在函数体上执行特定场景下的优化操作。大多数编译器不会在outlined的函数调用上执行这样的优化。
缺点
-
以函数体代替函数调用,因此目标码增大。
-
在内存有限的机器上,过度的inlining会造成占用空间过大的问题。
-
即使拥有虚内存,inline造成的代码膨胀也会造成额外的换页行为,降低指令高速缓存装置的集中率,以及伴随效率的损失。
2、隐式内联和显式内联
需要注意的是inline是对编译器的请求而不是强制命令。请求可以显示或者隐式的提出来。
2.1 隐式内联
隐式内联:当成员函数定义在类的内部时,这个函数是隐式inline的(隐式内联只有这一种情况)。例如:
class Person {
public:
//隐式内联(编译器自动申请),这个函数(age)不仅在类中声明,还在类中进行了定义
int age()const { return theAge; }
private:
int theAge;
};
这样的函数通常是成员函数,但是条款46中解释道friend函数也能在类中定义。如果是这样,它们也会被隐式声明成inline。
2.2 显式内联
显式内联:我们也可以通过inline关键字显式的指出一个函数作为内联函数。例如:
template<typename T>
inline const T& std::max(const T& a, const T& b)
{
return a < b ? b : a;
}
3、函数模板必须inline么?
max是个template可以让让我们联想到inline函数和模板通常被定义在头文件中的。因此一些程序要就下结论函数模板就必须是inline的。这个结论既无效并可能会有潜在的危害,值得我们对此进行分析。
inline函数通常被置于头文件内,因为大多数建置环境在编译过程中进行inlining,编译器必须了解这个函数长成什么样子。某些编译环境能够在链接的时候执行内联,甚至有一些能够在运行时进行内联(如基于.NET CLI的托管环境),这样的环境都是例外,但不是通用规则。在大多数C++程序中inline是编译时活动。
template模板通常也被置于头文件内,因此它一旦被使用,编译器为了将其实例化,也需要知道它长什么样子。
template的具现化与inlining无关:
-
如果你写的模板认为具体实现处的函数应该是inlining的,那么就将template声明为inline
-
如果你写的代码没有理由应该是inlining的,那么就将不要将template声明为inline(因为可能会产生代码膨胀)
4、编译器忽略内联的情况
即使你将函数声明为inline的,inline也只是一个对编译器的请求,而编译器可能会将其忽略。例如:
-
太过复杂的函数:例如带有循环或递归
-
对virtual函数的调用:virtual意味着“只有在运行时才能决定调用哪个函数,”而inline意味着“执行程序之前,在调用点处用函数体进行替换”。如果编译器不知道将会调用哪个函数,你就不能因为拒绝为函数体内联而责备它。
这些叙述整合起来的意思就是:
- 一个表面看似inline的函数,或者显式使用inline声明的函数,到底是不是一个内联函数,取决于你所使用的编译环境——而这个编译环境主要是只编译器。幸运的是,编译器会对这个过程进行诊断,如果inline一个函数失败了,它会发出一个警告(见条款53)。
有时候即使编译器迫切的希望对函数进行inline,它们也会为其生成一个单独的函数体。例如,如果你的程序需要获知内联函数的地址,编译器就必须为其生成一个outline的函数体。它们不能使用一个不存在的函数指针吧?加上如下事实:编译器使用函数指针进行函数调用时不会为其进行inline,这意味着对内联函数的调用可能会被内联也可能不会,取决于函数调用是如何进行的:
inline void f() {...} //假设编译器有意愿inline对f的调用
void (*pf )() = f; //pf指向f
...
f(); //这个调用将被inlined,因为它是一个正常的调用
pf(); //这个调用或许不被inlined,因为它通过函数指针调用
未被inline的inline函数还是会缠住你,即使你从未使用函数指针也是如此,因为并不是只有程序员才会需要函数指针。有时候编译器也会为构造函数和析构函数生成一份outline副本,这样一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构过程中使用。
5、构造函数和析构函数是否要被inline?
构造函数和析构函数通常情况下是inline函数的槽糕候选人,而不像表面看上去那样,考虑以下Derived class的构造函数:
class Base{
public:
//...
private:
std::string bm1, bm2;
};
class Derived :public Base {
public:
Derived() {} //构造函数为空
private:
std::string dm1, dm2, dm3;
};
上面的Derived构造函数为空,此时你可能会认为Derived的构造函数时inlining的,但是实际上不是这样的。
我们知道当对象被创建或者析构的时候C++必须保证一些事情的发生:
-
当你使用new的时候,你的动态创建的对象由它们的构造函数自动初始化;当你使用delete时,对应的析构函数要被触发。
-
当你创建一个对象时,对象的基类部分和它的每个数据成员都会被自动构建,当对象被销毁的时候相反的过程也就是自动析构就会发生。
-
如果在构造或者析构的时候抛出异常,已经被构建出来的对象的任何部分都应该被自动释放。
上面的Derived的构造函数虽然为空,但是其有3个数据成员,基类有2个数据成员。下面是伪代码,编译器会自动为这些数据成员进行初始化:
//伪代码
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的吸引力。
同样的原因适用于基类构造函数,因此如果它被inline了,它里面的代码同样会被插入到Derived构造函数中(Derived构造函数会调用基类构造函数。)。并且如果string构造函数恰恰也被inline了,Derived构造函数会增加5份函数代码的拷贝(对应Derived中的5个string),现在你应该明白了为什么对Derived构造函数进行inline是一个没脑子的决定。同样的考虑也适用于Derived析构函数,我们必须看到被Derived构造函数初始化的对象被合适的销毁掉。
6、牢记
-
将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
-
不要只因为function templates出现在头文件,就将它们声明为inline。
总结
期待大家和我交流,留言或者私信,一起学习,一起进步!