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

  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_ = ....

条款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的引用。