这是effecttive的第二部分,这章并不覆盖你需要知道的优良接口设计的每一件事,但这章所强调的就是你应该优先考虑最重要的事情.
18. 让接口容易正确使用,不易被误用
让不符合预期的代码通不过编译,能通过编译的代码一定能完成用户的预期任务.
1 | //这个接口并不是一个完美的接口 |
另一个限制用户错误的办法是:限制类型内什么事可做,什么事不可以做. 常规的限制是加上 const.
条款3指出:以cosnt 修饰 operator* 的返回类型,可以阻止客户因“用户自定义类型而犯错”
新 type 的行为应该与“与内置 types 一致”
1 | if(a*b = c) ... |
在条款中13中 createInvestment 工厂方法的返回值(Investment*)存储在一个智能指针中,从而将 delete 的责任推给智能指针. 但如果客户没有或者不知道使用智能指针怎么办? 更好的接口设计是先发制人,令工厂方法 返回一个智能指针.
1 | std::tr1::shared_ptr<Investment> createInvestment(); |
这使得用户不会犯”忘记释放资源”的错误.(记得指定对应的 删除器,不然还是会出现内存泄露的问题)
但仅仅将这样做还不能避免用户出现的错误。假设class 的设计者期许那些从”createInvestment取得 Investment* 指针的客户将该指针传递给一个名为 getRidOfInvestment 的函数,而不是直接在它身上使用 delete”.这可能导致”企图使用错误的资源析构机制”(也就是拿 delete 替代 getRidOfInvestment).
更好的做法是: 直接返回一个 将 getRidOfInvestment 绑定为删除器的 shared_ptr.
1 | shared_ptr<Investment> createInvestment() { |
条款26指出:应该尽可能延后变量定义式的出现时间:更好的做法是得到 raw pointer后,在声明 retVal,这样 第一参数就不再是 null,而是真正的raw pointer了.
19. 设计 class 犹如设计 type
C++ 就像其他的 OOP 语言一样,当你设计一个新的class,也就定义了一个新的 type。如何设计高效的 classes, 以下是你必须考虑的问题:
- 新 type 的对象是如何创建和销毁的? (默认/copy)构造函数,copy assignment 符,和析构函数。
- 对象的初始化和对象的赋值该有什么差别? 构造函数和copy assignment。
- 新 type 的对象,如果被 passed by type,意味着什么? copy 构造函数
- 什么是新 type 的”合法值”.
- 你的 新 type 需要配合某个继承关系吗?
你选择继承别的 classes,那么你就会受到那些 classes 设计的约束,特别是受到virtual 或 non-virtual 的影响.(条款34 和 条款36).
如果你允许别的 classes 继承你的classes,那会影响你所声明的函数 - 尤其是析构函数是否为 vritual.(条款7) - 新 type 需要什么样的转换? 条款15
如果你希望允许类型 T1 隐式转换成类型 T2, 你需要在你的类中写一个类型转换函数(operator T2) 或是 在 class T2 中写一个 non-explicit-one-argument(可被单一实参调用)的构造函数.
如果你只允许explicit 构造函数存在,那就得写出专门负责执行 转换的函数,而 不得为类型提供转换操作符 或 non-explicit-one-argument 构造函数. - 什么样的操作符和函数对此新 type 而言是合理的? 条款23、24、46
- 什么样的标准函数应该被驳回? 条款6
- 该如何取用 新 type 的成员?
public private protected,以及 friend ,甚至是嵌套类和函数 - 什么是新 type 的”未声明接口”
- 你的新 type 有多么一般化?
- 你真的需要一个新的 type 吗?
20.用 pass-by-reference-to-const 代替 pass-by-value
省缺的情况下,C++ 以 by value 方式(继承自C方式)传递对象至(或来自)函数。
1 | //考虑这个继承体系 |
用 by reference 方式传递参数也可以避免 slicing(对象切割)问题。当一个 derived class 对象以 by value方式传递并被视为一个 base class 对象,base class 的 copy 构造函数会被调用,而“造成此对象的行为想个 derived class 对象”的那些特性化性质全被切割掉了,仅仅留下一个 base class 对象。
1 | class Window { |
解决切割问题的办法,就以 by refrence-to-const 的放传递 w
1 | void printNameAndDisplay( const Window& w) { |
reference 往往以指针的方式实现出来,因此 pass by refenrence 通常意味着真正传递的是指针.
注意并不是,内置类型就该使用 pass by value,class/ struct 就该设计成 pass by reference。在STL中, RB-tree的迭代器被设计成 struct,在 erase 算法中,是通过 by value 的方式传递的.
1 | class rb_tree { |
我们可以合理假设“pass-by-value”并不昂贵的唯一对象就是: 内置类型和STL的迭代器和函数对象.
21.返回对象时,别返回其 reference
pass by reference 通常比 pass by value 意味着更高的效率.但着并不意味着应该在任何时刻都应该使用 pass by reference.
1 | class Rational { //有理数 |
这样的类实现方式,并不能能够实现以下的功能.
1 | Ratinal a(1, 2); // a = 1/2 |
我们必须自己创建一个 Rational 对象,并返回它。
函数创建对象的途径有两种,一种是在stack, 一种是在heap空间创建。
我们这样实现 Rational 类的opeartor* 函数.
1 | //返回的是reference,糟糕的写法 |
但这样做将调用构造函数,还记得我们为什么要用 pass by reference吗?更严重的是 result是一个 local 对象,当函数退出时,result 也被销毁了,它返回的reference 指向已经不存在的 Ratioanl.
思考对象的构造和析构成本的代价是一个好习惯.
于是为了避免出现 local 对象的情况,我们尝试在 heap 上申请空间.
1 | class Ratinal& operator*(const Ratinal& lhs, const Ratinal& rhs) { |
你还是得付出一个“构造函数调用”的代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。但着会带来另一个问题:谁该 delete 你申请出来的空间?
1 | Rational w , x, y, z; |
无论是 on-the-stack 还是 on-the-heap 做法,都是因为对operator* 返回的结果调用构造函数而受到惩罚.
1 | const Rational& operator* (const Ratioanl& lhs, const Ratinal& rhs) { |
说了这么多,一个“必须返回新对象”的函数的正确写法是:
1 | inline const Rational operator*(const Rational& lhs, const Rational& rhs) { |
当然,你得付出返回值的构造函数和析构成本,但这可以避免代码出现“错误”.
22. 将成员变量声明为 private
在 C++ 中我们通常可以可以看见 public 、private 、protected 三种访问控制符号。
如果我们有一个 public 成员变量,而我们最终取消了它。所有使用它的客户端都会被破坏。(public 完全没有封装性). 对于 protected 而言,所有的 derived classes 都会被破坏.
23. 以non-member、non-friend 替代 member 函数
1 | class webBrowser { |
下面有两种方案,添加一个 clearEverything() 函数
1 | //方案一、添加成员函数 |
那个方案更好呢? 面向对象的观点告诉我们数据以及操作数据的函数应该被绑定在一起,这意味着 member 函数是更好的选择。 但是与直观相反地,答案并不是这个。在很多方面 non-member 比member做法更好。
让我们从封装开始讨论,如果某些东西被封装,它就不再可见。愈多东西被封装,我们改变那些东西的能力也就愈大。现在考虑对象内的数据,愈少代码能够访问它,愈多的数据可被封装,我们也就愈能自由的改变对象数据。条款22 曾经解释了“成员变量应该是private”,这使得我们能够有更多的封装性。在 non-member 和member函数之间,导致较大封装性的是 non-member non-friend 函数,因为它并不增加“能够访问class 内之private成分” 的函数数量,因此 clearBrowser 比 clearEverything 更受欢迎: 它导致WebBrowser class 有较大的封装性.
这一点适用于 non-member 和 non-friend 函数
因在意封装性而让函数“成为class的non-member”并不意味着它“不可以是另一个类的member”(工具类),更自然的做法是让 non-member 函数位于 WebBrowser 所在的同一个 namespace 下。
24. 若所有参数皆需类型转换,请为此采用 non-member 函数
令 classes 支持隐式类型转换通常是个糟糕的主意。然而这条规则也会遇见例外情况。最常见的例外就是在建立数值类型时,假设你设计一个class用来表现有理数,允许整数“隐式转换”为有理数似乎颇为合理。
1 | class Rational { |
如果你向支持算数运算,例如加法、乘法等等,但你不确定是否该由 non-member 函数来实现他们。直觉告诉你,你应该保持面向对象精神.所以你可能很自然的就在 Rational 类中重写了 operator*等.
你可能会写出这样的代码:
1 | class Rational { |
但是你并不满足,你还打算让 Rational 类跟 int 相乘.
1 | result = oneHalf * 2; |
在第一行中,发生了隐式类型转换,更详细的说,编译器根据 int 类型变出了一个适当的 Rational,类似于:
1 | //Rational 的构造器并不是 explicit |
解决的方法是:提供non-member 函数
1 | class Rational { |
25. 考虑写一个不抛出异常的 swap 函数
常见交换两个数的写法如下( 用到了 template)
1 | tempalte <typename T> |
只要类型 T 支持 copying( copy 构造函数和 copy assignment 操作符完成),缺省的 swap 实现代码就会帮你置换类型为 T 的对象,你不需要做任何工作.
然而对于以下的代码,缺省版本的 swap却表现的并不好.
1 | class WidgetImpl { |
一旦我们要置换两个 Widget对象,缺省的 swap 函数,不只复制了三个 Widgets,还复制了三个 WidgetImpl对象,而实际上我们需要置换的仅仅是置换其 pImpl 指针.
1 | class Widget { |
然而,如果 Widget 和 WidgetImpl 都是class template 而非 classes。
1 | template <typename T> |
这时对应的swap应该这样写?
1 | namespace std { |
c++只允许对 class template 偏特化,在 function template 身上偏特化是行不通的
正确的写法是,简单的为它添加一个重载版本
1 | template <typename T> |
然而,上面的”正确写法”也通不过编译…,原因是 C++ 只允许全特化 std 内的 templates,但不能添加其他任何东西到 std 内.
1 | //真正正确的版本 |
总结如下:
如果缺省的 swap 函数效率很低,试着做以下事情:
- 提供一个public swap成员函数,高效的进行交换
- 在 class 或 template 所在的命名空间提供一个 non-member swap,令其调用上述的 swap 成员函数
- 如果你真正编写一个 class , 而不是 class template ,为你的 class 特化std::swap,并令其调用你的 swap 成员函数
- 最后,如果你调用 swap,请使用 using 声明式,然后不加任何 namespace 修饰符,赤裸裸地调用 swap
另外的一点是 成员 swap 决不能抛出异常. 那是因为 swap的一个最好的应用就是帮助 classes(template) 提供强烈的异常安全性保障. 见条款29