我动手写这篇博文,主要是出于三个原因:一是,我希望可以将《Effective C++》在读完后形成一本手册——可以短时间抓住核心进行使用;二是,可能是语言习惯的问题(译者是中国台湾的同胞侯捷先生),我在阅读本书时会有不少难以理解的地方,我尽量用自己的逻辑去梳理;三是,书中并未涉及 C++11,因此会在这里进行适当的填补。但是出于个人能力的有限,如果你想深入探究其中的细节,还请将原著反复研读😇
2024-09-26 11:57:21
目录:
条款10:令operator=
返回一个 reference to *this
2 构造/析构/赋值运算
它们控制着类的基本操作,像是产出新对象并确保它被初始化、摆脱旧对象并确保它被适当清理、以及赋予对象新值。如果这些函数犯错,会导致深远且令人不愉快的后果,遍及你的整个 class。因此将这些函数正确良好地集结在一起形成 class 的脊柱,是 “生死攸关” 的大事
条款05:了解 C++ 默默编写并调用哪些函数
当你声明了一个 empty class(空类)时,如果你自己没有声明下述的函数,编译器会为你声明:
- 一个 default 构造函数
- 一个 non-virtual 析构函数
- 一个 copy 构造函数
- 一个 copy assignment(拷贝赋值)函数
所有这些函数都是public
且inline
的
默认生成的 copy assignment 函数的特点:
- 浅拷贝
- 成员逐个复制
- 默认返回类的引用,以支持连续赋值操作
如果类内含const
成员和 reference 成员,那么你必须自己定义 copy assignment 函数。这是因为编译器觉得无法为 reference 和const
生成合理的赋值操作,干脆禁用了。例如重新绑定 reference 成员是不合法的,但是某些场景下你希望重新绑定,这就需要显式定义 copy assignment 函数来处理
C++ 中 reference 有两个关键特性:
- 一旦绑定对象,就不能重新绑定
- 赋值操作作用于 reference 所指的对象
C++11 在原有的基础上新增了移动构造函数和移动赋值函数,如果不是极其简单的 class,请自己编写好构造、析构、拷贝构造和赋值、移动构造和赋值(如有必要)这六个特殊成员函数
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
当你不希望 class 支持某一特定功能,只要不声明对应函数就是了。但对于特殊成员函数,这个策略会不起作用,因为条款 5 已经指出,编译器会为你声明。这里给出了两种方案解决这一问题:
- 将相应的成员函数声明为
private
并故意不实现。但是如果通过 member 或friend
函数不慎调用了,那么会得到一个链接错误 - 将上述可能的链接期错误移至编译期。设计一个专门的 base class,在其体内将相应的成员函数声明为 private 并故意不实现,接着通过私有继承继承该 base class 即可。这样的做法和用法过于复杂,并且可能会导致多重继承(见条款 40)
多重继承有时会阻止 empty base class optimization(见条款 39)
C++11 提出可以使用 = delete
来禁止编译器声明特殊成员函数
条款07:为多态基类声明virtual
析构函数
当一个 derived class 对象经由一个 base class 指针被删除,而该 base class 带有一个 non-virtual 析构函数,实际执行时通常仅仅执行了 base class 的析构函数,发生的是 “局部销毁”——对象的 derived 成分未被销毁, base class 成分被销毁
消除这个问题的做法很简单:给 base class 一个virtual
析构函数。需要注意的是virtual
函数是有代价的,因此给所有的 base class 带上一个virtual
析构函数是不可取的
virtual
函数的代价:为了实现virtual
函数,对象必须携带一个所谓的 vptr(virtual table pointer)。vptr 指向一个由函数指针构成的数组,称为 vtbl(virtual table)。每一个带有virtual
函数的 class 都有一个相应的 vtbl。当对象调用某一virtual
函数,实际被调用的函数取决于对象的 vptr 所指的那个 vtbl——编译器在其中寻找合适的函数指针
这里直接说明结论,polymorphic base class 应该声明一个virtual
析构函数。如果 class 带有任何virtual
函数,它就应该带有一个virtual
析构函数。如果 base class 并不是为了多态用途,就不需要virtual
析构函数(见条款 6 和条款 40)
如果一个 class 没有被设计为 polymorphic base class,又有被误继承的风险,在 C++11 中可以通过在 class 声明为final
,以禁止派生来防止 “局部销毁”
条款08:别让异常逃离析构函数
析构函数绝对不要抛出异常,那会导致程序出现未定义行为。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序
吞下它们(不传播)或结束程序其实都没什么吸引力,因为两者都无法对抛出的异常做出反应
如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通函数(而非在析构函数中)执行该操作
条款09:绝不在构造和析构过程中调用virtual
函数
1 | class Transaction { |
现在执行以下这行代码,会发生什么:
BuyTransaction b;
derived class 对象内的 base class 成分一定会在 derived class 成分被构造之前先构造妥当。这里 Transaction 构造函数调用virtual
函数,即使当前即将建立的对象类型是 BuyTransaction,这时被调用的 LogTransaction 也是 Transaction 内的版本。这是因为,base class 构造期间 virtual 函数绝不会下降到 derived class 阶层。也就是说,这里的virtual
函数表现的不再是virtual
函数,建立 derived class 对象时会调用错误版本的 LogTransaction
因此,在构造和析构期间绝对不要调用virtual
函数
条款10:令operator=
返回一个 reference to *this
为了实现 “连锁赋值”:
x=y=z=15;
赋值操作符必须返回一个 reference 指向操作符的左侧实参:
1 | Entity& operator=(const Entity& rhs) { |
条款11:在operator=
中处理 “自我赋值”
一般而言,如果某段代码操作 pointers 或 references 而它们被用来 “指向多个相同类型的对象”,就需要考虑这些对象是否为同一个:
1 | a[i] = a[j]; // 如果 i == j,自我赋值 |
实际上,两个对象只要来自同一个继承体系,它们甚至不需要声明为相同类型就可能造成 “别名”,即 “有一个以上的方法指向同一对象”:
1 | class Base { ... }; |
“自我赋值” 存在 “在停止使用资源之前意外释放了它” 的陷阱:
1 | class Bitmap { ... }; |
传统的解决方法,增加一个 “证同测试(identity test)” 来达到 “自我赋值” 的检验目的:
1 | Widget& operator=(const Widget &rhs) { |
通常让operator=
具备 “异常安全性” 往往自动获得 “自我赋值安全性” 的回报,而许多时候精心排列的语句就可以导出异常安全(见条款 29):
1 | Widget& operator=(const Widget &rhs) { |
手工排列语句有一个替代方案,就是使用所谓的 copy and swap 技术(见条款 29)
条款12:复制对象时勿忘了其每一个成分
当你每为 class 添加一个成员变量,你必须同时修改 copying 函数,否则会出现局部拷贝(partial copy)的现象,更可怕是由于你拒绝了编译器缺省实现的 copying 函数,编译器将不会提醒你忘记复制了某个成员变量
一旦发生继承,问题会变得更加隐匿且麻烦。任何时候只要你承担 “为 derived class 实现 copying 函数”,你必须确保复制 “derived class 对象内的所有成员变量” 以及 “所有 base class 成分”。那些 base class 成分往往是private
的,因此你需要让 derived class 的 copying 函数调用相应的 base class 函数
切记,不要尝试某个 copying 函数调用另一个 copying 函数,虽然这看上去是一个避免代码重复的方法。例如,令 copy assignment 操作符调用 copy 构造函数,这件事看起来十分荒谬,因为这就像是试图构造一个已经存在的对象;令 copy 构造函数调用 copy assignment 操作符,同样没有意义,因为这像是对一个尚未构造好的对象赋值。如果 copy 构造函数和 copy assignment 操作符有重复的代码,建立第三者函数init()
放置重复代码,由两个 copying 函数共同调用不失为一个好方法
3 资源管理
所谓资源就是,一旦使用了它,将来就得返还给系统,不然就会发生大问题。资源不仅仅只是内存,其它常见的还包括文件描述符(file descriptors)、互斥锁(mutex locks)、图形界面中的字型和笔刷、数据库连接、以及网络 sockets
条款13:以对象管理资源
“以对象管理资源” 的观念常被称为 RAII,即 “资源获取即初始化”。为什么提出这种观念呢?这是因为以面向过程的方式管理资源是存在很大缺陷的,它意味着调用者承担了过多的责任:
- 调用者是否会记得释放资源
- 调用者是否能合理地释放资源
RAII 的想法是将资源放进对象内,在构造函数中获取资源,在析构函数中释放资源,借助 C++ 的 “析构函数自动调用机制确保资源被释放。许多资源被动态分配于 heap 内而后被用于单一区块或函数内,当控制流离开了当前的区块或者函数时对象会被销毁,一旦对象被销毁会自动调用其析构函数进行资源的释放,如果释放资源动作导致抛出异常,事情将变得棘手,但无需过多担心(见条款 8)
常见的 RAII class 可见 C++11 的智能指针,如std::shared_ptr
条款14:在资源管理类中小心 copying 行为
大多数情况,我们所面对的是 heap-base 资源,掌握智能指针尽心管理已经够用了。但是并非所有处理的资源都是 heap-base 资源,因此你可能偶尔会需要自己建立资源管理类
普遍而常见的 RAII class copying 行为是:禁止 copying 操作(见条款 6),实施 “引用计数法”(reference count)
条款15:在资源管理类中提供对原始资源的访问
每一个 RAII class 应该提供一个 “取得其所管理的资源”的方法
对原始资源的访问可能经由显示转换(一般命名为get()
)或隐式转换。一般而言,显示转换比较安全,但隐式转换对客户比较方便
条款16:成对使用new
和delete
时要采用相同形式
如果你在new
表达式中使用[ ],就必须在相应的delete
表达式中也使用[ ]。如果你在new
表达式中不使用[ ],一定不要在相应的delete
表达式中使用[ ]
条款17:以独立语句将 newed 对象置入智能指针
1 | int priority(); |
即使我们在这里使用了 RAII class,但是上述函数调用依然会引起内存泄漏:
在调用ProcessWidget()
之前,编译器会执行三个动作,这里没有先后顺序:
- 调用
priority()
- 执行
new Widget
- 调用
std::shared_ptr
构造函数
C++ 编译器并没有对参数的执行次序有着明确的规定,这里唯一可以确定的是new Widget
一定执行于std::shared_ptr
构造函数被调用之前,因为new Widget
的结果还要作为实参传递给std::shared_ptr
构造函数。当编译器选择第二顺位执行priority()
,此时的执行次序为:
- 执行
new Widget
- 调用
priority()
- 调用
std::shared_ptr
构造函数
此时就会出现资源泄露的隐患:一旦调用priority()
抛出异常,那么执行new Widget
返回的指针将会遗失,导致它将不会被正确释放,因为它没有被置入std::shared_ptr
——防卫资源泄露的武器
避免这类问题的方法很简单:
1 | std::shared_ptr<Widget> pw(new Widget); // 在单独语句中以智能指针存储 newed 对象 |
4 设计与声明
条款22:将成员变量声明为private
声明成员变量为priavte
的理由主要有三:语法一致性、访问控制细微划分以及最重要的封装
语法一致性可见于条款 18,这里不做过多解释。至于访问控制的细微划分,如果你令成员变量为 public
,那么所有人都可以读写它,也就没有什么访问控制的说法了。但如果令成员变量为private
,以函数取得或设定其值,就可以实现 “不准访问”、”只读访问” 以及 “读写访问” 等访问控制级别
将成员变量隐藏在函数接口的背后(也就是封装),可以为 “所有可能的实现” 提供弹性。这是因为,从条款 23 中我们可知:成员变量的封装性与成员变量的内容改变时破坏的代码数量成反比,因此封装成员变量可以保留日后变更实现的权利
语法一致性和访问控制细微划分显然也适用于protected
,但是封装呢,protected
其实并不比public
更具封装性。假设我们有一个public
成员变量,取消它会导致所有使用它的客户码都会被破坏,这是一个不可估量的极大值。假设我们有一个protected
成员变量,取消它会导致所有使用它的 derived classes 都会被破坏,这往往也是一个不可估量的极大值
7 模板与泛型编程
泛型编程(generic programming)——写出的代码和其所处理的对象类型彼此独立
条款41:了解隐式接口和编译器多态
没看懂,暂且记下结论。。。
面向对象编程世界:对 classes 而言接口是显示的(explicit),以函数签名为中心。多态则是通过 virtual 函数发生于运行期
泛型编程世界:对 template 参数而言,接口是隐式的(implicit),基于有效表达式。多态则是通过 template 具现化和函数重载解析发生于编译期
条款42:了解typename
的双重意义
当我们声明 template 类型参数时, class
和typename
的意义完全相同。然而 C++ 并不是总是把class
和typename
视为等价,有时候一定得使用typename
任何时候当你想要在 template 中涉及一个嵌套从属类型名称时,就必须在紧邻它的前面放上关键词typename
。嵌套从属类型名称可能会导致解析困难,这是由于 C++ 有条解析规则:如果解析器在 template 中遭遇一个嵌套从属名称,它便假设这名称不是个类型,除非你告诉它它是一个类型。而我们通常是将嵌套从属类型名称视为类型使用的,如T::const_iterator iter;
,这样就会产生矛盾引发解析错误
从属名称:template 内出现的依赖于某个 template 参数的名称
嵌套从属名称:从属名称在 class 内成嵌套状
嵌套从属类型名称(nested dependent type name):嵌套从属名称涉及某类型
“typename
必须作为嵌套从属类型名称的前缀词” 这一条规则是有例外的,typename
不可以出现在 base classes list 内的嵌套从属类型名称之前,也不可以在 member initialization list 中作为 base class 修饰符
typename
在不同编译器上会有不同的实践,因此typename
和 “嵌套从属类型名称” 之间的互动会在移植性方面存在一些问题
v1.5.2