开始记录这本书的阅读笔记出自三个目的:

  1. 很多人说没读过Effective C++等于没学C++;

  2. 很难坚持以往想法-验证这种模式的学习,实在耗费精力,希望能在读书上仍然坚持下去;

  3. 希望这本书能缓解我写代码时对于安全性、规范和技巧上的惶恐不安。

本文的主题以及本书的副标题是改善程序设计的55个条款。值得一提是,本文不是大多数笔记那样的纯摘抄和摹写(实际上这种台湾翻译不太符合内地术语习惯,意义并不大,再者本书在C++ 11以前出版,有许多语言特性和解决方案已经落后,在能力范围以内不指出是一种懒惰的阅读习惯。

持续更新。

条款01:View C++ as a federation of languages.

视C++为一个语言联邦。

C++一开始是一种C with Classes,但随着语言逐渐成熟,其开始引入各种编程战略,例如Exceptions(异常处理)Templates(模板)、STL;今日的C++已经是多重范型编程语言(multiparadigm programming language),同时支持面向过程面向对象函数泛型(generic)元编程(metaprogramming);

不应该将C++看成是单一语言,而应该视为主要的四个语言

  • C:语句、数据类型、指针、数组等均来自C语言,但缺乏异常、重载等;

  • 面向对象C++:三大特性,封装、继承、多态;

  • STL:紧密配合了容器、迭代器、算法,遵守了自己的规约。

  • Template C++:C++泛型编程的部分,其设计逐渐弥漫整个C++生态,威力十分强大,其带来了崭新的编程范例(programming paradigm),即模板元编程(template metaprogramming,TMP)

当跨越这些次语言时,高效编程要求我们改变策略,不必感到惊讶,例如:

对于C-like数据类型(int/bool等),值传递(pass-by-value)常常比引用传递(pass-by-reference)更加高效,因为前者涉及很少字节的拷贝,甚至拷贝会被编译器优化成寄存器的传递,而后者需要解引用;从C到面向对象C++,对于用户自定义了构造和析构函数的对象,常量引用传递往往更好(const T&);再回到STL,因为迭代器和函数对象都是从C指针上构造出来的,pass-by-value守则再次适用(详见条款20);

因此C++是一种语言联邦,其高效编程取决于情况而变化;

条款02:Prefer const,enum,inline to #define.

尽量以const、enum、inline替换#define。

const

#define并不通过编译器,它在编译预处理时已经被展开成具体内容,因此它很有可能并没有进入符号表(symbol table),因此当获得一个编译错误信息时,它可能来自一个奇怪且重复的被define的数字而不是define的名称,其次,一些场景下,例如浮点数,constdefine耗费更少的码

当你需要使用一个常量的指针,考虑将指针设置成常量,例如const char* const name = "Eden";

关于const的使用,条款3会有更详尽的意见,此处应该注意

const作用域一个类中时,应该让它成为一个成员变量,为了保证这个变量有且仅有一份,应该让它声明成静态的:

1
static const int num = 64;
注意,这是一个声明式而非定义式,如果它是类作用域、且static、且整数类型(bool/int/char),且你不会对它们取地址,那么直接在类中使用是没问题的:
1
int array[num];
否则你需要额外提供定义式,这个定义不应该放在头文件(特指类内)(也即我们说的静态成员变量类外声明,这里赋值声明还是定义式均可(旧式编译器习惯在类外赋值)):
1
2
3
4
5
6
7
8
9
10
class Person{
public:
static const int num;
};
const int Person::num = 64; //类外定义式

int main(){
cout<<Person::num<<endl;
return 0;
}

也可以知道,#define不重视作用域,它可以全局生效

不实践看这样的一段的代码可能会感到puzzle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person{
public:
Person(){
num = 5;
}
void print(){
static int num = 4;
num++;
qDebug() << num;
}
void print2(){
static int num = 6;
qDebug()<< num;
}

private:
static int num;
};
int Person::num = 3;
假设现在执行会得到不同的输出:
1
2
3
4
5
Person p;
p.print(); //5
p.print(); //6
p.print(); //7
p.print2(); //6

至少注意几件事情

  1. static变量不为const时,必须类外赋值,带const可类外、类内赋值;另一种方法是在支持C++17编译器下,在类内使用inline static int num = xxx进行初始化;

  2. 虽然这里有三个static int num,分别是两个局部静态变量和一个类静态变量,它们之间互相独立没有任何关系连结;

  3. 三次调用print逐次递增证明了静态变量的初始化只会进行一次(对进程而言)。

enum hack

如果你的编译器正如上述所说不支持类内赋值,而你恰巧希望在类初始化时获得这个常量,你只能使用enum方法:

1
2
3
4
5
class xxx{
public
enum{ num =5};
int array[num];
}
enum hack行为更贴近#define,例如你可以对const值取地址,绝不能对define和enum等取地址;条款18可以让你通过enum约束避免别人通过指针或者引用来指向你的整数常量,enum也是模板元编程的基础技术(见条款48);

inline

#define也常常被用于表达式,避免函数调用带来的开销,但是一些灾难性的写法却带来意外的效果:

1
2
3
4
5
6
7
8
9
10
11
12
void test(int num){
cout<<"num = "<<num;
}
#define call(a,b) test(a>b?a:b)

int main(){
int a = 5;
int b= 0;
call(++a,b); //a变成7,输出num = 7
call(++a,b+10); //a变成8,b=10,num = 10(后b成为20
return 0;
}
可见,a递增次数取决于a和谁比较

使用条款30中的template inline函数,你既可以获得define的性能,也避免这种写法的灾难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void test(int num){
cout<<"num = "<<num;
}

template<typename T>
inline void call(const T&a, const T& b){
test(a>b?a:b);
}

int main(){
int a = 5;
int b= 0;
call(++a,b); //num =6
call(++a,b+10); //num =10
return 0;
}

条款03:Use const whenever possible.

尽可能使用const。

区分常量指针和指针常量,此处略;

STL的const

在STL中,迭代器是来自指针的,因此有两种写法要注意,如果你需要的是一个指针常量(指向不可变、指向值可变),应当是:

1
2
const std::vector<int>::iterator iter = ...
*iter = 10; // ok
如果你希望是一个常量指针(指向可变、指向值不可变),那么应该是:
1
2
std::vector<int>::const_iterator citer = ...
++citer; //ok

const成员函数

本条款书中其他内容仍然是无聊的(多半是为了后面呼应,实际价值有限),它们在此前的文章被讨论过,用的并不多,例如:使用const修饰成员函数,分为两派用法,bitwise constness认为只要一个成员函数声明为const,就不应该修改任何成员,但实际上编译器也无法完全排除这种行为,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string.h>
using namespace std;

class Person{
public:
Person(char* str){
ch = new char(strlen(str)+1);
strcpy(ch,str);
}
~Person(){
delete []ch;
}
char& operator[](std::size_t num)const{
return ch[num];
}
char *ch;
};

int main(){
const Person p("Hello"); //常对象能调用长函数
char* ch1 = &p[0];
*ch1 = 'J';

cout<<p.ch<<endl; //Jello
return 0;
}
此处将成员函数声明为const,还将对象设置为const(没什么用,常对象仅能调用常函数,普通对象能调用常函数和普通成员函数),但还是通过指针修改了成员变量,这种行为被很多编译器认为是允许的。

另一派称为logical constness,它们认为能在客户端没有察觉时更改成员变量,比如尽管声明能const,我们可以使用mutable修饰成员变量,这样它们能够在const函数里光明正大地修改成员变量:

1
2
3
4
5
6
7
8
class xxx{
public:
void test()const{
num = 5; //ok
}
private:
mutable int num;
};

const作函数返回值

const作函数返回值、配合常对象只能调用常函数的特性,能做到读写区分安全性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <string.h>
using namespace std;

class Person{
public:
Person(const char* str){
ch = new char[strlen(str)+1];
strcpy(ch,str);
}
~Person(){
delete []ch;
}
char& operator[](std::size_t num){
return ch[num];
}
const char& operator[](std::size_t num)const{
return ch[num];
}

char *ch = nullptr;
};

int main(){
Person p("Hello");
const Person cp("Hello");

p[0] = 'J';
//cp[0] = 'J';
cout<<p[0];
cout<<cp[1];

return 0;
}
此处const作为成员函数修饰能作为重载条件(返回值const不能作为重载条件),常对象无法进行写入;

由此延申另一个问题是:能否使用const函数去实现non-const函数,从而避免重复的代码片段,可以通过static_cast进行常对象转换,再使用const_cast进行常量转除,常量转除在一般情况是不被建议的,但是这里non-const函数和const是功能是一致的,仅仅在返回值有差异,因此是安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <string.h>
using namespace std;

class Person{
public:
Person(const char* str){
ch = new char[strlen(str)+1];
strcpy(ch,str);
}
~Person(){
delete []ch;
}
char& operator()(std::size_t num){
return const_cast<char&>(static_cast<const Person&>(*this)(num));
}
const char& operator()(std::size_t num)const{
return ch[num];
}

char *ch = nullptr;
};

int main(){
Person p("Hello");
const Person cp("Hello");

p(0) = 'J';
// //cp[0] = 'J';
cout<<p(0);
cout<<cp(0);
return 0;
}
深拷贝的坑一点也不少,例如static_cast<const Person&>(*this)(num)中的const Person&写成const Person,否则你可能意外地触发拷贝构造,而默认的拷贝构造对char指针并不安全,你可能需要严格地自定义一个。

以上这些虽然有趣,但却不常用,最近碰到一个和常函数相关的问题是:通过const函数返回的类对象,其成员函数能否被改写:答案是肯定的

1
2
3
4
5
ClassA getClassA() const{
return ClassA;
}
//链式:
Class B->getClassA->a_ = ....

一些operator*返回也应该设置成const属性,以免写出if(a*b = c)(本应该是==)的代码;

条款04:Make sure that objects are initialized before they're used.

确定对象被使用前已先被初始化。

链式传参更加高效

对于一般数据类型,只需要按规则将它们初始化即可,对于非内置数据类型,初始化规则来自构造函数,而这样的构造函数是赋值而非初始化:

1
2
3
4
5
6
7
8
class Person{
public:
Person(string name,std::vector<int>& numbers){
this->name = name;
this->numbers = numbers;
}
...
};
初始化发生在进入构造函数体之前,对象首先调用default构造函数进行初始化,然后马上按照参数传入新值,而链式传参则是使用参数值直接初始化,因此更加高效;

对于用户自定义的数据类型,编译器能够调用默认构造为其进行初始化,因此应该在构造函数中使用链式传参指定每一个成员变量,只要罕见的情况你需要考虑使用赋值代替链式传参,例如当一个class含有多个构造函数并且进行了大量重复的参数传递,你可以考虑把那些“赋值像初始化一样友好的”封装成私有函数,并且再重复的构造函数中调用它们。

此外C++具有固定的成员变量初始化顺序:父类构造总是在子类之前,而且成员变量的初始化按照其声明的顺序进行,而不是链式传参的顺序。

non-local静态变量

问题是C++对于“定义于不同编译单元内的non-local静态变量对象”初始化次序没有明确定义;

解释一下,所谓一个编译单元可以认为是一个源文件已经它所包含的所有头文件non-local静态变量局部静态变量,指向函数作用域内静态变量,而命名空间文件内、全局等作用域的静态变量均为local静态变量。 **

如何体现没有明确定义,例如:

1
2
3
4
class Person{
xxx
};
extern Person p; //此处提供了一个对象给外部使用
另一个类调用这个类对象:
1
2
3
4
5
class Student{
void doSomething{
xxx = p.xxx; //使用了这个对象
}
};
由于两个类在不同的编译单元,无法保证Person一定可以在Student前被编译和初始化,C++不可能做到正确的顺序。幸运的是一个设计可以完全避免这个问题,这也是单例模式常见实现手法:通过静态引用返回对象:
1
2
3
4
5
6
class Person{
static Person& getPerson(){ //定义成静态函数只为绕开实例化
static Person p;
return p;
}
};
从而:
1
2
3
4
5
class Student{
void doSomething{
xxx = Person::getPerson().xxx;
}
};
这里通过return这个对象,相当于把non-local变量转换到了local作用域,这至少有四点好处:

  1. C++能保证返回的这个变量被初始化的;

  2. 不调用该函数,该静态对象不会被构造和析构

  3. C++单例设计模式中说过,C++11后返回这个对象是线程安全的(此处原书认为初始化仍然存在race condition(非const静态变量都存在)并指出一种方法是启动时单线程调用所有的静态引用函数,多线程二次引用就没有问题了,应该是滞后说法;

  4. 定义一个变量-返回的写法使得该函数容易被inline

第二章:Constructors,Destructors,and Assignment Operators,构造/析构/赋值运算。

条款05:Know what functions C++ silently writes and calls.

了解C++默默编写和调用了哪些函数。

自行生成的构造/拷贝构造/析构/拷贝赋值函数

当你写下一个空类,编译器会为你声明这四个函数:

1
2
3
4
5
6
7
class Empty{
public:
Empty(){}
Empty(const Empty& obj){}
~Empty(){}
Empty& operator=(const Empty& obj){}
};
它们是public且inline的,当它们被调用是才真正被创建,创建定义的default行为进行了简单的工作,例如:

  • 构造和析构:调用了基类/non-static变量的构造和析构,除非基类是虚析构函数,否则这种defualt析构就是non-virtual的;

  • 拷贝构造和拷贝赋值函数:将non-static变量从源obj拷贝到目标对象;

当你自行声明了一个有参构造/无参构造,编译器都不再自行生成构造函数;

当成员是引用/const

当成员引用是引用和const,C++如何定义拷贝赋值的行为呢:

1
2
3
4
5
class xxx{
public:
string& name;
const string str;
};
答案是不响应,C++无法直接修改一个引用到另一个引用,也无法直接对const进行赋值,因此如果你需要对含引用的类赋值,必须自定义拷贝赋值函数;如果是拷贝构造函数则需要注意使用链式传参进行构造,使得引用变量/const变量能初始化;

当父类私有化了拷贝赋值

子类没有定义自己的拷贝赋值,会默认调用父类的拷贝赋值,当父类的拷贝赋值被private,编译会失败,因此此时应该自行在子类实现operator=

条款06:Explicitly disallow the use of compiler-generated functions (that) you do not want.

如果不想使用编译器生成的函数,应该明确拒绝。

原书的本问题具备滞后性,当我们不想使用拷贝赋值/拷贝构造时,应该如何拒绝:

原书历史方法:定义一个父类,私有化其拷贝构造和拷贝赋值函数,子类继承该父类并且同样私有化拷贝构造和拷贝赋值,这样编译就会发现问题;如果仅子类定义私有化函数是不足够的,因为友元/成员变量仍然可以调用私有化方法,而且这个错误发生在链接期而不是编译期,根据05条款,使用父类私有拷贝赋值能把错误提前到编译期;

而C++11以后,拒绝一个函数只需要声明delete即可:

1
2
Person(const Person&) = delete;
Person& operator=(const Person&) = delete;

条款07:Declare destructors virtual in polymorphic base classes.

为多态基类声明虚析构函数。

多态父类应该声明虚析构

在子类中我们常常不会关心父类的计算方法,因此我们常常有这种写法:

1
2
3
4
5
6
7
class Father{
Father(){}
~Father(){}
};
class Son1:public Father{...}
class Son2:public Father{...}
class Son3:public Father{...}
此时我们返回一个父类指针来获得子类对象:
1
Father* ptr = new Son1();
为了防止内存泄漏当然要手动delete掉:
1
delete ptr;
当然我们认为如果Son1本身并不含有堆区指针对象,似乎没什么问题,但Effetive C++认为这仍然是不好的习惯,因为delete ptr只会删除父类成分,所以应该父类声明成虚析构函数:virtual ~Father(){},这样会在delete时调用也会调用子类析构,完成完善的对象释放。

总而言之,对于多态base classes,应该声明其析构为虚函数;而并不是所有父类都是为了实现多态的,有的父类单纯就是为了作为接口处理子类对象,可以不声明虚函数;

非父类不应该含虚函数

反过来,如果一个类不打算拥有子类,其不应该含有虚函数:在32位操作系统虚函数表指针花费了4字节(32位空间)(Visual Studio),而在64位操作系统可能带来8字节(64位)的空间开销(gcc);

继承非虚析构的string/STL等

尽管你履行了第一点,可能还是会无意被坑伤害,例如你继承了string/vector/list/set/unordered_map等STL:

1
2
3
class MyClass:public std::string{
......
};
然后不经意地使用了它们的指针:
1
2
3
4
5
MyClass myClass = new MyClass();
std::string* str;
......
str = myClass;
delete str;
导致了未定义错误的麻烦;

纯虚析构函数

当你需要一个抽象父类(该类不能被实例化,因为其析构一定会失败),但是此时你没有任何虚函数可以写,那么可以不妨定义一个纯虚析构函数:

1
2
3
4
class Father{
public:
virtual ~Father() = 0;
};
因为子类析构时一定会调用父类析构,因此必须给出纯虚析构的定义(可以为空),否则链接器可能无法链接到该对象:
1
Father::~Father(){}

条款08:Prevent exceptions from leaving destructors.

别让异常离开析构函数。

C++并不禁止在析构函数抛出异常,但是抛出的异常可能导致程序不明确行为或者过早结束:

1
2
3
4
5
6
7
8
9
10
class Widget{
public:
~Widget{
//// 异常
}
};

void doSomething{
std::vector<Widget> wid;
} //函数结束,wid析构
假设wid析构第一个Widget对象,也许能继续执行后续析构,然而析构第二个对象仍然异常,那么可能导致不明确行为,因为C++可能不能接受两个异常;

我们可能会在析构函数执行一些关闭行为:

1
2
3
~Widget{
onClose();
}
一旦onClose()调用失败,这种异常行为会发生传播,导致程序异常;两种方法可以阻止这种异常离开析构函数:

  1. 异常发生时强制终止程序,通过std::abort:

    1
    2
    3
    4
    5
    6
    7
    ~Widget{
    try {
    onclose();
    }catch(exception ...){
    std::abort();
    }
    }

  2. 吞下这种异常,也即上述catch不做处理;

可见两种办法无法避免和良好处理析构函数导致的异常,如果确乎需要在onClose执行异常处理,只能在析构函数以外的普通函数进行,为了避免析构前close代码没有被调用,可以采用标志位进行双重保险

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void onClose(){
......
isClosed = true;
}

~Widget{
if(!isClosed){ //用户没有调用,析构来做
try{
onClose();
}catch{
std::abort();
}
}
}

条款09:Never call virtual functions during construction or destruction.

从不在构造/析构过程调用虚函数。

这种行为其实这并不是很隐晦的坑/禁令,只是有可能导致这样的行为,例如你写下了这样的代码:

1
2
3
4
5
6
7
8
9
10
11
class Father{
public:
virtual void log(){...}
Father(){
....
log()
}
};
class Son{
virtual void log()override{...}
};
我们的期望是每当子类构造时,就留下一个记录log,但是实际上编译不会这么干:

  1. 当log是一个纯虚函数,父类构造程序无法调用一个纯虚函数,程序终止

  2. 当log是一个非纯虚函数子类的log版本不会生效,因为父类构造在子类以前;

因此只需要避开这两个坑,不至于茫然即可,确实需要实现这样的目的,父类的log应该声明成非虚函数:void log(){...},子类构造时给父类传递必要信息即可。

条款10:Have operator=(assignment operators) return a reference to *this.

让operator=返回一个*this的引用。

通常返回一个*this,往往是为了链式法则,这里也不例外,例如你可以这样写:

1
x = y = z = 5;
赋值运算采用了右结合的规律,所以在我们自己重载=时也应该遵循这一点;这个规则不是强制的,其他重载运算符也应该看情况遵循;

条款11:Handle assignment to self in operator=.

在operator=处理自我赋值。

我想这个问题在大部分的代码都存在,内存安全实在吹毛求疵;

赋值时如果不考虑自我赋值,可能带来野指针的操作,例如以下属于自我赋值行为,或显然或隐蔽:

1
2
3
num = num;
array[i] = array[i];
*px = *qx; //它们可能是同一块可见
甚至来自同一个继承系统的对象,也有可能:
1
2
3
class Base{...};
class Derived:public Base{...};
void doSomeThing(const Base& obj,Derived* son); //obj和son可能来自同一个对象

如果写下这样的代码:读写一段已删除的内存,会导致错误

1
2
3
4
5
void doSomeThing(const Base& obj,Derived* son){ //obj和son可能来自同一个对象
delete son;
... = obj; //仍然使用obj
obj = ...
}

证同测试

常用的方法是通过identity test

1
2
3
4
5
6
7
8
9
10
//一个指针成员:
T* map;

Person& operator=(const Person& obj){
if(&obj==this)
return *this;
delete map; //防止内存泄漏
map = new T(*obj.map);
return *this;
}
证同测试确实解决了自我赋值的问题,但是当new抛出异常(内存不足、构造函数异常等)时,仍然返回了一个未初始化的指针

安全的方法

一种稳健的方法是牺牲了一些效率,在确保赋值成功以前我们不应该主动删除这个原始指针:

1
2
3
4
5
6
7
8
T* map;

Person& operator=(const Person& obj){
T* map_back = map; //浅拷贝
map = new T(*obj.map); //新对象,如果异常仍然能返回正确指针
delete map_back; //防止内存泄漏
return *this;
}
另一种兼顾安全和效率的方法,会在条款29介绍。

条款12:Copy all parts of an object.

复制对象时勿忘其每一个成分。

自定义copying函数后编译器的复仇

这点在Qt中也许尤为重要,当你定义自己了拷贝构造、拷贝赋值赋值函数,编译器不会管你的死活,最常见的是你缺省了一些成分的初始化,尽管是最严格的编译器不大可能报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Pers
#include <iostream>
using namespace std;

struct Test{
int num = 99;
};
class Person{
public:
Person() = default;
Person(string name,Test t):name(name),test(t){}
Person(const Person& person):name(name){} //没有定义成员Test
Person& operator=(const Person& person){ //没有定义成员Test行为
name = person.name;
return *this;
}
private:
string name;
Test test;
};

int main(){
Test t;
Person p("Eden",t);
Person p1(p);**
return 0;
}

当这种错误发生在继承关系时:子类的赋值拷贝、拷贝构造不存在任何父类成分,因此父类会调用默认的构造,此时会发生缺省行为,子类拷贝构造时父类的name和test都是未被初始化的;

1
2
3
4
5
6
7
8
9
10
class Son:public Person{
public:
Son(const Son& son):son_num(son.son_num){}
Son& operator=(const Son& son){
son_num = son.son_num;
}

private:
int son_num = 100;
};
因此正确的写法应该是调用父类的copying函数,因为通常子类无法直接访问父类的private成员
1
2
3
4
5
6
7
8
9
10
11
12
class Son:public Person{
public:**
Son(const Son& son):Person(son),son_num(son.son_num){} //调用父类拷贝构造
Son& operator=(const Son& son){
Person::operator=(son); //调用父类拷贝赋值
son_num = son.son_num;
return *this;
}

private:
int son_num = 100;
};
此外记得在父类copying函数中增加Test结构体的拷贝行为;

继承STL的父类成分

Effective C++为人推崇的原因是它的条款并非危言耸听,除了原书,我对本条款也印象深刻,继承关系尤其容易让我们忽略一部分成分,例如最近在项目中一个继承了STL的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
public:
int num = 3;
};

class Derived:public QList<Base>{ //可见父类成分是一个列表,这个列表也是子类的成员信息
public:
QString GetKey()const{
return key_;
}
private:
QString key_ = "A";
};

现在我们对Derived进行Json序列化,然而很容易忽略Derived类中的父类成分,所以务必在类外将整个结构体序列化:

1
2
3
4
5
6
7
8
9
10
11
12
auto Derived2json = [](const Derived& son){
QJsonObject json;
json.insert("key_", son.GetKey());

//父类成分
QJsonArray baseArray;
for(const auto&i: son){
baseArray.append(i.num);
}
json.insert("baseArray",baseArray);
return json;
};
这里Derived暴露也许继承关系是浅显的,当Derived本身也被类或者STL封装,可能导致花费精力去弄明白为什么Json传输了空的数据,也体现了本条款的重要性;

第三章:Resource Management,资源管理

条款13:Use objects to manage resources.

使用对象管理资源。 内容几乎是过期的,但RAII的思想没有过期。当我们从系统申请到资源,除了堆区内存以外,还有文件描述符互斥锁数据库连接sockets等,应该将其放入对象中,例如类对象:

1
2
3
class Management{
......
};
析构函数执行资源的释放。C++ 11的智能指针已经完成了这样的工作,常用的有std::unique_ptrstd::shared_ptr,对应Qt类似的QScopedPointerQSharedPointer
1
2
3
std::unique_ptr<Person> uptr = std::make_unique<Person>("Eden",t); //C++ 14
std::unique_ptr<Person> uptr1(new Person("Eden",t));
std::shared_ptr<Person> sptr = std::make_shared<Person>("Eden",t);//C++ 11
尽管之前我们记录过三种智能指针,一些容易忘记的要点再提也不为过: 1. 智能指针直接作为参数,容易误认为是一种指针传递,所以不要轻易将std::unique_ptrQScopedPointer作为参数类型(引发值拷贝),当然你可以通过引用或者右值引用来避免,但是请注意,对于QScopedPointer,没有对应的移动构造函数,因此只有引用方式可用,std::move的方法行不通;
1
2
3
void test(QScopedPointer<int>& uptr){
qDebug() << *uptr.data();
}
2. 共享指针推荐使用make_shared初始化,尽量不要使用裸指针初始化两个裸指针初始化一个共享指针会导致异常);

此外,与原书所录不同,C++ 11的智能指针是支持管理数组对象的,也没有使用delete不使用delete[]的问题,尽管很少使用:

1
2
3
4
std::unique_ptr<int[]> uArrayptr = std::make_unique<int[]>(4);
uArrayptr[0] = 1;//......
std::unique_ptr<int[]> uArrayptr1(new int[4]{1,2,3,4});
cout<<uArrayptr1[3];
而大部分情况下,stringvector已经足够用于动态分配了。题外话,vector内存管理是这样的,它分成两个部分,一部分是控制信息,一部分是数据内存,它们分配是:控制信息随声明而变化,如果是局部数组,就在栈区,如果是new则在堆区,如果是全局变量、静态变量等则位于全局静态区,而对于数据内分配位置往往是在堆区

条款14:Think carefully about copying behavior in resource-managing classes.

在资源管理类中小心拷贝行为。 其实这是std::unique_ptrstd::shared_ptr深层次设计面对的问题,当你有一个资源管理类,你会允许它们复制吗?允许RAII对象复制通常不是一个好选择,因为它们和资源绑定,你会有以下选择:

  • delete copying:首先是std::unique_ptr,它delete拷贝构造拷贝赋值两个copying函数,禁用了复制,因为一旦管理对象复制发生,资源被其中一方析构另一方可能会做出野指针操作或者双重delete,这是非安全行为;

  • reference countstd::shared_ptr使用了引用计数;这里原书强调的不只是资源引用归零删除问题,还有当资源管理类构造时上锁、析构解锁,其如何复制锁的资源?必然经过解锁-复制的过程,因此提到了shared_ptr的第二个参数——自定义删除器,通过定义这个删除器函数来解决锁的问题;

  • deep copying:深拷贝,即申请内存存入资源的副本

  • 转移资源:这也是std::unique_ptr的行为,std::unique_ptr不支持直接拷贝,但可以通过右值等转移资源所有权

条款15:Provide access to raw resources in resource-managing classes.

在资源管理类中提供对原始资源的访问。 本条款完全是因为需要对C接口的API兼容,因此在资源管理类必须提供raw resources的接口,就像智能指针提供了.get().data()(for Qt)等作为raw指针的访问接口(例如connect函数必须接收一个裸指针对象); 虽然这种方法比较不美观,大可以提供一种隐式转换**的方法:

1
2
3
4
5
6
7
8
class Font{
public:
FontHandle operator FontHandle()const{
return f;
}
private:
FontHandle f; //raw resource
};
但是这种写法容易发生灾难,例如当你需要一个Font时,却得到了FontHandle:Font f1(); FontHandle f2 = f1; 当f1资源析构,f2因为是裸资源,即不会发生拷贝、引用计数也不会发生资源转移,导致f2悬空;因此仍然推荐使用get等显式接口来作为访问路径;

条款16:Use the same form in corresponding uses of new and delete.

成对使用new和delete的相同形式。

当指针是new单一对象,使用delete,当指针是new[],使用delete[],中括号是告诉析构函数析构对象为数组的唯一标识,如下:

1
2
3
4
5
string* str = new string;
delete str;

string* strArray = new string[100];
delete []strArray;
当有时候数组隐晦地使用了别名,也务必遵守:
1
2
3
4
5
typedef std::string Address[100];
// or using Address = std::string[100];

std::string* str = new Address;
delete []str;

条款17:关于std::tr1::shared_ptr的过期条款

条款17是一个过期条款,C++11前的智能指针常常描述成std::tr1::shared_ptr,写成这种形式可能导致不安全的内存泄漏:

1
func(std::tr1::shared_ptr<Class>(new Class),...)
因为参数...可能是函数返回值,例如函数调用失败,new出来的内存指针会丢失,导致内存泄漏,因此应该额外写构造再传入:
1
2
std::tr1::shared_ptr<Class> pw(new Class);
func(pw,...)

第四章:Designs and Declarations,设计与声明

条款18:Make interfaces easy to use correctly and hard to use incorrectly.

让接口容易被正确使用而不易被误用。

例如一个表现日期的类:

1
2
3
4
class Date{
public:
Date(int month,int day,int year);
}
你的客户在传递参数时,可能调换了参数的顺序,为了避免这种情况应该使用wrapper types来区分参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Day{
explicit Day(int day):val(day){}
int val;
};
struct Month{
explicit Month(int month):val(month){}
int val;
};
struct Year{
explicit Year(int year):val(year){}
int val;
};
class Date{
public:
Date(const Month& month,const Day& day, const Year& year);
};
除了顺序问题,有时还可能固定参数的取值范围,例如参数为13的月份是非法的,一种选择是使用enum类型,但是enum并不具备类型安全,因为enum x = 13也是合法的;

根据条款4的经验,可以这样使用静态返回值,既可以返回指定对象,也避免了返回未初始化对象的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Month{
public:
static Month Jan(){return Month(1);}
static Month Feb(){return Month(2);}
static Month Mar(){return Month(3);}
//...
private:
explicit Month(int month):val(month){}
int val;
};
class Date{
public:
Date(const Month& month,const Day& day, const Year& year){}
};

//main:
Date date(Month::Jan(),....);

条款19:Treat class design as type design.

设计类犹如设计type。

和许多OOP(面向对象编程)语言一样,当定义一个新的class,也就是定义一个新的类型,你必须考虑类的重载操作符函数、内存的分配和归还、定义对象的初始化和总结,一个高效的class会面向以下的问题:

  • 新type的对象如何被创建和销毁:包括类的构造、析构函数和内存分配函数operator new/operator new[]/operator delete/operator delete[](前提是你打算撰写它们,第八章设计会讨论);

  • 对象的初始化和赋值有什么差别

  • 新type对象如果值传递会发生什么,也即如何设计拷贝构造函数

  • 新类的合法值如何:约束成员函数的错误检查、抛出异常等;

  • 新type的继承图系(inheritance graph)是如何:如果type继承某些classes,会受到它们的virtual和non-virtual影响吗,同理你是否允许其他类继承你的新type,关系到是否将其析构声明为virtual;

  • 需要实现什么样的成员函数和重载操作符函数:条款23、24、46;

  • 什么样的标准函数应该驳回:例如条款6将copying函数(拷贝、赋值等)声明成private、单例中将构造声明成私有;

  • 什么是新type的未声明接口(undeclared interface):它对效率、异常安全性及资源运用提供何种保证?(条款29);

  • 你的type是否一般化:如果你定义的是一个type家族而不是单一的type,应该使用类模板;

条款20:Prefer pass-by-reference-to-const to pass-by-value.

以const引用传递代替值传递。

值传递造成额外开销

值传递在基本数据类型上略优于引用传递,但如果是复杂类作为参数仍然使用值传递,会导致额外的开销,例如使用void func(Student s),当你传入子类对象s,s除了额外构造自己,还需要构造成员中可能的类(如std::string)、继承的基类等,使用const Student& s 高效很多;

值传递可能导致切割问题

切割(slicing)问题是指向父类参数传入子类对象,如果使用了值传递,那么该子类本身特性可能会被全部切割,例如其重写的虚函数等;

条款21:Don't try to return a reference when you must return an object.

必须返回对象时不要妄想返回引用。

一个例子是返回计算乘法的计算结果:

1
2
3
4
5
6
class Rational{
public:
const Rational operator*(const Rational& lv,const Rational& rv){
...
}
};
你有几种替代的糟糕选择:

  1. 返回对象引用:其一,C++引用必然是来自已经存在的地址,我们需要计算的对象必然不存在;其二,当你使用了局部对象存储这个结果,返回这个对象,然而局部对象生命周期在函数结束时已经结束,因此导致野指针操作;

  2. 返回指针的引用

    1
    2
    3
    4
    5
    const Rational& operator*(const Rational& lv,const Rational& rv){
    Rational* result = new Rational;
    *result = ...
    return *result;
    }
    当客户这样调用时:A*B*C,竟然导致了泄漏了两次;

  3. 返回静态对象引用

    1
    2
    3
    4
    5
    const Rational& operator*(const Rational& lv,const Rational& rv){
    static Rational result;
    result = ...;
    return result;
    }
    对于该计算应用显然不是好选择,当你写下if(a*b==c*d),无论a、b、c、d是什么,结果总是成立,因为static局部变量多次调用中,总是只有一份静态数据;

条款22:Declare data members private.

将成员变量声明为private。 为了封装性,容易无损地替换所有客户代码的实现方法。

条款23:Prefer non-member、non-friend functions to member functions.

宁以非成员、非友元函数代替成员函数。

单看题目有点误导性,这并非否定成员函数本身的意义。考虑一种情况,需要对类的数据进行清理(例如清理浏览器的缓存、历史和cookies),有两种选择:

  1. 使用成员函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class WebBrowser{
    public:
    void clearCache();
    void clearHistory();
    void clearCookies();

    void clearAll(){ //成员函数清理
    clearCache();
    clearHistory();
    clearCookies();
    }
    };

  2. 使用非成员函数:

    1
    2
    3
    4
    5
    void clearAll(WebBrowser& wb){
    wb.clearCache();
    wb.clearHistory();
    wb.clearCookies();
    }

反直觉的答案是,第二种做法更加被推荐,而且为了维护其与WebBrowser的相关性,可以将其放在同一个命名空间中:

1
2
3
4
namespace WebBrowserStuff{
class WebBrowser{...};
void clearAll(WebBrowser& wb){...}
}
其理由是:第二种方法提高了WebBrowser的封装性,因为越少函数能够访问类的private成员,该类的封装性越高。

这里针对的对象不是成员函数,clearAll完全可以是另一个类(例如清理工具类)的成员函数,只是它不适合作为WebBrowser的成员函数,但是比起类,这种命名空间的做法更加灵活,因为它可以跨越头文件作用域,C++标准库的组织形式就是如此,例如若干个头文件:

1
2
3
4
5
6
7
8
9
10
//头文件1
namespace WebBrowserStuff{
class WebBrowser{...}; //核心函数
void clearAll(WebBrowser& wb){...}
}

//头文件2
namespace WebBrowserStuff{
.... //与WebBrowser相关的其他函数,如书签管理
}

这样当你不需要书签管理时,你根本不需要include这个头文件;而且命名空间允许了在多个头文件扩展相关功能;

条款24:Declare non-member functions when type conversions should apply to all parameters.

若所有参数需要类型转换,请使用非成员函数。

定义这样的一个类:这个类完成了乘法的重载计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rational{
public:
Rational(int num):num(num){}
int getNum()const{
return num;
}

const Rational operator*(const Rational& rhs){
num *=rhs.num;
return *this;
}

private:
int num;
};

于是我们这样调用:

1
2
3
4
5
6
7
8
9
10
Rational a(2);
Rational b(3);
Rational result = a*b; //ok
cout<<result.getNum();

Rational result1 = a*2; //ok
cout<<result.getNum();

Rational result1 = 2*a; //报错!
cout<<result.getNum();
第一次是正常调用,第二次产生了Rationalint隐式转换(编译器会使用2隐式地构造Rational)再调用成员函数,然而到了第三次就不行了,因为成员函数没有2这种类;

也就是说,成员函数对这种重载关系,当采用隐式转换参数时,二元的关系并不是对称的,而乘法交换律是很自然的事情,所以并不推荐这种情况下使用成员函数,而是使用普通函数:

1
2
3
4
5
6
7
8
9
10
const Rational operator*(const Rational& lhs,const Rational& rhs){
return Rational((lhs.getNum())*(rhs.getNum()));
}

调用:
Rational result1 = a*2; //ok
cout<<result.getNum();

Rational result1 = 2*a; //ok
cout<<result.getNum();

条款25:Consider support for a non-throwing swap.

std::swap本来只是一个STL函数,但后来成为了异常安全性编程(exception-safe programming,见条款29)的脊柱,以及用来处理自我赋值可能性的一个常见机制(条款11),std::swap如此重要之余也带来了非凡的复杂度,本条款将讨论这些复杂度的因应之道;

std::swap的典型实现是:

1
2
3
4
5
6
7
8
namespace std{
template<typename T>
void swap(T& a, T& b){
T temp(a);
a = b;
b = temp;
}
}
只要T是一个支持copying(拷贝构造、拷贝赋值),就可以通过std::swap交换它们,须知非delete情况下copying都是自动生成的,因此对一个类直接swap是合法的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class Person{
public:
Person(int a):a(a){}
int a = 0;
};

int main(){
Person p1(10);
Person p2(100);
std::swap(p1,p2);

cout<< p1.a << p2.a; //100,10

return 0;
}

但一些情况下,使用std::swap却非常臃肿,例如那些“以指针指向一个对象,内含真正数据”的类型,这种设计的表现称为pimpl(pointer to implementation,见条款31)手法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//一个含大量数据的类
class WidgetPimpl{
private:
vector<double> vp;
};

class Widget{
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs){
*wpl = *(rhs.wpl);
}
private:
WidgetPimpl* wpl; //指针对象指向这个大数据类
};
此处如果对Widget进行swap,swap会构造一个Widget再进行交换赋值(这相当于对大量数据进行了三次操作),而事实上我们只需交换两个类中的成员指针即可,因此我们想提醒std::swap,使用特化,因为是对Widget的私有成员操作,因此我们需要通过一个中间代理成员函数swap来实现交换,请注意,对std空间函数修改是不被允许的,但是全特化其中的函数是可行的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget{
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs){
*wpl = *(rhs.wpl);
}
//
void swap(Widget& rhs){
using std::swap; //这个声明是重要的
swap(wpl, rhs.wpl);
}

private:
WidgetPimpl* wpl; //指针对象指向这个大数据类
};

namespace std{
template<>
void swap<Widget>(Widget& a, Widget& b){
a.swap(b);
}
}

还有一种情况使得这种全特化版本std::swap不可用,那就是class是一个类模板:

1
2
3
4
5
6
7
template<typename T>
class Widget{
public:
void swap(Widget<T>& a, Widget<T>& b){
a.swap(b);
}
};
此时我们再也无法在std命名空间使用这个成员函数了,因为函数是没有偏特化这一种做法的,另一方面,对std函数实现重载也是违法的,因此只能将class template和非成员swap写入一个新的命名空间:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <vector>

using namespace std;

//一个含大量数据的类
class WidgetPimpl{
private:
vector<double> vp;
};


namespace newStuff{ //集中在命名空间中
template<typename T>
class Widget{
public:
void swap(Widget<T>& rhs){
using std::swap;
cout<<"mark"<<endl;
swap(wpl, rhs.wpl);
}
private:
WidgetPimpl* wpl; //指针对象指向这个大数据类
};

template<typename T>
void swap(Widget<T>& a, Widget<T>& b){
a.swap(b);
}
}

int main(){
newStuff::Widget<int>w1;
newStuff::Widget<int>w2;
newStuff::swap<int>(w1,w2);

return 0;
}
看似抛弃std::swap是最后的答案,这种方式既适合class template,也适用于class,然而为了确保你误调用std::swap(xxx)时仍然得到较高性能版本的swap,你应该保留全特化std::swap的写法。

第五章:Implementations,实现

条款26:Postpone variable definitions as long as possible.

尽可能延后变量定义式的出现时间。

变量一旦定义就可能触发构造函数,当变量离开作用域就触发析构函数,因此应该到明确使用时才定义这个变量。 ### 抛出异常可能浪费定义式的构造和析构

1
2
3
4
5
6
void test(){
string str; //异常抛出时浪费构造
if(xxxx){
throw std::xxx_error(xxx);
}
}

循环变量的处理

1
2
3
4
5
6
7
8
9
10
string name;
for(int i = 0; i<n; i++){ #1
name = xxx;
......
}

for(int i = 0; i<n; i++){ #2
string name = xxx;
......
}

第一种的代价是1次构造+1次析构+n次赋值,第二种的代价是n次构造+n次析构;

从易读性考虑,作者似乎更推荐做法2,除非n很大、明确知道赋值成本更低、该部分位于效率高度敏感部分几种情况,否则应该选择第二种写法;

条款27:Minimize casting.

尽量减少类型转换操作。

主要原因来自类型转换并非单纯地改变编译器解析数据的逻辑,而是真真实实地产生了某些额外的编译码,例如将int转型到double,因为两种数据类型的存储原理不同,所以应该会有新的代码产生;

另一种更加深刻的是父子类指针的互相转换,例如:

1
2
3
4
5
Base* base = new Derived();
printf("%p\n",base);

Derived* son = dynamic_cast<Derived*>(base);
printf("%p",son);
注意,下行转换(父类到子类,downcasting)并非是完全不安全的,只要它们在一个继承体系下,满足两个条件

  1. dynamic_cast要转换的父类对象,是多态对象至少提供了一个虚函数常是虚析构函数);

  2. dynamic_cast转换的父类指针,实际上指向的是一个子类对象(例如上例我们使用父类指针指向一个子类对象,这种在多态中尤其常见);

如果条件1不满足,那么没有足够的RTTI信息,编译会失败;要注意的是,如果条件1通过但条件2不通过,能正常编译,但是你得到的对象绝对是一个危险产物

1
2
3
4
5
6
Base* base = new Base();
printf("%p\n",base);

Derived* son = dynamic_cast<Derived*>(base);
son->doSomething();
printf("%p",son);
迷惑性在于你的doSomething有可能被正确地执行(实际上是一个UB),然而你会发现这个son指针因转换失败成为一个空指针

还有一个神奇的地方,在C/Java/C#中你可以断言一个对象只有一个地址,但是在C++中,这个假设是错误的,一个对象有可能存在一个以上的地址:

1
2
3
4
5
Derived d;
printf("%p\n",d);

Base* test = &d;
printf("%p",test); //与上指针不同
这里将子类地址隐性地转换到父类指针,正如int转double时某些东西发生改变,这里需要获取父类指针,往往需要某些地址偏移量来处理子类指针,故得到不同的指针结果;这启示我们如果随意将对象指针转到char*,再进行某些算术行为,往往导致无定义行为;

再者,转型的行为有可能导致父子类函数的误用,例如:

1
2
3
4
5
6
7
8
9
10
11
12
class Window{
public:
virtual void doSomething(){...}
};

class SpecialWindow : public Window{
public:
void doSomething()override{
static_cast<Window*>(*this).doSomething();
//...子类专属处理
}
};
一种致命的情况是父类的doSomething作用于某些成分变量,然而这种转型只是在一个“临时的父类对象”上作了某些作用,根本不会改变子类中的父类成分,导致一个残缺的子类对象(父类成分未处理,子类对象被处理);

而且,dynamic_cast的性能是很低的,我们使用dynamic_cast的理由,无非是想调用一个子类对象以及其操作函数,无奈只有一个父类指针,此时我们宁愿在父类中提供一个什么都不做的虚函数,或者提前存储这样的子类对象。

条款28: Avoid returning "handles" to object internals.

避免返回handles指向的对象内部成分。

handles指的是引用、指针、迭代器等能直接干扰元对象的级别函数,所以:

  1. 使用这些作为函数的返回值时,考虑是否加上const使得外部无法修改,否则其变量的封装性等同于这些非const引用函数的访问权限,而不是private;

  2. 谨慎使用这些作为返回值,因为这些返回值可能是局部的,将导致意外销毁后操作空指针;

条款29:Strive for exception-safe code.

为“异常安全”努力是值得的。

异常安全分成三个层次等级:

  1. 基本承诺:尽管函数抛出异常,程序内任何事物仍然保持在有效状态,没有数据结构被破坏,但程序的现实状态不可完全被预料

  2. 强烈保证:函数抛出异常,程序的状态不发生改变,即能够回复到函数开始前的那刻状态;

  3. nothrow保证:并不是保证函数不会抛出异常(这在C++系统中几乎不可能),而是如果函数抛出异常,将导致严重后果,例如意想不到的函数被调用。

如下一个例子:这是一个改变背景的函数,通过锁来确保更改过程的数据安全,但是如果这个函数抛出异常,将带来糟糕的后果:死锁、内存泄漏、野指针、change_times计算错误等:

1
2
3
4
5
6
7
void PrettyMenu::changeBackground(std::istream& imgSrc){
lock(&mutex);
delete bgImage;
++change_times;
bgImage = new Image(imgSrc);
unlock(&mutex);
}

解决方法基本是RAII:现在唯一可能导致异常不安全的是reset的构造抛出异常,对象构造的异常,无非就是备份、异常回复、删除备份(copy and swap),但对现代项目大多数场景而言,这基本不现实,此处省略。

1
2
3
4
5
void PrettyMenu::changeBackground(std::istream& imgSrc){
unique_lock(&mutex);
bgImage.reset(new Image(imgSrc));
++change_times;
}

条款30:Understand the ins and outs of inlining.

透彻理解内联的前世今生。

inline指的是对那些基本不涉及额外函数调用的函数,在编译器中往往直接展开成函数本体,例如标准的std::max实现:

1
2
3
4
template<typename T>
inline const T& std::max(const T& a, const T& b){
return a < b ? b : a;
}
inline函数和模板函数都有一个共同点,就是一般均声明和定义在头文件,因为它们往往在编译期进行匹配和内联,编译期在链接之前就需要获知它们的本体是什么样子的。

inline一般在函数本体代码量很少的情况下被声明使用,如果函数本体代码量很大,内联后替换的代码可能还多于函数调用产生的代码。当然,inline不是强制的命令标识,没有inline的函数可能也被隐晦地内联,声明inline的函数可能也被编译器忽略(例如带循环、递归的函数、调用了虚函数的函数(因为虚函数运行期才真正确定调用对象)。

以上规则使得似乎构造函数、析构函数是完美的inline候选人,但是这可能是错误的直觉,因为C++总是为对象的构建和删除做出各种保证,保证的行为是来自编译器,所以看似没什么内容的构造函数可能做了各种错误处理,再者,对于派生类而言,其构造函数还调用了基类的构造函数,因此这些函数发生inline反而造成体积膨胀,这并不绝对,但极少有对构造和析构inline的决定。

条款31:Minimize compilation dependencies between files.

将文件间编译的依存关系降至最低。

当你只是简单修改了一个类Person的私有成分,点击build却发现项目被重新编译和链接了,问题就是出在C++没有将接口和实现完全分离,使得每次Person被修改,所有依赖Person头文件的代码都会被重新生成,例如:

1
2
3
4
5
6
7
8
9
10
11
#include <Date>
#include <Address>
#include <string>

class Person{
...
private:
Date date;
Address address;
string str;
};
PersonDateAddress有依赖,因此如果DateAddress改变,Person将被重新编译,所以你可能疑惑为什么C++不采用以前向声明的方式引入类,而是需要在类定义中引入类变量,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Date;    //仅使用前向声明,而不定义变量
class Address;
namespace std{
class string;
}
class Person{
...

private:
Date date;
Address address;
string str;
};
根据前向声明解决循环依赖问题的经验,前向声明是无需引入头文件的。这样做存在问题:其一string前向声明不是这么简单,其涉及模板的声明,忽略这一点,因为标准头文件几乎不可能成为编译的瓶颈;其二,也很简单,当在Person类中使用DateAddress时,必须知道它们的大小以分配内存空间,所以任何涉及拷贝语义的使用都会异常,当然Java等一些语言则是一律将这些未定义类看成指针大小,如Date* date

为了前向声明满足头文件需求,接口实现分离就依赖于两点:

  1. 头文件内不使用拷贝语义,即尽量使用指针、引用传递代替值传递

  2. 一种值传递的行为是例外的:当类做返回值、或者参数类型声明时,尽管是值传递仍然可以使用前向声明:但注意,不能对testtest1进行调用!

    1
    2
    3
    class Date;
    Date test(); //ok
    void test1(Date date);
    原书示例并不完整,具体实现参考工厂模式即可。

第六章:Inheritance and Object-Oriented Design,继承与面向对象设计

条款32:Make sure public inheritance models "is-a".

公有继承是一种"is-a"关系。

当你让D类公有继承B类意味着:每个D都是一个B对象任何接受B的地方都能够接受D,反之不成立。

public继承的思想,是希望能应用在父类身上的特性,能够完全施行在子类身上,但是有一些直觉的公有继承可能是错误的,例如是否应该让正方形类继承矩形类

答案是否定的。例如,当矩形定义了“扩大高度”这种函数,并assert保证函数不会纂改宽度,这个函数无法施加到子类身上。所以在公有继承时也应该审视父类的每一项特性是否会影响子类。

使用公有继承而不想继承父类的所有函数,这是荒谬的想法,见条款33。

条款33:Avoid hiding inherited names.

避免遮掩继承而来的名称。

这是有关作用域的问题,例如double x就遮掩了int x的作用域:

1
2
3
4
5
int x = 10;
void func(){
double x;
///...
}
在继承中更是如此,例如,当派生类有这样的代码:
1
2
3
4
5
6
7
8
class Derived : public Base{
public:
void test(){
//...
mf2();
//...
}
};
编译器首先在test函数作用域查找mf2定义,没有找到会在派生类内查找,没有找到然后往外围查找,例如父类的作用域内,若没有则会到父类所在命名空间的作用域,最后到全局作用域

无论是普通函数虚函数纯虚函数,尽管函数参数不同,如果发生继承且重名,就可能导致父类的函数继承失败,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
virtual void mf1() = 0;
virtual void mf1(int x);
void mf2();
void mf3();
void mf3(int x);
};

class Derived : public Base{
public:
virtual void mf1();
void mf3();
};
当子类调用:
1
2
3
4
5
mf1(); //调用子类
mf1(3); //失败
mf2();
mf3(); //子类自身
mf3(3); //失败

public using标签公开父类同名

因为作用域遮掩,对父类的mf1、mf3继承都会完全失败。如果希望仍然能使用父类的带参函数,只能使用using标签:

1
2
3
4
5
6
7
class Derived : public Base{
public:
using Base::mf1; //使得编译器能够看到父类的mf1、mf3东西
using Base::mf3;
virtual void mf1();
void mf3();
};
using标签仍然不能阻止子类mf1对同样无参的父类mf1的掩盖,如果需要使用父类无参版本,需要使用转交函数

转交函数

在public继承中不希望子类继承父类的某些函数是不可能的,但是在私有继承中可能你只想使用父类的无参版本,就需要转交函数,实际上就是在子类显式指明父类作用域版本:

1
2
3
4
5
6
7
8
9
10
class Derived : private Base{
public:
using Base::mf1; //使得编译器能够看到父类的mf1、mf3东西
using Base::mf3;
virtual void mf1(){
Base::mf1(); //这也是一种inline
//...可添加其他逻辑
}
void mf3();
};

更复杂的情况是继承结合模板,遮掩问题就需要拓展,见条款43;

条款34:Differentiate between inheritance of interface and inheritance of implementation.

区分接口继承和实现继承。

有时候可能你希望只继承接口(纯虚函数),有时候你可能需要同时继承接口和实现,并且能够覆写其实现(虚函数),有时候你可能需要继承接口和实现,并且不允许任何覆写(普通函数);

纯虚函数可享有定义

具有纯虚函数的对象(抽象类)不能被实例化,因为其函数行为是不完整的,其次其虚函数表中对应函数指针也是缺省的,在头文件中纯虚函数被定义成:

1
virtual void draw() const = 0;
但事实上它是可以被提供定义的,虽然这个意义有限(并非完全冗余,见下文),但只要特定地指明父类调用,你完全可以做到通过子类对象去调用父类的纯虚函数,这种行为被C++认可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Person{ //基类
public:
virtual void draw() = 0;
};

class Son : public Person{ //子类
public:
void draw() override;
};

void Person::draw(){
cout << "draw" << endl;
}

void Son::draw(){
cout << "Son draw" << endl;
}

int main(){
Person *p = new Son(); //上行转换
p->draw(); //Son draw
p->Person::draw(); //你调用了一个纯虚函数,输出draw

return 0;
}

纯虚函数比虚函数更具迫使性

虚函数能够让函数覆写或者直接使用其实现,但是有一种情况可能引起执行错误,假设一个基类提供了虚函数缺省行为:

1
2
3
4
5
6
class Base{
public:
virtual void fly(const Airport& destination){
//Default Behaviour...
}
};
当两个类A、类B继承这个函数,并且使用其默认行为(缺省行为),是OK的,但轮到C类不能使用这个默认行为,却忘记定义自身行为,那么就导致失误性的调用,解决方法是将函数改写成:

1
2
3
4
5
6
7
8
class Base{
public:
virtual void fly(const Airport& destination) = 0;
protected:
void defaultBehaviour(const Airport& destination){
//Default Behaviour
}
};

这样A、B只需要在自定义时做一个inline即可:

1
2
3
4
5
6
class A:public Base{
public:
virtual void fly(const Airport& destination){
defaultBehaviour(destination);
}
};
这个defaultBehaviour不能是虚函数,否则又陷入了忘记定义default虚函数的循环。当然,另一种方法就是统一接口,也就是不用额外地定义defaultBehaviour,给纯虚函数加个定义即可:
1
2
3
4
5
6
7
8
class Base{
public:
virtual void fly(const Airport& destination) = 0;
};
//pure func:
void Base::fly{
//DefaUlt Behaviour
}
效果是等效的,完全可以防止C忘记重写函数而使用父类缺省行为。