Effective C++ 条款 18 - 25【设计与声明】
条款 18:让接口容易被正确使用,不易被误用
1. 如何让接口容易被正确使用?
接口一致性
原则,即不要让用户需要努力记住他们应该做什么,例如 STL 中所有容器类型取大小的接口都是 size(),而没有 lenth(),count() 这些乱七八糟的。自定义的接口要尽量与内置类型保持一致
,例如 operator= 重载应该返回引用类型,从而可以实现连续赋值……
2. 如何让接口不易被误用?
四种方式:建立新类型
、限制类型上的操作
、束缚对象值
、消除用户的资源管理责任
。以下分别举例介绍。
- 建立新类型:
// 不好的做法:
class Date {
public:
Date(int month, int day, int year);
}
--------------------------------------------------------------------------------------------------------
// 好的做法:
class Year {
public:
explicit Year(int y) : val(y) { ... }
...
private:
int val;
}
class Month {
// 与 Year 类似
}
class Day {
// 与 Year 类似
}
// 定义上面的 Year,Month,Day 三个新类型,让它们成为成熟的类,在 Date 中调用这三个类型的构造函数
class Date {
public:
Date(const Month&, const Day&, const Year&);
}
- 限制类型上的操作
// 例如之前介绍过的 operator+ 重载应该返回 const,从而阻止用户做出如下的误操作 :
if (a + b = c) // 实际上是想进行 == 判断
- 束缚对象值
// 例如对于上面的 Month 类,一年只有 12 个月份,所以可以使用一种十分安全的方法:预先定义所有有效的 Month
class Month {
public:
static Month Jan() { return Month(1); } // 注意这里使用了条款 4 中的方法:
static Month Feb() { return Month(2); } // 用局部静态对象保证对象在使用前一定进行了初始化。
...
private:
explicit Month(int m); // 将构造函数声明为 explicit private,阻止用户定义其它的 Month 对象
}
// 用户代码:
Date d(Month::Nov(), Day(5), Year(1995));
- 消除用户的资源管理责任
// 回忆之前的 “工厂函数 + 智能指针” 的用类管理资源的例子:
void f() {
std::shared_ptr<Investment> pInv(createInvestment());
}
// 1. 这要求 “用户必须记得将 createInvestment 的返回值传给 shared_ptr”;
// 2. 如果在资源使用完毕后不是要进行 delete ,而且要执行其它的析构方式,例如用 getRidOfInvestment() 作为删除器,
// 还要求用户必须记得这一点:“不要使用 delete ,要使用 getRidOfInvestment”。
// —— 因此,我们最好帮助用户解决这些问题!
--------------------------------------------------------------------------------------------------------
// 最好的方式是:在 createInvestment 函数中不再返回普通指针,
// 而是直接返回一个带有 getRidOfInvestment 删除器的 shared_ptr:
std::shared_ptr<Investment> createInvestment() {
std::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment); // 指定删除器
// 注意此处 static_cast 的使用(shared_ptr 的构造是 explicit 的,它坚持要一个真正的指针,因此要 cast)
retVal = ... ;
return retVal; // 返回指定删除器的智能指针
}
注:这里指出 shared_ptr 的另外一个好处 —> 没有 cross-DLL 问题。
cross-DLL 问题:在一个 DLL 中 new 的对象如果在另一个 DLL 中 delete 会发生运行时错误。
shared_ptr 没有这个问题,无论在哪个 DLL 中,当对象的引用计数变为 0 时,它都会追踪到定义该 shared_ptr 时所在的那个 DLL,找到当时定义的删除器,然后调用那个删除器。
条款 19:设计 class 犹如设计 type
- 新 type 的对象应该如何被创建和销毁?
—— 如何设计构造函数和析构函数、内存分配函数和释放函数(operator new/new[],operator delete/delete[])。 - 对象的初始化和对象的赋值该有什么样的差别?
—— 如何设计构造函数和赋值操作符。 - 新 type 的对象如果被 pass by value 意味着什么?
—— 如何设计拷贝构造函数。 - 什么是新 type 的 “合法值”?
—— 如何设计类需要维护的约束、各函数需要进行的错误检查、各函数抛出的异常。 - 你的新 type 需要配合某个继承图系吗?
—— 如何设计虚函数。 - 你的新 type 需要什么样的转换?
—— 是否允许类型 T1 被转换为 T2?如果允许,是只允许显式转换还是允许隐式转换(参照条款 15)。显式转换:写一个特定的函数例如 get(),隐式转换:非 explicit 的构造函数、类型转换函数(operator T2)。 - 什么样的操作符和函数对此新 type 而言是合理的?
—— 哪些应该是成员函数,哪些不是。 - 什么样的标准函数应该被驳回?
—— 哪些应该是 private 的(如不允许拷贝时的拷贝构造和拷贝赋值)。 - 谁该取用新 type 的成员?
—— 哪些该是 public、protected、private 或 friend。 **什么是新 type 的 “未声明接口”?(不明白啥意思,在条款 29,还没看到那儿)**
- 你的新 type 有多么一般化?
—— 是否应该将这个 type 定义成一个 type 族,即应该定义成 class 还是 class template。 - 你真的需要一个新 type 吗?
—— 是否你只是想定义一个现有类的派生类,这个类是不是在原有类中加几个非成员函数或模板就可以解决。
条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value
void f(Type t); // pass-by-value
void f(const Type& t); // pass-by-reference-to-const (不要忘记 const 哦)
1. 以 pass-by-reference-to-const 替换 pass-by-value 的好处:
效率高
:不用频繁地调用拷贝构造和析构(在拷贝某类型时,这个类型里的类类型成员又会调用该成员的拷贝构造函数,然后继续……总之,开销会比想象的还要大得多)。解决了切割问题
:即派生类实参和基类形参的传递问题,用值传递会失去派生类的部分,在调用虚函数时也不会绑定到派生类的虚函数(动态绑定只对指针和引用有效)。
2. 哪些情况下用 pass-by-value 反而更好?
内置类型
、STL 迭代器
、函数对象
。- 为什么这些情况下用 pass-by-value 反而更好?—— 因为 references 的底层是以指针实现的,对于这些情况来说,传递指针效率的效率反而没有直接拷贝值的效率高。
- 一个误解:由于内置类型往往比较小,因此很多人认为对于小型的类用 pass-by-value 更好,这是不对的。因为: 1) 对象小并不意味着拷贝构造不昂贵(对象小指的是成员少,但是即使只有一个成员,如果这个成员是类类型成员的话,有可能调用它的拷贝构造函数时会有很大的开销,比如一些指针的拷贝定义成了拷贝指针所指的所有实际内容);2) 一些编译器对待内置类型和类类型是不同的(比如内置的 double 可以放入缓存,但只有一个 double 成员的类类型对象却无法放入缓存,因此还是用 pass-by-reference 效率更高);3) 现在很小的类,以后有可能会变很大。
条款 21:必须返回对象时,别妄想返回其 reference
- 不要返回指向 local stack 对象的指针或引用 ——> 对象在返回后被销毁。
- 不要返回指向 local heap 对象的引用 ——> 将会失去对对象的控制权,不知道该由谁来 delete,从而内存泄漏。
- 不要返回指向 local static 对象的指针或引用 ——> 如果需要多个这样的对象,这些对象将永远相等。
以下是上面三点的错误示例:
// 错误示例 1 :不要返回指向 local stack 对象的指针或引用
const Rational& operator* (const Rational& lhs, const Rational& rhs) {
Rational result(lhs.val * rhs.val);
return result; // 错误!result 在函数退出时被销毁。
}
--------------------------------------------------------------------------------------------------------
// 错误示例 2:不要返回指向 local heap 对象的引用
const Rational& operator* (const Rational& lhs, const Rational& rhs) {
Rational* result = new Rational(lhs.val * rhs.val);
return *result; // 错误!
}
Rational w, x, y, z;
w = x * y * z; // 没办法取得背后隐藏的指针,内存泄漏。
--------------------------------------------------------------------------------------------------------
// 错误示例 3:不要返回指向 local static 对象的指针或引用(如果有可能需要多个该对象的话)
const Rational& operator* (const Rational& lhs, const Rational& rhs) {
static Rational result(lhs.val, * rhs.val);
return result; // 错误!
}
bool operator==(const Rational& lhs, const Rational& rhs);
Rational a, b, c, d;
if ((a * b) == (c * d)) { ... } // 将永远为真。
// 补充说明:这个式子等价于 if (operator==(operator*(a, b), operator*(c, d))) ,
// 在 operator== 调用前,两个 operator* 都已经返回了结果,
// 虽然它们各自改变了 static Rational 对象,但是在调用端看到的永远是 “现在的值”,因此永远相等。
- 正确的做法:就直接返回一个新的值呗。
const Rational opeator* (const Rational& lhs, const Rational& rhs) {
return Rational(lhs.val * rhs.val);
}
条款 22:将成员变量声明为 private
将成员变量声明为 private 意味着 —— 封装
而封装正是 C++ 面向对象的三大特性之首呀。
将成员变量声明为 private (即:封装)有什么好处?
- 用户访问数据将具有一致性:
—— 用户能够接触到成员变量的唯一方式就是使用接口函数,因此用户不需要费脑筋去思考要不要加上小括号的问题。 - 可细微划分访问控制:
—— 例如,对各个成员变量设置或不设置 set()、get() 函数,能够将这些成员变量区分为:可读可写、可读不可写、不可读不可写……等等。 - 允诺约束条件获得保证:
—— 可使得成员变量被读或写时轻松通知其它对象(咋通知?
)、可以验证类的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制……总之,可以确保类的约束条件总是会获得维护,因为只有成员函数可以影响到成员变量。 - 提供类的作者以充分的实现弹性:
——封装意味着可改变,不封装意味着不可改变。
—— 需求决定实现方式,而封装意味着对用户隐藏实现方式。因此当需求改变时,可以轻松地在类的内部改变类中成员的实现方式,而不需要改变用户代码,可以说封装就是为了更容易改变
。
// 书中的一个例子:
class SpeedDataCollection {
...
public:
void addValue(int speed); // 添加一笔新数据
double averageSoFar() const; // 返回平均速度
...
}
// averageSoFar() 的两种实现方式:
// 1. 在类中设置成员变量存放累积的所有速度、当前平均值等,每次 addValue 时都计算一下平均值,averageSoFar() 直接返回此值;
// 2. 不需要成员来时刻维持当前的平均速度,averageSoFar() 调用时再收集所有速度值,并计算这些值的平均数。
// 其中第 1 种实现方式适合经常需要平均值,不在乎内存的情况,第 2 种实现方式适合内存吃紧,并且并不需要经常计算平均值的情况。
// 重点是:由于使用 averageSoFar() 成员函数来访问平均值,也就是封装了它,得以在不同的场景下替换不同的实现方式。
public 和 protected 的封装能力是否有区别?
- 没有区别,这两者都是 不封装。
- 从封装的角度看,只有
封装(private)
和不封装(其它)
两种访问权限。 - public 使得实现改变时需要破坏用户代码,这个数量是很庞大的;protected 使得实现改变时需要破坏派生类的代码,这个数量也是很庞大的。这两者的庞大程度是无法比较的,因此并没有谁比谁的封装性更好一些,它们都没有封装性。
条款 23:宁以 non-member、non-friend 替换 member 函数
1. 用 non-member && non-friend 函数替换 member 函数
// 一个网页浏览器类
class WebBrowser {
public:
...
void clearCache(); // 清除下载元素高速缓存区
void clearHistory(); // 清除访问过的 URL 的历史记录
void removeCookies(); // 移除系统中的所有 cookie
...
}
- 许多用户会想要一整个儿执行这些函数,因此可以用一个函数把它们包含起来,那么问题来了:是应该用一个成员函数呢?还是应该用一个非成员且非友元的函数呢?
// 1. 用成员函数的方法:
class WebBrowser {
public:
...
void clearEverything();
...
}
------------------------------------------------
// 2. 用非成员且非友元函数的方法:
void clearBrowser(WebBrowser& wb) {
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
------------------------------------------------
// —— 哪种更好???
- 很多人会误以为第 1 种方式更好,因为根据面向对象守则,数据以及操作数据的函数应该被捆绑在一块,然而这种理解是有误的,面向对象守则的这一点应该被理解为:
数据应该尽可能地被封装。而封装是针对成员变量的,封装意味着应该让尽量少的代码可以访问到类的成员变量,从而能实现更容易的扩展。用成员函数的方式,增加了访问到成员变量的可能性,而非成员非友元的方式才是真正地“让尽量少的代码可以访问到类的成员”,因此导致更大的封装性
。 - 使用 non-member && non-friend 函数并不意味着这些函数不可以成为其它类的 member 函数,例如上例可以让 clearBrowser 成为另一个 utility class 的 static 成员,只要它不是 WebBrowser 的 member 函数,就不会破坏 WebBrowser 的封装。
2. 将所有便利函数放在多个头文件内但隶属同一个命名空间
- 对于上面的示例,一种更自然的做法是使用命名空间 namespace,即:
将类本身与为该类提供的那些 non-member && non-friend 的便利函数放在同一个命名空间下
:
namespace WebBrowserStuff {
class WebBrowser { ... }
void clearBrower(WebBrower&);
...
}
- 更一般地,如果类有很多不同种类的便利函数,例如对于浏览器而言,一些与书签有关、一些与打印有关、一些与 cookie 有关……,则可以
按类别将不同的便利函数组织在不同的头文件下,所有的便利函数及类本身都放在同一命名空间中
,例如:
// 头文件 webbrowser.h
namespace WebBrowserStuff {
class WebBrowser { ... }
... // 核心便利函数(几乎所有客户都需要的 non-member && non-friend 函数)
}
--------------------------------------------------------------------------------------
// 头文件 webbrowserbookmarks.h
namespace WebBrowserStuff {
... // 与书签相关的便利函数
}
--------------------------------------------------------------------------------------
// 头文件 webbrowsercookies.h
namespace WebBrowserStuff {
... // 与 cookie 相关的便利函数
}
--------------------------------------------------------------------------------------
// 在不同的用户代码中,根据需求的不同,包含不同的头文件 (都需要包含 webbrowser.h 头文件)
- 这种组织方式的好处是
便于扩展
,例如如果需要新增一些与影音有关的便利函数,只需要新建一个 webbrowservideo.h 头文件,然后在该头文件下将这些便利函数声明在 WebBrowserStuff 命名空间中即可。
条款 24:若所有参数皆需类型转换,请为此采用 non-member 函数
解释:
- 有一个原则是
尽量不要让类支持隐式类型转换
(例如之前条款 15 中的例子,隐式转换导致空悬),然而这个原则有一些例外:比如是在建立数值类型
,数值类型应该有类型转换,这是很自然的事情。一个例子:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1); // numerator 和 denominator 分别表示分子和分母
// 这个构造函数暗藏玄机:
// 1. 首先它是 非 explicit 的; 2. 另外它的 denominator 默认为 1。
// 这导致它可以隐式地将 int 型直接转换为 Rational 类型
...
int numerator() const; // 取分子
int denominator() const; // 取分母
private:
...
}
- 对于上述的 Rational 类,如果我们想要重载算术运算如加法、乘法,是应该设计成成员函数还是非成员函数呢?
——若运算中的所有参数皆需支持隐式类型转换,应该设计成非成员函数,因为隐含的 this 不会参与到类型转换中。
// 假如设计成成员函数:
const Rational operator*(const Rational&) const;
// 在用户调用时:
Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // 正确,oneHalf 调用了 operator*(const Rational&), 其中 2 隐式转换为了 Rational 型参数;
result = 2 * oneHalf; // 错误,不存在 2 的 operator*(const Rational&) 函数。
// —— 这种不对称的设计明显是错误的,设计成非成员函数就不会有这种问题了。
- 再考虑:是否应该设计成 friend 呢?
—— 很多程序员认为,对于与类相关的函数,如果不能设计成成员函数,至少应该设计成友元。这种思想是没有道理的。在这个例子中, 由于 public 中包含 “取分子”、“取分母” 两个函数,已经能够满足 operator* 函数,因此没有必要将它设计成友元。至于为什么,还是之前的道理:尽可能地减少能够接触到成员变量的函数数量,从而尽最大可能地增强封装性
。
条款 25:考虑写出一个不抛异常的 swap 函数
swap:原本只是 STL 的一部分,之后成为了一种用于处理自我赋值的常见机制
,之后还成为了异常安全性编程的脊柱
。
1. STL 中的缺省的 swap (即默认 swap)
- 缺省的 swap 的实现如下,就是一种最经典的 swap 实现方法。从这个实现中可以看出,
缺省的 swap 的实现依赖于类型的拷贝构造函数和拷贝赋值运算符
。
namespace std {
template<typename T>
void swap(T& a, T& b) {
T tmp(a);
a = b;
b = tmp;
}
}
- 缺省的 swap 存在的问题:在
pImpl 手法 (即:pointer to implementation,以指针指向一个对象,内含真正数据)
中,有可能 swap 需要的只是交换指针本身,然而由于拷贝构造和拷贝赋值的一些设计,在实际 swap 时会(无用地)交换指针所指的所有数据,使效率很低。例如:
class WidgetImpl {
public:
...
private:
int a, b, c;
std::vector<double> v; // 这里有好多数据,复制起来好慢好慢的
...
}
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
...
*pImpl = *(rhs.pImpl); // 在交换两个 Widget 对象时,其实要做的只是交换两个 pImpl 指针
// 然而由于这种拷贝赋值方式,导致无用地交换了两个 WidgetImpl 类型对象,效率很低
...
}
...
private:
WidgetImpl* pImpl;
}
2. 如何解决缺省 swap 在 pimpl 时存在的问题?
- 基本解决方案:定义自己的 swap。
- 具体解决方案:
首先需要说一下
全特化
和偏特化
:
- 全特化用于处理有特殊要求的类模板和函数模板,将所有的 T 都用一个实际的类型替代,在代码中表现为 template 后面的尖括号为空,而在类名后面或者函数名后面加上尖括号,尖括号里的内容为 <特化的类型>。
- 偏特化是将类模板中的一部分 T 用特化的类型来替代,另一部分 T 还是 T,在类名后面加上尖括号,尖括号里的内容为 <特化的类型,T>。
需要注意的是:类模板既可以全特化也可以偏特化,而函数模板只能全特化,因为它的偏特化应该用函数重载的方式来实现。
- 在 class 或 template 中实现一个 public 的 swap 成员函数,实现高效率的 swap(只交换指针);
—— 由于要交换的指针类型成员变量是 private,因此需要用 public 成员函数,在 2 3 步骤中调用此成员函数。(注:这种方式与 STL 有一致性,因为 STL 就是用的非成员 swap 调用成员 swap 的模式)。 - 在 class 或 template 所在的命名空间中提供一个非成员 swap,调用 1 中的成员 swap;
—— 为什么不直接在 std 中特化,而是要新加一个命名空间?因为当 swap 针对的不是一个 class 而是一个 template 时,std 命名空间不允许:此时的特化是 “函数模板的偏特化”(因为 template 中依然有 < typename T >,只不过这个 T 不是 swap 的类型 T,而是模板类(例子中的 Widget<T>)的类型 T)。函数模板只能全特化而不能偏特化,如果想要自定义 Widget<T> 的特化 swap,可以用函数重载的方式(将 void swap(T&, T&) 重载为 void swap(Widget<T>&, Widget<T>&)),但是 std 命名空间中不允许添加新东西,即,可以添加标准类模板或函数模板的特化版本,但是不能添加新的模板、新的类或新的函数(包括重载函数)。所以,我们不能直接在 std 中对 template 进行针对类模板的特化,要实现同样的目的,只需要声明一个新的非成员 swap,让它调用成员函数 swap 即可,但不将这个非成员 swap 声明为 std 命名空间中的特化版本,而是将它放在一个新的命名空间中。(注:其实也可以不放在新的命名空间中而是直接放在全局命名空间中,但是那样并不符合一些编程整洁性原则)。 - 如果这是一个 class 而不是一个 template,那么在 std 中为这个 class 特化 std::swap,让它调用 1 中的成员 swap;
—— 既然有了步骤 2,为什么还要再在 std 中特化一次?答案是:为了让 swap 的 T 专属版本在更多场景下适用。其实主要是因为,在调用 swap 时,有很多程序员会习惯性地加上 std:: 来修饰,这导致编译器跳过了 T 所在的命名空间而直接到 std 命名空间中找 swap 函数,因此在 std 命名空间中再对 T 进行一次特化,让即使在这种情况下,也能找到 T 专属的特化 std::swap 版本而不是使用那个低效率的缺省 std::swap。 - 在成员 swap 函数和调用 swap 的那个函数中加上
using std::swap
; 语句,并且在调用 swap 时不加作用域修饰符。
—— 在成员 swap 函数中加上 using std::swap; 很好理解,因为在成员 swap 中做真实的交换指针操作,就应该用 std::swap。在调用 swap 的那个函数中加上 using std::swap; 语句,则是为了将 std::swap 在当前作用域中曝光,在 swap(T&, T&) 时(T 可能是某个需要特殊方式 swap 的 class,也可能是某个需要特殊方式 swap 的 template),会1)
先在 T 所在的命名空间中找 T 专属的 swap 函数,如果找不到,再去找2)
std 命名空间中的对 T 特化的 std::swap,如果还是找不到,则会3)
使用缺省的默认 std::swap。在调用 swap 时不加作用域修饰符,就是为了让编译器执行上述的名称查找过程,如果加了命名空间作用域 std::,则会跳过步骤 1,不符合我们的预期。
- 最后用代码总结一下:
// 1. 在类中实现高效的 public swap 成员函数
class Widget {
public:
...
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl); // 实际交换的是指针
}
...
private:
WidgetImpl* pImpl;
}
--------------------------------------------------------------------------------------------------
// 3. 在 std 中实现对 Widget 的 std::swap 函数特化版本
namespace std {
template<> // template<> 表明是全特化
void swap<Widget>(Widget& lhs, Widget& rhs) {
lhs.swap(rhs); // std 特化版本的 swap 调用成员函数
}
}
--------------------------------------------------------------------------------------------------
// 4. 在实际调用中
template<typename T>
void dosomething(T& obj1, T& obj2) {
using std::swap; // 让 std::swap 曝光
swap(obj1, obj2); // 光秃秃地调用 swap
}
- 针对第二步特别说明一下
// 例如 Widget 和 WidgetImpl 不再是 class 而是 template
template<typename T>
class WidgetImpl { ... };
template<typename T>
class Widget { ... };
--------------------------------------------------------------------------------------------------
// 如果想对 Widget<T> 进行 std::swap 特化,则会变成下面这个样子
template<typename T>
void swap<Widget<T> >(Widget<T>& lhs, Widget<T>& rhs) { // 错误!这是对函数模板的偏特化,函数模板不可以偏特化!
lhs.swap(rhs);
}
--------------------------------------------------------------------------------------------------
// 函数模板不可以偏特化,因为应该用重载的方式
template<typename T>
void swap(Widget<T>& lhs, Widget<T>& rhs) { // 此重载方式本身是没有问题的,然而 std 命名空间中不允许添加新函数!
lhs.swap(rhs);
}
--------------------------------------------------------------------------------------------------
// 正确做法:3. 在 T 的专属命名空间中添加 swap 非成员函数(不再需要特化的方式)
namespace WidgetStuff { // WidgetStuff : Widget<T> 自己的命名空间
...
template<typename T>
class Widget { ... };
...
template<typename T>
void swap(Widget<T>& lhs, Widget<T>& rhs) {
lhs.swap(rhs);
}
}
3. 自定义 swap 的异常安全性保障
- swap 的缺省版本依赖于拷贝构造函数和拷贝赋值运算符,这两个函数中几乎总是有异常抛出。而自定义版本的 swap 为了实现高效率,几乎总是对内置类型的操作(例如指针),在对内置类的的操作中绝不会抛出异常。
- 为了提供异常安全性,成员版本的 swap 绝不可以抛出异常(条款 29 中会讲到)。而如上所述,自定义版本的 swap 中绝不会抛出异常,恰好提供了此安全性保障。