读书笔记 Effective C++

共有55个法则。

1、视C++为一个语言联邦

C++包含多个次语言:

  • C
  • 面向对象
  • 模板
  • STL

每个次语言都有自己不同的规范。

2、尽量用const,enum,inline代替#define

#define宏定义直接被替换掉,因此像

1
#define NUM 123

当程序获得一个编译错误时,不会出现NUM,因为NUM已经被123替换掉了,可能存在多个123,因此就无从得知123对应的是什么意思,用const就不会有这种问题。此外const还可以定义类内的专属常量,比如:

1
2
3
4
5
class GamePlayer{
private:
static const int NumTurns = 5;
int scores[NumTurns];
}

使用enum hack也可以做到这一点:

1
2
3
4
5
class GamePlayer{
private:
enum { NumTurns = 5 };
int scores[NumTurns];
}

enum中的数值可以当作int来使用。
这种常量是类内专用的,如果用#define就做不到这一点,因为#define的值是作用到全局的。enum hack的行为更像#define,它们都不能取地址,而const可以取地址。

对于函数形式的宏,会导致各种各样的问题,最好用inline函数代替。

3、尽可能使用const

const修饰的对象不能被改变,编译器会强制实行这个约束。

const作用于类的成员函数时,不能对成员变量进行修改,但是通过使用mutable关键字的成员变量可以在const成员函数里被改变。

当const成员函数和non-const成员函数的实现等价时,可以让non-const函数调用const版本避免代码重复,方法是用const_cast去除const属性。如下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TextBlock{
public:
const char& operator[](std::size_t position) const
{
...
...
...
return text[position];
}

char& operator[](std::size_t position)
{
return const_cast<char&>( //去除const属性
static_cast<const TextBlock&>(*this) //将*this转为const调用const版本的[]
[position]);
}
}

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

对于内置类型如int、double等,必须手工完成初始化,C++不保证初始化他们。

对于类类型,构造函数必须保证初始化所有的成员,最好的方式是使用初始化列表而不是在函数体内赋值,初始化列表使用对应参数直接进行拷贝构造,而函数体内赋值则是先经过默认构造函数构造然后再进行赋值,效率较低,且const,引用和没有默认构造函数的类成员只能在初始化列表中进行初始化。

对于位于多个不同文件中的static对象,为了避免初始化次序带来的问题,如A的初始化依赖于B,而B不保证在A初始化之前进行初始化,可以将static对象放到一个函数里,在函数里声明局部的static对象,返回这个对象的引用,将直接使用static对象改为调用函数来使用static对象,这样就保证static对象在使用时一定已经经过了初始化。如下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileSystem{...};
FileSystem& tfs(){
static FileSystem fs;
return fs;
}

class Directory{...};
Directory::Directory(params){
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir(){
static Directory td;
return td;
}

tfs()函数保证fs对象在使用前已经进行了初始化。

5、了解C++默默编写并调用哪些函数

编译器会自动为class创建默认构造函数、拷贝构造函数、赋值操作符、析构函数。只要用户自己声明了一个构造函数,编译器就不会自动创建默认构造函数。
如果类成员包含const或者引用,编译器不会自动合成一个赋值操作符,因为const和引用不能赋值,此时需要自己编写赋值操作符。
如果基类的赋值操作符是private的,则编译器也不会为其派生类合成赋值操作符。

6、若不想使用编译器自动生成的函数,就该明确拒绝

可以将成员函数声明为private的,来阻止编译器的自动合成行为。比如我们不希望一个类进行拷贝或赋值,可以将拷贝构造函数和赋值操作符声明为private,且不给出实现,如此当程序试图拷贝这个类时,编译器会报错,当类的成员函数或友元调用这个函数时,由于没有给出实现,因此连接器会报错。

1
2
3
4
5
6
7
8
class HomeForSale{
public:
...
private:
...
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&); //只声明不定义
}

第二种方法是设计一个不能拷贝的基类,让其余类派生自这个基类,则这些派生类也就不能拷贝了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Uncopyable{
public:
//允许构造和析构
Uncopyable();
~Uncopyable();
private:
//不允许拷贝和赋值
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
}
class HomeForSale:private Uncopyable{
...
}

7、为多态基类声明virtual析构函数

考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper{
public:
TimeKeeper();
~TimeKeeper();
}
class AtomicClock:public TimerKeeper{
...
}
TimeKeeper* tk = new AtomicClock;
...
delete tk;

创建一个基类的指针指向派生类对象,而该指针释放时,却由基类指针进行释放,而且析构函数不是virtual的,因此只有基类的部分进行了析构,而派生类的部分没有得到析构,因此造成了内存泄漏。将基类的析构函数声明为virtual就可解决这个问题。往往一个基类只要包含了其他的virtual函数,它的析构函数也要是virtual的,virtual函数是用来实现多态的,需要用基类的指针指向派生类对象,因此析构函数必须是virtual。若一个基类没有virtual函数,说明这个基类不是用于多态,就不需要virtual析构函数,因为virtual函数带来了额外的vptr的空间开销,因此若不需要多态就不应该将析构函数声明为virtual的。

8、别让异常逃离析构函数

只要析构函数抛出异常,程序可能过早结束或者发生不明确的行为。如果某个操作失败时会发生异常,则应该将这个操作移出析构函数放到另外一个函数里面,在析构函数里调用这个函数,此时析构函数应该捕捉任何异常,并且选择吞下它们不进行传播或者结束程序(abort函数)。

9、绝不在构造和析构过程中调用virtual函数

在构造和析构过程中调用virtual函数往往不会带来预期的结果。在派生类对象构造时,基类部分会先进行构造,如果基类的构造函数里调用了virtual函数,此时派生类部分还未初始化完成,因此这个函数不会下降到派生类去调用派生类里面的virtual函数,此时virtual函数的行为就像一个non-virtual函数一样,只是调用基类的,另一个根本原因是在派生类的基类部分构造期间,对象的类型就是基类而不是派生类,因此不存在多态。析构函数也一样,派生类优先于基类析构,如果此时析构函数调用了virtual函数,因为派生类部分已经被销毁了,调用派生类的virtual函数也是危险的。

10、令operator=返回一个reference to *this

为了实现连锁赋值,operator=应该返回一个指向左侧对象的引用。

1
2
3
4
5
6
7
8
class Widget{
public:
...
Widget& operator=(const Widget& rhs){
...
return *this;
}
}

11、在oprator=里处理自我赋值

由于多个指针或者引用可能指向同一个对象,所以对自我赋值需要特别注意。

1
2
3
4
5
6
7
8
9
10
11
class Widget{
private:
Bitmap* pb;
public:
...
Widget& operator=(const Widget& rhs){
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
}

以上的代码未考虑自我赋值情况,如果*this和rhs是同一对象时,第一行的delete就已经将rhs释放掉了。可以加上一个测试以解决自我赋值:

1
2
3
4
5
6
Widget& operator=(const Widget& rhs){
if(this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

但是上面这个版本不具备异常安全,当new抛出异常时,bp指向的对象已经被释放了,所以返回的是一个指向已经被删除的指针,这种指针非常危险,下面是一个异常安全的做法:

1
2
3
4
5
6
Widget& operator=(const Widget& rhs){
Bitmap* pOrigin = pb; //记住原来的pb
pb = new Bitmap(*rhs.pb); //构造新的pb
delete pOrigin; //释放旧的pb
return *this;
}

另一个方案是采用swap and copy:

1
2
3
4
5
Widget& operator=(const Widget& rhs){
Widget temp(rhs); //copy rhs
swap(temp); //swap
return *this;
}//temp对象析构,此时temp指向的原来的pb,被析构掉了

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

拷贝构造函数和赋值函数应该确保对每一个成员进行复制,特别是一个派生类的拷贝构造函数应该调用基类的构造函数来复制基类的部分。

1
2
3
4
5
class Base{...};
class Derived:public Base{
public:
Derived(const Derived& rhs):Base(rhs){...};
}

拷贝构造函数和赋值函数不应该互相调用对方,如果有相同的操作,应该放到第三个函数中,由两个函数调用。

分享到