Effective C++ 内容提要(上)

Effective C++

Effective C++ 的内容提要。博主通读完一遍 Effective C++ 后,深感 C++ 的编写不易,在写程序时需要时刻考虑方方面面的多种因素,但我深知这些知识看完就会遗忘,因而将每个条款最精要的部分记录在此,方便自己和他人随时翻看。

让自己习惯 C++

条款 01:视 C++ 为语言联邦

要将 C++ 视为一个由相关语言组成的联邦而非单一语言。在其中某个次语言中,各种守则都比较易懂,但当从一个次语言切换到另一个,编程守则就可能改变。有 4 个主要的次语言:

  • C
  • Object-Oriented C++
  • Template C++
  • STL

条款 02:尽量以 const enmu inline 替换 #define

也可以说成”宁可以编译器替换预处理器“。因为#define 定义的符号无法进入记号表;没有作用域;不遵守访问规则;用宏实现函数会很麻烦等缺点。

可以使用 const 对象或者 class 中的 static const 对象替换 #define 中单纯的常量,用 inline 函数替换 #define 定义的“宏函数”,并不会带来额外的运行时间开销。

条款 03:尽可能使用 const

只要某个值保持不变是事实,就应该用 const 指明。若关键字 const 出现在 * 左边,表示被指物是常量;出现在 * 右边,表示指针自身是常量。关于 const

  • 令函数返回一个常量值,减少意外发生。const Rational operator*(...); 可以防止用户写出 if ((a * b) = c)

  • 两个成员函数若只是常量性不同,可以被重载。const char& operator[]() constchar& operator[]()

  • 注意 bitwise constness 和 logical constness,可以使用 mutable 关键字

  • constnon-const 成员函数中避免重复。令 non-const 版本调用 const 版本避免重复

    1
    2
    3
    4
    5
    6
    7
    char& operator[](std::size_t position) {
    return const_cast<char&>(static_cast<const XX&>(*this)[position]);
    }

    const char& operator[](std::size_t position) const {
    ...
    }

条款 04:确定对象被使用前已先被初始化

确保每一个构造函数都将对象的每一个成员初始化。注意在构造函数中,要使用 member initialization list 进行初始化。

C++ 有着十分固定的成员初始化次序。最好总是以声明的次序进行初始化。

注意不同编译单元内定义的 non-local static 对象的初始化次序。解决方法:将每个 non-local static 对象搬运到自己的专属函数内(该对象在此函数声明为 static)。这些函数返回一个 reference 指向它所含的对象。即 non-local static 被 local static 替换了:

1
2
3
4
5
class FileSystem
FileSystem& tfs() {
static FileSystem fs;
return fs;
}

构造/析构/赋值运算

条款 05:了解 C++ 默默编写并调用了哪些函数

注意,在程序员没有定义的情况下,编译器会默默地构造出:

  • default 构造函数
  • non-virtual 析构函数
  • copy 构造函数
  • copy assignment 操作符

但一般而言,只有当生成的代码合法且有机会证明其有意义时,才可。

条款 06:若不想使编译器用自动生成的函数,就应该明确拒绝

若你设计的某一类的对象是独一无二的,不可以被拷贝或者拷贝是无意义的,那么就应该:

  • 将 copy 构造函数或 copy assignment 操作符声明为 private
  • 不要定义它们

或者,你也可以继承一个阻止拷贝动作而设计的 base class:

1
2
3
4
5
6
7
8
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
}

或者考虑使用 Boost 库提供的 noncopyable 类。

条款 07:为多态基类声明 virtual 析构函数

当 derived class 对象经由一个 base class 指针删除,而该 base class 带有一个 non-virtual 析构函数,就会发生不确定行为。这是因为,只有 base class 的析构函数会被调用,将 derived class 对象中的 base class 部分销毁了,而剩下 derived class 部分没有销毁,造成了一个“局部销毁”的现象。因此,带有多态性质的 base class 应该声明一个 virtual 的析构函数。

而如果 class 不含 virtual 函数,往往表示它并不想做一个 base class,因此令其析构函数为 virtual 也是一个馊主意。因为 virtual 函数会带来完全不必要的开销。但有时候,令 class 带一个 pure virtual 析构函数会十分便利,比如我们在声明一个抽象基类(abstract base class),又想不到其他的 pure virtual 函数时,就可以:

1
2
3
4
class AWOV {
public:
virtual ~AWOV() = 0;
}

条款 08:别让异常逃离析构函数

C++ 不喜欢析构函数吐出异常!若你的析构函数必须执行一个动作,且该动作可能会在失败时抛出异常,那么可以在异常出现时就结束程序;也可以吞下异常。但这都不是非常好的选择!我们可以重新设计类的接口,让客户有机会处理可能出现的异常。

我们将析构函数执行的动作放到另一个新函数中(当然考虑到客户会忘记调用它,会在析构函数里另设一个保险),这样选择权就在客户手里。如果客户认为该动作决不会抛出异常,就不必调用新函数;但若因为客户忘调用新函数,导致析构函数抛出异常进而导致程序进入不确定行为,锅也不在设计者头上,毕竟客户自己放弃了处理异常的机会。

条款 09:绝不在构造和析构过程中调用 virtual 函数

不要在构造函数和析构函数中调用 virtual 函数,这样的调用会带来意想不到的效果。为什么?因为程序会先调用 base class 的构造函数,然后才会使用 derived class ,才会初始化这个 derived class 对象。如果你在构造函数中使用 virtual 函数,编译器只会将它认作为 base class 的对象(因为这时候 derived 部分还未完成构建,其部分成员值仍是未确定的),就会调用 base class 的函数,而不是你想的 derived class 的函数。

同样道理也适合用于析构函数,当析构函数开始时,derived class 对象就会被逐步销毁,编译器就会把它视作 base class 的对象,调用 base class 的函数。

可以使用一种办法避免这样的局面。先将构造函数中的 virtual 函数改为 non-virtual ,然后,我们要求 derived class 的构造函数传递必要的信息给 base class 构造函数,然后,base class 的构造函数就可以调用 non-virtual 函数。

条款 10:令 operator= 返回一个 reference to *this

为了实现像 x = y = z 的连续赋值,赋值操作符必须返回一个 reference 指向操作符的左侧实参。如:

1
2
3
4
Widget& operator=(const Widget& rhs) {
...
return *this;
}

条款 11:在 operator= 中处理“自我赋值”

*px = *py 如果恰巧 pxpy 指向了同一个东西,就会发生自我赋值。这会导致“你要使用该资源之前意外地释放了它”的潜在问题。

可以使用“证同测试”来消除“自我赋值”,即 if (this == &rhs) return *this; 。但为了做得更好,还需要考虑“异常安全性”,可以使用条款 29 的 copy and swap 技术,或者可以先暂存原先的值,等到新值被构建好并没有出现问题时,再将旧值抛弃。

条款 12:复制对象时勿忘其每一个成分

如果你自己在写一个 copying 函数时,一定要记住不要遗漏每一个成分,因为编译器不会警告这一点。请确保:1)复制了所有的 local 成员变量。2)调用了所有 base classes 的适当的 copying 函数。

特别注意,copy assignment 操作符与 copy 构造函数不要相互调用,这是荒谬的。如果你发现了这两个函数有相似的代码,那么你应该写一个第三方函数,再让它们都调用它。

资源管理

条款 13:以对象管理资源

为确保分配后的资源总是被释放,我们需要将资源放入到对象内。因为单纯依赖 delete 语句是不能百分百保证资源一定会被释放的,把资源放进对象内,便可倚赖 C++ 的析构函数确保资源被释放。Resource Acquisition Is Initialization (RAII) 告诫我们,在资源被获得的同时,就应该立即被放入管理对象中。

现在,C++ 11 以上的版本中,tr1::shared_ptrtr1::unique_ptr 或者 Boost 的相关库完全可以提供这项服务,不要再使用 raw pointer 了!

条款 14:在资源管理类中小心 copying 行为

前面的条款介绍了在 heap-based 资源上的管理方式:使用智能指针。然而并非所有资源都是 heap-based 的,也并非所有资源管理问题都可以用智能指针解决。例如,对于互斥锁(Mutex Lock)而言,我们就需要小心复制行为带来的问题。你可以:

  • 禁止复制。就如条款 06 中做的那样
  • 对底层资源使用引用计数。在复制时,将资源的引用计数增加,并在最后引用计数归零时删除(释放)资源。注意:tr1::shared_ptr 的删除器可能会帮你大忙。
  • 复制底部资源。在复制资源管理对象时,应同时复制其所管理的资源,进行深拷贝。
  • 转移底部资源的控制权。就像 tr1::unique_ptr 做的那样

条款 15:在资源管理类中提供对原始资源的访问

这一条款主要是针对那些直接使用 raw pointer 的 APIs 。因为你在编程的过程中几乎不可避免的遇到它们,你需要提供一个取得原始资源的方法。有两种解决方案:

  • 显式转换。比较安全,比如智能指针的 get() 函数。
  • 隐式转换。比较方便,例如 operator FontHandle() const { return f; }

条款 16:成对使用 new 和 delete 时要采用相同的形式

很简单,若你调用 new 时使用了 [] ,你必须在对应调用 delete 时也使用 [] ,如果你调用 new 时没有使用 [] ,那么也不应在调用对应 delete 时使用 []

条款 17:以独立语句将 newed 对象置入智能指针

如果不这样做,有可能导致难以察觉的资源泄漏。

1
2
3
4
5
6
7
8
9
10
11
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

// 调用 priority()
// 进行 new 但也可能顺序相反,在 priority() 函数抛出异常后,可能出现内存泄漏
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());

// 建议如下:将 new 对象的语句独立出来,不与任何其他语句掺和在一起
std::tr1::shared_ptr<Widget> pw(new Widget);

processWidget(pw, priority());

设计与声明

条款 18:让接口容易被正确使用,不易被误用

理想上,若客户错误地使用了接口,那么这个代码不应通过编译,如果代码可以通过编译,那么接口的行为就应当与客户的期望一致。例如:

1
2
3
4
5
class Date {
public:
Date(int month, int day, int year);
...
};

三个参数都是 int ,客户在使用接口时可能会把年月日的参数传错,也有可能传入了一个不存在的日期,比如 13 月 39 日。阻止误用的办法有:新建新类型,限制类型上的操作,束缚对象值,消除客户的资源管理责任。比如,设计者可以使用类型系统来防止这样的低级错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 使用外覆类型区别 年月日,但这样无法限制其值
struct Month {
explicit Month(int m): val(m) {}
int val;
};

// 可以使用静态函数来限制取值,注意到需要使用函数,详看条款04
class Month {
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
....
static Month Dec() { return Month(12); }
};

Date d(Month::Jan(), Day(30), Year(2010));

为避免资源泄露,可以将接口

1
Tank* createTank();

改成:

1
std::tr1::shared_ptr<Tank> createTank();

强迫客户使用智能指针,其好处正如条款 14所言,也可以防范 cross-DLL problem

条款 19:设计 class 犹如设计 type

设计一个好的 type 是一项艰巨的工作,自然的语法、直观的语义、一个或多个高效实现等。重载函数、控制内存、定义对象等都需要注意。你要考虑好以下问题:新的类型对象如何被创建和销毁?对象的初始化和赋值会有什么差别?新类型的对象若是 pass-by-value ,意味着什么?什么是该类的合法值?新的类型需要配合某个继承图系吗?新的类型需要什么样的转换?哪些操作符和函数对新类型而言是合理的?哪些标准函数必须被驳回?谁该使用新类型的成员?什么是新类型的未声明接口?新类型有多么一般化?你真的需要一个新类型吗?

条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value

默认情况下,C++ 都是使用 by-value 方式传递对象。这过程中会使用 copy 构造函数,对于一些体量庞大的对象来说,pass-by-value 是非常耗时的操作(因为要复制很多字节)。事实上,该条款给出的忠告可以这么理解,除了 C++ 中的内置类型和 STL 的迭代器和函数对象,其他任何东西(尤其是你实现的类)都最好使用 pass-by-reference-const 。

除了效率方面的考量,by reference 传递参数也可以避免对象切割的问题。当一个 derived class 对象以 by value 的方式传递到函数中,而函数参数的类型是 base class 类,那么 base class 的 copy 构造函数会被调用,导致传入的对象变成了 base class ,绝不会是你想要的 derived class。

references 在 C++ 编译器的底层中,往往通过指针实现。

条款 21:必须返回对象时,别妄想返回其 reference

可能受到上一条款的影响,我们可能会陷入另一个误区——在所有地方都是用 by reference 传递对象。然而,我们不可能传递一些 reference 指向其实不存在的对象

比如,你可能会在函数的最后,返回一个 local 变量的 reference😅。这是相当危险的,因为函数调用一旦结束,local 变量就会被销毁释放,其 reference 就指向了一个毫无意义的东西。当然,你可能还会想将 local 变量放入 heap 中,或者使用 static 变量,那就更离谱了。

当程序在逻辑上,就要返回一个新对象时,请不要吝啬这一点点的拷贝效率,就让那个函数返回一个新对象。

条款 22:将成员变量声明为 private

很多人都说所有的成员变量最好为 private ,可实际做工程时却又怕麻烦直接使用 public 的成员变量😅。事实上,类的所有成员变量都需要为 private ,然后使用函数做读/写访问。有两大原因:

  • 使用函数控制可以让成员变量的处理有更精密的控制,比如,你可以通过设置 getter 和 setter 实现只读访问、不准访问、读写访问,甚至只写访问。
  • 封装。如果有一个成员变量是 public ,并且在工程后续中客户使用了它。那么,一旦你需要对该类进行更改维护,你会发现很多客户的代码都需要变更!这是一个非常可怕的工作量。private 提供了非常好的封装,而且,我们使用类的一个原因不就是它的封装性么?

条款 23:宁以 non-member non-friend 替换 member 函数

还是一个与封装性有关的条款。面向对象守则要求数据应该尽可能地被封装,然而与直觉相反的是,提供 non-member 函数可允许对类的相关功能有较大的灵活性,可以更好地封装。为什么?从封装的角度出发,如果类内的东西被封装,它就不再可见,越多东西被封装,越少的人可以看到它,越少的人看到它,就意味着我们有越大的弹性来改变它

就如条款 22 所说,将成员变量声明为 private,那么就只有 class 的成员函数和其 friend 函数看到而已,改变就相对容易,改变的弹性就越大。增加一个member 函数就意味着增加一个可以窥见类内部秘密的“知情人”,其封装性就会减弱。因此,如果要在一个 member 函数(可以改变类内的 private 数据)和一个 non-member non-friend 函数中二选一,那么从封装的角度考虑,non-member non-friend 函数会更好。

值得注意的是,成为类的 non-member non-friend 函数并不意味它不可以是另一个 class 的 member ,比如可以设立某些工具类的 member 函数来完成这差事。

但在 C++ 中,更加自然的做法就是让 class 和这些 non-member non-friend 函数存在于同一个 namespace 中。因为 namespace 可以跨文件,那么这些 non-member non-friend 函数就可以按分类放在不同的文件内,当客户需要时,再 #include 相应的头文件,以此降低编译依赖性。C++ 的标准程序库就是这样组织起来的。

条款 24:若所有参数皆需类型转换,请为此采用 non-member 函数

如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member 。

举个例子来说明该条款,假设一个你设计的 Rational 类,用于计算分数的加减乘除。

1
2
3
4
5
6
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
...
const Rational operator*(const Rational &rhs) const; // 可以想一想为什么接受一个 reference-to-const ,返回一个 const by-value
}

重载 * 函数可以让你完成很多乘法运算,但

1
2
3
4
5
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight; // good
result = oneHalf * 2 // good
result = 2 * oneHalf // Error!

为什么最后一句话错了,因为整数 2 并没有相应的 operator* 函数!同样因为不存在接受 intRational 的函数,result = operator*(2, oneHalf) 也会报错。为什么倒数第二句话对,最后一句话错?因为只有当参数被列于参数列(parameter list),这个参数才是隐式类型转换的合格参与者!整数在第一个时,就无法隐式转换了。

其实,我们的目标就是让编译器把整数 2 “看作”一个 Rational 对象,那么,应当怎么办呢?很简单,只要我们避开 member 函数的陷阱,使用 non-member 函数,让第一个乘数也可以被放入参数列中就行。

1
const Rational operator*(const Rational &lhs, const Rational &rhs);

条款 25:考虑写一个不抛出异常的 swap 函数

swap 函数,原本只是用于将两者交换,但现在,却被赋予了更多重大的任务。用 swap 来应对异常安全性编程(见条款 29)和处理自我赋值(条款 11)已经非常常见。

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;
}
}

不过,我们还是得一步一步地讨论问题,首先来看效率方面。

std::swap 会调用 copy 构造函数来完成交换,而很多时候 std::swap 对你的类型效率不高时,此时就需要提供一个 swap 成员函数,并确定这个函数不抛出异常。比如说,你设计的类型是一种“以指针指向对象,内含真正数据”的,即所谓的 pimpl 手法

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(const Widget &rhs);
Widget& operator=(const Widget & rhs) {
...
*pImpl = *(rhs.pImpl);
}
private
WidgetImpl* pImpl;
}

那么如果要交换两个值,最有效率的办法毫无疑问就是交换它们内部的指针。那么,我们该怎么做才能让 std::swap 明白这一点呢?或者说我们要自己实现一个交换函数吗?可以利用 std::swap 的全特化版本,让 swap template 在遇到某一特定的类(Widget)时才会使用我们写的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace std {
template<>
void swap<Widget>(Widget &a, Widget &b) {
a.swap(b);
}
}

class Widget {
public:
void swap (Widget & rhs) {
using std::swap;
swap(pImpl, rhs.pImpl);
}
}

但如果遇到了 class template,情况就会变得更加复杂。因为 C++ 不允许偏特化一个 function template,只允许偏特化 class template。更重要的是,std 允许客户全特化内部的 template,但不允许添加新的 template 或者 class 等东西,这意味着我们也无法重载 std::swap

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class WidgetImpl {...};

template<typename T>
class Widget {...};

// nonono
namespace std {
template<typename T> // 不合法
void swap(Widget<T> & a, Widget<T> & b) { a.swap(b);}
}

我们可以声明一个 non-member swap ,让它调用 member swap,但不再将 non-member swap 声明为 std::swap 的全特化版本。它们存在于某个命名空间中(不能是 std)。但是,客户调用 swap 时,可不知道到底用哪个命名空间的 swap(客户甚至不知道 WidgetStuff 命名空间中是否存在专属的 swap),所以,我们需要用 using std::swap ,让 C++ 编译器在找不到原命名空间下的 swap 时,去调用 std::swap

1
2
3
4
5
6
7
8
9
10
namespace WidgetStuff {
template<typename T>
void swap(Widget<T>& a, Widget<T> &b) {
a.swap(b);
}
}

// 交换时,可以如下调用
using std::swap;
swap(obj1, obj2);

但不能这么写 std::swap(obj1, obj2); 。这样是在强制 C++ 编译器使用 std 空间下的 swap 函数,我们的本意可不是这样。

最后的最后,成员版的 swap 绝不可以抛出异常,因为 swap 的一个最好的应用就是提供强烈的异常安全性保障(条款 29)。注意,只是成员版的 swap 。


Effective C++ 内容提要(上)
https://dingfen.github.io/2020/10/20/2020-10-20-EffectiveCpp1/
作者
Bill Ding
发布于
2020年10月20日
更新于
2024年4月9日
许可协议