Effective C++ 第二版 33)内联 34)将编译依赖降至最低

条款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();  // 构造 dm3
}

>像上面这样的代码在C++中是不合法的; 1) 在构造函数内无法知道对象是不是在堆上 ( 想知道如何可靠地确定对象是否在堆上, 参见M27); 2) 对this赋值是非法的; 3) 通过函数调用访问构造函数也是不允许的;  但是编译器工作起来没有这些限制;

代码的合法性不是讨论的主题, 问题在于, 调用operator new的代码, 构造基类部分的代码, 构造数据成员的代码都会不知不觉地添加到你的构造函数中, 增加构造函数的体积, 使得构造函数不再适合内联; 同样的分析也适用于Base的构造函数, 如果Base的构造函数被内联, 添加到它里面的所有代码也会被添加到Derived的构造函数(Derived的构造会调用Base的构造); 如果string的构造函数恰巧也被内联, Derived的构造函数将得到其代码的5个拷贝, 每个拷贝对应于Derived对象中5个string的一个(2个继承, 3个自己声明); 同样的情况也适用于Derived的析构函数: 被Derived构造函数初始化的所有对象都要被完全销毁; 被销毁的对象之前可能占用了动态分配的内存, 那么内存还需要释放;

程序的设计者必须预先估计到声明内联函数带来的负面影响; 因为相对程序库中的内联函数进行二进制代码升级是不可能的; 如果f是库中的一个内联函数, 用户会将f的函数体编译到自己的程序中; 如果程序库的设计者后来要修改f, 所有使用f的用户程序必须重新编译; 这样很麻烦(参见条款34); 相反, 如果f是非内联函数, 对f的修改仅需要用户重新链接, 这比重新编译大大减轻了负担; [bridge模式] 如果包含这个函数的程序库是被动态链接的, 程序的修改对用户来说是完全透明的; [??是不透明的吧??感觉static link才是透明的]

Note 内联函数中的静态对象常常表现出违反直觉的行为; 如果函数中包含静态对象, 通常要避免将它声明为内联 (M26)

具体编程时, 大多数调试器对内联函数无能为力; 怎么在一个不存在的函数里设置断点? 怎么单步执行到这样的函数? 怎样捕获对它的调用? 

一般来说, 实际编程时最初的原则是不要内联任何函数, 除非函数很小很简单: e.g. age()

1
2
3
4
5
6
7
8
class  Person {
public :
     int  age()  const  return  personAge; }
...
private :
     int  personAge;
...
};

慎重使用内联, 不但给调试器更多发挥的机会, 还将内联定位到正确的位置: 它是一个根据需要而是要的优化工具;

不要忘了80-20定律(M16): 一个程序往往花80%的时间来执行程序中20%的代码; 程序员的重要目标就是找出这20%能提高整个程序性能的代码; 对于inline, 要做出正确的选择; 

一旦找出程序中的重要函数, 以及内联后可以确实提高程序性能的函数(函数本身依赖于所在系统的体系结构), 就应该将它声明为inline; 同时注意代码膨胀带来的问题, 并监视编译器的警告信息(条款48), 看看是否有内联函数没有被编译器内联; 

如果能正确使用, 内联函数对C++程序员来说是有价值的工具; [对于现在的编译器, 不加inline, 让编译器自己处理更有效]

[关键字inline 必须与函数定义体放在一起才能使函数成为内联, 仅将inline 放在函数声明前面不起任何作用, 一般来说参照setter, getter这样的函数来内联]


条款34 将文件间的编译依赖性降至最低

假设你打开C++程序代码, 对某个类的实现做了小改动 (改动实现, 不是接口); 然后准备重新生成程序, 按理来说编译和链接会很快, 因为只是改动了一个类实现; 然而build/make之后, 发现整个程序都在重新编译, 重新链接;

问题在于, 在将接口从实现分离这方面, C++做的不够出色, 尤其是类定义中不仅包含接口规范还有实现细节的时候; e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class  Person {
public :
     Person( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country);
     virtual  ~Person();
...  // 简化起见,省略了拷贝构造
// 函数和赋值运算符函数
     string name()  const ;
     string birthDate()  const ;
     string address()  const ;
     string nationality()  const ;
private :
     string name_;  // 实现细节
     Date birthDate_;  // 实现细节
     Address address_;  // 实现细节
     Country citizenship_;  // 实现细节
};

>有趣的命名方式: 当私有数据和公有函数都想用某个名字来标识时, 让变量带一个尾部下划线来区别; [在如今的编译器中这是不必要的, 变量和函数颜色不同, 或者使用m_var的命名方式]

Person的实现用到了string, Date, Address, Country; 要通过编译, 就需要编译器访问这些类定义:

1
2
3
4
#include <string> // 用于string 类型 (参见条款49)
#include "date.h"
#include "address.h"
#include "country.h"

这样一来, 定义Person的文件和这些头文件之间就有了编译依赖关系; 如果其中任意一个类改变了实现, 或者任意一个类依赖的类改变了实现, 包含Person类的文件以及任何使用了Person类的文件就必须重新编译; 

前向声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class  string;  // "概念上" 提前声明string 类型
// 详见条款 49
class  Date;  // 提前声明
class  Address;  // 提前声明
class  Country;  // 提前声明
class  Person {
public :
     Person( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country);
     virtual  ~Person();
...  // 拷贝构造函数, operator=
     string name()  const ;
     string birthDate()  const ;
     string address()  const ;
     string nationality()  const ;
};

如果使用这种方法, 那么除非类的接口改变, 否则Person的用户就不需要重新编译; 大系统的开发过程中, 在具体实现类之前, 接口往往基本趋于固定, 所以这种接口和实现的分离将节省重新编译和链接的时间;

1
2
3
4
5
int  main()
{
     int  x;  // 定义一个int
     Person p(...);  // 定义一个Person(为简化省略参数)
}

>对于x的定义, 编译器知道分配一个int大小的内存; 但不知道p定义的Person对象的大小; 如果类的定义省略了实现细节, 编译器怎么知道分配多大内存?

有些语言Smalltalk, Eiffel, Java的做法是, 当定义一个对象时, 只分配足够容纳这个对象的一个指针的空间; 

对应上面的代码, e.g.

1
2
3
4
5
int  main()
{
     int  x;  // 定义一个int
     Person *p;  // 定义一个Person 指针
}

>合法的C++语句; 程序员可以做到"将一个对象的实现隐藏在指针身后";

实现Person接口和实现的分离: 在声明Person类的头文件中没有实现的细节, e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 编译器还是要知道这些类型名,  因为Person 的构造函数要用到它们
class  string;  // 对标准string 来说这样做不对,原因参见条款 49
class  Date;
class  Address;
class  Country;
// 类PersonImpl 将包含Person 对象的实现细节,此处只是类名的提前声明
class  PersonImpl;
class  Person {
public :
     Person( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country);
     virtual  ~Person();
...  // 拷贝构造函数, operator=
     string name()  const ;
     string birthDate()  const ;
     string address()  const ;
     string nationality()  const ;
private :
     PersonImpl *impl;  // 指向具体的实现类
};

>Person的用户程序完全和string, date, adress, country以及person的实现细节分开了, 这些类都可以随意修改, Person不受影响(不需要重新编译); 因为看不到Person的实现细节, 用户不可能写出依赖细节的代码, 体现了接口和实现的分离;

分离的关键在于, "对类定义的依赖"被"对类声明的依赖"所取代了; 

Note 为了降低编译依赖性: 只要可能, 尽量让头文件不要依赖于别的文件; 如果不行, 就借助于类的声明, 不要依靠类的定义;

具体深化:

- 如果可以使用对象的引用和指针, 就要避免使用对象本身; 定义某个类型的引用和指针只涉及到类型的声明; 定义此类型的对象则需要类型定义;

- 尽可能使用类的声明, 而不使用类的定义; 因为在声明一个函数时, 如果用到某个类, 是绝对不需要这个类的定义的, 即使函数是通过传值来传递和返回这个类:

1
2
3
class  Date;  // 类的声明
Date returnADate();  // 正确 ---- 不需要Date 的定义
void  takeADate(Date d);

>传值通常不是好主意; 不得不这样做时, 千万不要引起不必要的编译依赖性;

函数的声明在编译时不需要Date定义的原因:

因为并非每个人都会调用所有的函数, 当调用这些函数时, 用户必须使得Date的定义可见; 

e.g. 一个包含数百个函数声明的库(涉及多个名字空间), 不可能每个用户都去调用每个函数; 将提供类定义(#inlcude)的任务从函数声明头文件转交给包含函数调用的用户文件, 就可以消除用户对类型定义不必要的依赖;

不要在头文件中再(#include)包含其他头文件, 除非无法通过编译; 相反, 要一个一个声明所需的类, 让使用这个头文件的用户自己包含所需的头文件; 一些用户可能会抱怨不方便, 但实际上避免了他们编译的痛苦; 这种技术被运用到C++标准库中, 头文件<isofwd>就包含了iostream库中的类型声明(只有类型声明);


Person类仅仅用一个指针来指向某个不确定的实现, 这样的类被称为句柄类Handle class 或信封类Envelope calss; (对于他们所指向的类来说, 前一种是主体类Body class; 后一种是信件类Letter class); 偶尔也有人叫他Cheshire猫类; (<艾丽丝漫游仙境>: 当猫愿意时, 它会是身体部分消失, 只留下微笑...);

句柄类把函数的调用都转移到了对应的主体类中, 主体类完成工作; e.g.

1
2
3
4
5
6
7
8
9
10
11
12
#include "Person.h" // 因为是在实现Person 类,所以必须包含类的定义
#include "PersonImpl.h" // 也必须包含PersonImpl 类的定义,否则不能调用它的成员函数。
// 注意 PersonImpl 和Person 含有一样的成员函数,它们的接口完全相同
Person::Person( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country)
{
     impl =  new  PersonImpl(name, birthday, addr, country);
}
string Person::name()  const
{
     return  impl->name();
}

>Person的构造函数调用PersonImp的构造函数(隐式地new), Person::name调用Imp::name; 这使得Person成为一个句柄类, 不需要改变Person类行为, 改变所执行的部分即可;


除了句柄类, 另一选择是使Person成为特殊类型的抽象基类, 称为协议类Protocol class; 协议类没有实现, 存在的目的是为派生类确定一个接口; 它一般没有数据成员, 没有构造函数; 有一个虚析构, 还有一套纯虚函数来制定接口;

e.g. Person的协议类

1
2
3
4
5
6
7
8
class  Person {
public :
     virtual  ~Person();
     virtual  string name()  const  = 0;
     virtual  string birthDate()  const  = 0;
     virtual  string address()  const  = 0;
     virtual  string nationality()  const  = 0;
};

>Person类的用户必须通过Person的指针和引用来使用它, 因为不能实例化一个包含纯虚函数的类(可以实例化它的派生类); 

和句柄类一样, 协议类的用户只有在类的接口被修改的情况下才需要重新编译:

协议类的用户常常通过调用一个函数来实现构造新对象的过程, 这个用来的构造函数所在的类就是那个真正被实例化的派生类; (工厂函数factory function, 虚析构函数virtual constructor), 但行为是一样的: 返回一个指针, 这个指针指向支持协议类接口的动态分配对象; 

e.g. 函数的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// makePerson 是支持Person 接口的对象的"虚构造函数" ( "工厂函数")
// 用给定的参数初始化一个// 新的Person 对象,然后// 返回对象指针
Person* makePerson( const  string& name,   const  Date& birthday,
     const  Address& addr,  const  Country& country);
用户这样使用它:
string name;
Date dateOfBirth;
Address address;
Country nation;
...
// 创建一个支持Person 接口的对象
Person *pp = makePerson(name, dateOfBirth, address, nation);
... // 通过Person 接口使用对象
cout << pp->name() <<  " was born on "  << pp->birthDate() <<  " and now lives at "  << pp->address();
...
delete  pp;  // 删除对象

>makePerson这类函数和创建的对象所对应的协议类紧密联系(对象支持协议类的接口), 所以将它声明为协议类的静态成员是个好习惯:

1
2
3
4
5
6
7
class  Person {
public :
...  // 同上
// makePerson 现在是类的成员
static  Person * makePerson( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country);
//...

>避免给名字空间带来混乱;

在某个地方, 支持协议类接口的具体类concrete class必须要定义, 真正的构造函数也要被调用, 它们都将发生在实现文件中; 例如, 协议类有一个派生RealPerson, 具体实现继承而来的虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class  RealPerson:  public  Person {
public :
     RealPerson( const  string& name,  const  Date& birthday,  const  Address& addr,  const  Country& country)
     : name_(name), birthday_(birthday), address_(addr), country_(country) {}
     virtual  ~RealPerson() {}
     string name()  const // 函数的具体实现没有
     string birthDate()  const // 在这里给出,但它们
     string address()  const // 都很容易实现
     string nationality()  const ;
private :
     string name_;
     Date birthday_;
     Address address_;
     Country country_;
}

>有了RealPerson, 可以开始实现Person::makePerson:

1
2
3
4
5
Person * Person::makePerson( const  string& name,  const  Date& birthday,
     const  Address& addr,  const  Country& country)
{
     return  new  RealPerson(name, birthday, addr, country);
}

实现协议类有两个通用机制, RealPerson是其中之一: 先从协议类Person继承接口规范, 然后实现接口中的函数; 另一种实现协议类的机制涉及多继承, 条款43;


句柄类和协议类分离了接口和实现, 降低了文件间编译的依赖; 但是在运行时会需要多些时间, 多些内存;

句柄类的成员函数必须通过(指向实现的)指针来获得对象数据, 访问的间接性多了一层; 每个对象所占的内存要加一个指针; 指针本身需要初始化(句柄类的构造函数内), 使它指向被动态分配的实现对象Imp, 所以要承担动态内存分配/释放的开销;

协议类每个函数都是虚函数, 所有每次调用函数时必须承担间接跳转的开销(条款14, M24); 这个指针可能会增加对象存储所需要的内存数量(对于具体对象来说, 协议类不是唯一的虚函数来源);

句柄类和协议类都不太可能使用内联; 内联函数需要访问实现细节, 而设计句柄类和协议类的设计初衷正是为了避免这个情况;

虽然会带来开销, 在开发阶段要尽量使用句柄类和协议类来减少"实现"的改变对用户的负面影响; 如果带来的速度或体积的增加程度远远大于类之间依赖性的减少程度, 那么当程序转化成产品时可以用具体类来取代句柄类和协议类;

混用句柄类, 协议类和具体类可能会使得软件系统运行高效, 易于改进, 但是在开发阶段要想办法减少程序重新编译消耗的时间; [开发效率 vs 运行效率; 桌面开发 vs 嵌入式开发...]

---类和函数实现 End---

你可能感兴趣的:(Effective C++ 第二版 33)内联 34)将编译依赖降至最低)