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

  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.

考虑写一个不抛出异常的swap函数。