Item 30:理解 inline 函数的里里外外
inline 函数
inline 函数的优缺点
内联函数的好处太多了:
- 它没有宏的那些缺点,而且不需要付出函数调用的代价。
- 同时也方便了编译器基于上下文的优化。
但 inline 函数也并非免费的午餐:
- 在有限内存的机器上,过分热衷于 inline 化会使得程序对于可用空间来说过于庞大。即使使用了虚拟内存,inline 引起的代码膨胀也会导致附加的分页调度,减少指令缓存命中率,以及随之而来的性能损失。
inline 函数声明方式
inline 只是对编译器的一个请求而非命令。该请求可以隐式地进行也可以显式地声明。
当你的函数较复杂(比如有循环、递归),或者是虚函数时,编译器很可能会拒绝把它 inline。因为虚函数调用只有运行时才能决定调用哪个,而 inline 是在编译器便要嵌入函数体。 有些编译器在 dianotics 级别编译时,会对拒绝 inline 给出 warning 。
隐式的办法便是把函数定义放在类的定义中:
class Person{
...
int age() const{ return _age;} // 这会生成一个inline函数!
};
例子中是成员函数,如果是友元函数也是一样的。除非友元函数定义在类的外面。
显式的声明则是使用 inline 限定符:
template<typename T>
inline const T& max(const T& a, const T& b){ return a<b ? b: a;}
模板与 inline
可能你也注意到了 inline 函数和模板一般都定义在头文件中。这是因为 inline 操作是在编译时进行的,而模板的实例化也是编译时进行的。 所以编译器时便需要知道它们的定义。
在绝大多数C++环境中,inline 都发生在编译期。有些环境下也可以在链接时进行 inline,尤其在.NET中可以运行时进行 inline。
但模板实例化和 inline 是两个过程,如果你的函数需要做成 inline 的就把它声明为 inline(也可以隐式地),否则仍然把它声明为正常的函数。
取函数地址
有些适合 inline 的函数编译器仍然不能把它inline,比如你要取一个函数的地址时:
inline void f(){}
void (*pf)() = f;
f(); // 这个调用将会被inline,它是个普通的函数调用
pf(); // 这个是通过指针调用的,不会被inline
构造/析构函数
构造析构函数看起来很适合 inline,但事实并非如此。我们知道C++会在对象创建和销毁时保证做很多事情,比如调用new时会导致构造函数被调用, 退出作用域时析构函数被调用,构造函数调用前成员对象的构造函数被调用,构造失败后成员对象被析构等等。
这些事情不是平白无故发生的,编译器会生成一些代码并在编译时插入你的程序。比如编译后一个类的构造过程可能是这样的:
Derived::Derived(){
Base::Base();
try{ data1.std::string::string(); }
catch(...){
Base::Base();
throw;
}
try{ data2.std::string::string(); }
catch(...){
data1.std::string::~string();
Base::~Base();
throw;
}
...
}
Derived 的析构函数、Base 的构造和析构函数也是一样的,事实上构造和析构函数会被大量地调用。 如果全部inline 的话,这些调用都会被扩展为函数体,势必会造成目标代码膨胀。
如果你是库的设计者,那么你的接口函数的 inline 特性的变化将会导致客户代码的重新编译。 因为如果你的接口是 inline 的,那么客户需要将函数体展开编译到客户的目标代码中。
总结
- inline 只是对编译器的一个请求而非命令,编译器可能忽略这一请求。
- 定义在类中的成员函数和友元函数默认 inline。
- 将大部分 inline 限制在小的,调用频繁的函数上。这使得程序调试和二进制升级更加容易,最小化潜在的代码膨胀,并最大化提高程序速度的几率。
- 不要仅仅因为函数模板出现在头文件中,就将它声明为 inline。