条款33 明智地使用内联
内联函数: 看起来像函数, 运作起来像函数, 比宏macro要好(条款1), 使用时还不需要承担函数调用的开销;
避免函数调用的开销仅仅是一方面; 为了处理那些没有函数调用的代码, 编译器优化程序本身进行了专门的设计; 当内联一个函数时, 编译器可以对函数体执行特定环境下的优化工作;
程序世界和现实生活一样, 没有免费午餐, 内联函数也不例外; 内联函数的基本思想在于将每个函数调用以代码体来替换; 这样的做法很可能会增加整个目标代码的体积; 在一台内存有限的计算机里, 过分地使用内联所产生的程序会因为体积太大导致可用空间不够; 即使可以使用虚拟内存, 内联造成的代码膨胀也可能会导致不合理的页面调度行为(系统颠簸), 这将使你的程序运行极慢; 过多的内联还会降低指令高速缓存的命中率, 使取指令的速度降低, 因为从主存取指令比缓存慢;
话说回来, 如果内联函数体非常短, 编译器为这个函数体生成的代码会真的比为函数调用生成的代码小许多; 这种情况, 内联这个函数将会确实带来更小的目标代码和更高的缓存命中率;
Note inline指令就像register, 只是对编译器的一种提示, 而不是命令;
只要编译器愿意, 可以随意地忽略掉你的指令, 事实上编译器常常这么做; e.g. 大多数编译器拒绝内联"复杂"的函数(包含循环和递归的函数);
Note 即使是最简单的虚函数调用, 编译器的内联处理也无能为力(virtual是等到运行时再决定调用哪个函数, inline是在编译期间将调用的函数用代码体代替, 如果编译器不知道哪个函数将被调用, 就无法生成内联调用);
结论: 一个给定的内联函数是否真的被内联取决于所用的编译器的具体实现; 大多数编译器都可以设置诊断级别, 当声明为内联的函数实际上没有被内联时, 编译器会发出警告信息(条款48);
理论上, 假设写了某个函数f声明为inline, 如果编译器决定不对他内联; f将作为一个非内联函数处理, 为f生成代码时就像是一个普通的"外联"函数, 对f的调用也和普通函数一样;
实际上, 这个方案对解决"被外联的内联outlined inline"非常理想, 但它加入C++标准的时间相对较晚, 较早的C++规范(例如ARM--条款50)告诉编译器制造商去实现的是另外不同的行为, 这一旧的行为在现在编译器中还存在;
内联函数的定义实际都是放在头文件中; 这使得多个要编译的单元(源文件)可以包含同一个头文件, 共享头文件内定义的内联函数所带来的好处;
e.g. 例子中的源文件名义常规cpp结尾:
1
2
3
4
5
6
7
8
9
|
// 文件example.h
inline
void
f() { ... }
// f 的定义
...
// 文件source1.cpp
#include "example.h" // 包含f 的定义
...
// 包含对f 的调用
// 文件source2.cpp
#include "example.h" // 也包含f 的定义
...
// 也调用f
|
如果现在采用旧的"被外联的内联"规则, 而且假设f没有被编译器内联, 当source1.cpp编译时, 生成的目标文件将包含称为f的函数, 就像f没有被声明为inline一样; 同样地, 当source2.cpp被编译时, 产生的目标文件也包含一个f函数; 当把两个目标文件链接在一起时, 编译器会因为程序中有两个f的定义而报错;
为了防止这个问题, 旧的规则规定, 对于未被内联的内联函数, 编译器把它当成被声明为static那样处理, 使它局限于当前被编译的文件; 对于刚才的例子, 遵循旧规则的编译器处理source1.cpp中的f时, 就像f在source1.cpp中是static的一样, 对source2.cpp也是; 这一策略消除了链接时的错误, 但带来了开销: 每个包含f的定义(以及调用f)的被编译单元都包含自己的f的静态拷贝; 如果f自身定义了局部静态变量, 那么每个f的拷贝都有这局部变量的一份拷贝; 这会让程序员意想不到, 因为一般来说函数中的static意味着只有一份拷贝;
具体实现也会出乎意料; 无论新规则还是旧规则, 如果内联函数没被内联, 每个调用内联函数的地方还是要承担函数调用的开销; 如果是旧规则, 还要忍受代码体积增加, 每个包含(或调用)f的被编译单元都有一份f的代码及其静态变量的拷贝(更糟糕的, 每个f的拷贝以及每个f的静态变量的拷贝往往处于不同的虚拟内存页面, 所以两个对f的不同拷贝进行调用有可能导致多个页面错误)
还有, 编译器即使很想内联一个函数, 却不得不为这个内联函数生成一个函数体; 特别是当程序中要取一个内联函数的地址, 编译器就必须为此生成一个函数体, 否则编译器没法指向一个不存在的函数的指针;
1
2
3
4
5
6
7
|
inline
void
f() {...}
// 同上
void
(*pf)() = f;
// pf 指向f
int
main()
{
f();
// 对 f 的内联调用
pf();
// 通过pf 对f 的非内联调用
}
|
>这种情况似乎很荒谬: f的调用被内联了, 在旧的规则下, 每个取f地址的被编译单元还是各自生成了此函数的静态拷贝;(新规则下, 不管涉及的被编译单元有多少, 将只生成唯一一个f的外部拷贝);
即使你从来不使用函数指针, 这类"没被内联的内联函数"也会找到你, 因为不只是程序员使用函数指针, 有时编译器也会这么做; 特别是编译器有时会生成构造和析构函数的外部拷贝, 这样可以通过得到那些函数的指针, 方便地构造和析构类的对象数组;(条款M8)
实际上, 随便一个测试就能证明构造和析构函数常常不适合内联, e.g. Derived的构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
|
class
Base {
public
:
...
private
:
string bm1, bm2;
// 基类成员1 和2
};
class
Derived:
public
Base {
public
:
Derived() {}
// Derived 的构造函数是空的, ------但,真的是空的吗?
private
:
string dm1, dm2, dm3;
// 派生类成员1-3
};
|
>这个构造看起来适合内联, 因为它没有代码; 但只是看上去没有代码, 实际上它含有相当多的代码 [编译器生成]
C++就对象创建和销毁时发生的事件有多方面的规定; 条款5和M8介绍了当使用new时, 动态创建的对象怎样自动地被构造函数初始化, 以及使用delete时析构怎样被调用, 条款13说明了创建一个对象时, 对象的每个基类以及对象的每个数据成员会被自动创建; 对象销毁时, 会自动执行相反的过程(析构); C++规定了哪些必须发生, 但没有规定"怎么"发生, 这取决于编译器的实现者; 程序中比如有代码使得他们发生, 特别是由编译器的实现者写的, 在编译期间插入到你的程序中的代码, 必然藏身于某处--有时, 就藏在构造和析构函数里; 所以, 对于看似空的Derived的构造函数, 有些编译器会产生下面的代码:
1
2
3
4
5
6
7
8
9
10
11
|
// 一个Derived 构造函数的可能的实现
Derived::Derived()
{
// 如果在堆上创建对象,为其分配堆内存;operator new 的介绍参见条款8
if
(本对象在堆上)
this
= ::operator
new
(
sizeof
(Derived));
Base::Base();
// 初始化 Base 部分
dm1.string();
// 构造 dm1
dm2.string();
// 构造 dm2
dm3.string();
|