Effective C++ 重述

 
 
C++
 7.9k
 44

我动手写这篇博文,主要是出于三个原因:一是,我希望可以将《Effective C++》在读完后形成一本手册——可以短时间抓住核心进行使用;二是,可能是语言习惯的问题(译者是中国台湾的同胞侯捷先生),我在阅读本书时会有不少难以理解的地方,我尽量用自己的逻辑去梳理;三是,书中并未涉及 C++11,因此会在这里进行适当的填补。但是出于个人能力的有限,如果你想深入探究其中的细节,还请将原著反复研读😇

2024-09-26 11:57:21

目录:

2 构造/析构/赋值运算

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

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

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

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

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

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

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

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

3 资源管理

条款13:以对象管理资源

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

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

条款16:成对使用newdelete时要采用相同形式

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

4 设计与声明

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

7 模板与泛型编程

条款41:了解隐式接口和编译器多态

条款42:了解typename的双重意义


2 构造/析构/赋值运算

它们控制着类的基本操作,像是产出新对象并确保它被初始化、摆脱旧对象并确保它被适当清理、以及赋予对象新值。如果这些函数犯错,会导致深远且令人不愉快的后果,遍及你的整个 class。因此将这些函数正确良好地集结在一起形成 class 的脊柱,是 “生死攸关” 的大事

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

当你声明了一个 empty class(空类)时,如果你自己没有声明下述的函数,编译器会为你声明:

  • 一个 default 构造函数
  • 一个 non-virtual 析构函数
  • 一个 copy 构造函数
  • 一个 copy assignment(拷贝赋值)函数

所有这些函数都是publicinline

默认生成的 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
2
3
4
5
6
7
8
9
10
11
12
13
class Transaction {
public:
Transaction() { init(); }
virtual void LogTransaction() const { std::cout << "base class" << std::endl; }

private:
void init() { LogTransaction(); }
};

class BuyTransaction : public Transaction {
public:
virtual void LogTransaction() const { std::cout << "derived class" << std::endl; }
};

现在执行以下这行代码,会发生什么:

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
2
3
4
Entity& operator=(const Entity& rhs) {
...
return *this;
}

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

一般而言,如果某段代码操作 pointers 或 references 而它们被用来 “指向多个相同类型的对象”,就需要考虑这些对象是否为同一个:

1
2
a[i] = a[j];  // 如果 i == j,自我赋值
*px = *py; // 如果 px 和 py 指向同一个东西,自我赋值

实际上,两个对象只要来自同一个继承体系,它们甚至不需要声明为相同类型就可能造成 “别名”,即 “有一个以上的方法指向同一对象”:

1
2
3
class Base { ... };
class Derived : public Base { ... };
void DoSomething(const Base &rb, Derived *pd); // rb 和 *pd 有可能其实是同一对象

“自我赋值” 存在 “在停止使用资源之前意外释放了它” 的陷阱:

1
2
3
4
5
6
7
8
9
10
11
class Bitmap { ... };
class Widget {
...
Bitmap *pb;
};

Widget& operator=(const Widget &rhs) {
delete pb; // 这里 rhs 和 *this 有可能是同一对象,那么会发生一个错误:持有一个指向已被删除对象的指针!
pb = new Bitmap(*rhs.pb);
return *this;
}

传统的解决方法,增加一个 “证同测试(identity test)” 来达到 “自我赋值” 的检验目的:

1
2
3
4
5
6
7
8
Widget& operator=(const Widget &rhs) {
// 证同测试
if (this == &rhs) return *this; // 这种方法存在异常方面的麻烦,不具备 “异常安全性”

delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

通常让operator=具备 “异常安全性” 往往自动获得 “自我赋值安全性” 的回报,而许多时候精心排列的语句就可以导出异常安全(见条款 29):

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

手工排列语句有一个替代方案,就是使用所谓的 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:成对使用newdelete时要采用相同形式

如果你在new表达式中使用[ ],就必须在相应的delete表达式中也使用[ ]。如果你在new表达式中不使用[ ],一定不要在相应的delete表达式中使用[ ]

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

1
2
3
4
int priority();
void ProcessWidget(std::shared_ptr<Widget> pw, int priority);

ProcessWidget(std::shared_ptr<Widget>(new Widget), priority());

即使我们在这里使用了 RAII class,但是上述函数调用依然会引起内存泄漏:

在调用ProcessWidget()之前,编译器会执行三个动作,这里没有先后顺序:

  • 调用priority()
  • 执行new Widget
  • 调用std::shared_ptr构造函数

C++ 编译器并没有对参数的执行次序有着明确的规定,这里唯一可以确定的是new Widget一定执行于std::shared_ptr构造函数被调用之前,因为new Widget的结果还要作为实参传递给std::shared_ptr构造函数。当编译器选择第二顺位执行priority(),此时的执行次序为:

  1. 执行new Widget
  2. 调用priority()
  3. 调用std::shared_ptr构造函数

此时就会出现资源泄露的隐患:一旦调用priority()抛出异常,那么执行new Widget返回的指针将会遗失,导致它将不会被正确释放,因为它没有被置入std::shared_ptr——防卫资源泄露的武器

避免这类问题的方法很简单:

1
2
3
std::shared_ptr<Widget> pw(new Widget);  // 在单独语句中以智能指针存储 newed 对象

ProcessWidget(pw, priority());

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 类型参数时, classtypename的意义完全相同。然而 C++ 并不是总是把classtypename视为等价,有时候一定得使用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和 “嵌套从属类型名称” 之间的互动会在移植性方面存在一些问题

Comments
Powered By Valine
v1.5.2