本章将介绍设计和声明 C++ 接口的一些原则。
Item18: Make interfaces easy to use correctly and hard to use incorrectly
核心思想:
- 好的接口应该引导正确使用,阻止错误使用。如果用户用错了,编译器应该报错,而不是运行时才发现 bug。
所以,我们在设计接口的时候,最好多问自己一个问题:
如果别人用我的接口,最可能的几种用错方式是什么?我能不能在编译期就拦住,从而让用户正确地使用接口?
具体来说,主要有下面四个点需要检查:
- 参数顺序容易搞混吗? -> 用类型区分
- 参数有合法范围吗? -> 用
enum或者enum class限定范围 - 行为和内置类型一致吗? -> 该
const就const,该重载运算符就重载 - 涉及资源管理吗? -> 返回智能指针,不要返回裸指针
参数顺序容易搞混吗?
比如说,我有一个 Date 类,它的构造函数需要三个参数:month-day-year。如果我全都定义成 int 类型,那么在传错顺序时很难检查出来。
所以,我可以自定义新的三个类:Month、Day 和 Year。
| |
这样,就可以轻松区分下面三种情况,用户很难传错参数:
| |
参数有合法范围吗?
如果参数有范围限制(比如上例中的日期、月份等参数),那么我们可以在上面自定义的类中,加入一些静态成员函数来返回对应的值,而不是自己直接传入。
比如:
| |
那么,为什么用静态成员函数,而不是而不是 enum 或者 static 对象来提供合法的枚举值呢?
第一,为什么不用 enum 呢?因为 C++03 的 enum 可以隐式转成 int,类型安全性不够:
| |
第二,为什么用函数而不是 static 对象呢?这个在 Item4 中提过:非局部静态对象在不同翻译单元中的初始化顺序是未定义的,而用静态成员函数返回局部对象则没有这个问题。
| |
但是,在 C++11 中,其实可以直接使用 enum class,这是更简洁且合规的选择:
| |
enum class 不会隐式转成 int,彻底解决了类型安全问题,也比书里的方案简洁得多。书里写 Month 类的方式在 C++11 之后不需要了。
行为和内置类型一致吗?
这段有点懒得自己写了,因为和前面一些内容重复(比如 const),而且比较抽象,让 AI 帮忙代劳一下。
简单来说,我们自定义的类型应该让用户像使用 int、double 一样自然。
1. const 正确性
内置类型不会允许你对临时值赋值:
| |
如果你的类重载了 operator*,也应该阻止这种操作:
| |
如果返回的不是 const,(a * b) = c 居然能编译通过,用户会很困惑——这不像内置类型的行为。
2. 与 STL 容器保持一致的接口
| |
用户已经熟悉了 begin() / end() / size() / empty(),我们的容器也提供同样的接口,学习成本就是零。
3. 支持 copy/move 语义
内置类型天然支持拷贝和移动,我们的类也应该做到一致的行为(或者明确禁止,比如 unique_ptr 用 = delete)。
4. 支持比较运算
内置 int 能比大小,我们的类如果概念上有大小关系,也应该重载 operator<、operator== 等。这就是为什么 STL 算法要求元素支持 < ——因为这是内置类型的默认行为。
涉及资源管理吗?
这条也主要是 AI 写的,因为感觉自己没啥好写的。
涉及资源的接口,不要让调用方手动管理生命周期。返回智能指针,把资源管理的责任留在我们的模块内部。
返回智能指针而不是裸指针
这是最核心的一条。如果我们的接口涉及动态分配的资源,返回裸指针就是把隐患甩给调用方:
| |
自定义删除器
有时候资源的释放方式不是 delete。比如 C 库分配的内存要用 free,文件句柄要用 fclose,互斥锁要用 pthread_mutex_unlock:
| |
智能指针记住了我们指定的删除方式,析构时用正确的方式释放,调用方不需要关心。
解决 cross-DLL 问题
在 A.dll 里 new 的对象,如果在 B.exe 里 delete,堆不匹配会崩溃。智能指针携带的删除器确保对象永远在创建它的模块里被销毁:
| |
总结
Things to Remember:
- Good interfaces are easy to use correctly and hard to use incorrectly. You should strive for these characteristics in all your interfaces.
- Ways to faciliate correct use include consistency in interfaces and behavioral compatibility with built-in types.
- Ways to prevent errors include creating new types, restricting operations on types, constracting object values, and eliminating client resource management responsibilities.
std::shared_ptrsupports custom deleters. This prevents the cross-DLL problem, can be used to automatically unlock mutexes (see Item 14), etc.
这一节主要是讲较为抽象的接口设计原则,理解为主。以及,每次设计接口前,问问自己上面提到的四个问题。
Item19: Treat class design as type design
这一条和 Item 18 一样,不是具体的语法规则,而是设计哲学。定义一个类 (class) 就是在定义一个类型 (type),我们应该像语言设计者设计 int、double 那样认真对待自己的类。
书里列出了 12 个在设计一个 effective classes 时需要关注的问题,我们直接按类型分类如下。
对象生命周期
- 对象怎么创建和销毁?(构造函数、析构函数、
operator new/delete) - 初始化和赋值有什么区别?(构造 vs
operator=,详见 Item 4) - 按值传递意味着什么?(拷贝构造函数)
类型约束
4. 合法的值有哪些?(不变量/invariants,决定错误检查逻辑)
5. 允许哪些类型转换?(隐式 vs 显式,explicit)
6. 哪些默认生成的标准函数应该被禁止?(= delete 或 private,详见 Item 6)
继承关系
7. 是否在继承体系中?(基类的 virtual 设计)
8. 是否允许被继承?(final、虚析构)
接口设计
9. 哪些运算符和函数有意义?(成员 vs 非成员,Item 23-24)
10. 谁能访问成员?(public / protected / private、friend)
性能与泛型 11. 性能、异常安全、资源使用的保证是什么?(“未声明的接口"对用户存在隐性的承诺,编译器不会检查这些承诺,但我们需要保证,Item 29,) 12. 这个类型够通用吗?(需要类模板吗?还是非成员函数就够了?)
最后一条,也是最重要的一条:我真的需要设计一个新的类吗?
- 当你不能修改已有的类,却希望为其增加一个新的功能时,如果能用非成员函数直接解决,那么就用非成员函数,而不要去继承这个类来创建一个新的类。
- 因为继承带来的耦合代价很大,往往比直接写一个非成员函数的代价更大。
Things to Remember:
- Class design is type design. Before defining a new type, be sure to consider all the issues discussed in this Item.
Item20: Prefer pass-by-reference-to-const to pass-by-value
核心观点就是标题:在大多数情况下,尽可能使用 const T& 这样的传递常数引用的参数形式,而不是 T 这样的传递值的参数形式。
这主要解决了两个问题:
- 性能问题:pass-by-value,需要调用对应的 copy constructor 来构造一个新的参数,同时在函数 return 时还需要调用 destructor 来析构,开销比较大。而使用 pass-by-reference-to-const 来传递参数,就完全没有这些开销。
- 类型隐患:在 pass-by-value 时,如果形式参数类型为基类,而实际传入的参数类型是派生类,那么调用 copy constructor 只会构造出基类的对象,而派生类独有的成员会被“切掉”,这就是所谓的 slicing problem。而使用 pass-by-reference-to-const 则不会有这样的问题,因为引用基本上就是指针。(所以引用具体是怎么实现的?和指针有什么区别和联系?)
【补充】为什么说引用基本上就是指针?
- 引用在底层实现上确实是一个被编译器隐藏了 dereference 操作的指针。编译的时候,引用和指针生成的机器码基本一样,区别只在于语言层面上的约束。具体可以看下面的表格。
| 指针 | 引用 | |
|---|---|---|
| 可以为空 | nullptr | 不能 |
| 可以重新绑定 | p = &b | 不行 |
| 语法 | *p、p-> | 直接 . |
| 需要解引用 | 需要显式 * | 编译器自动帮你做 |
那么什么时候更适合用 pass-by-value 呢?答案是:对于 built-in types 和 STL 中的 iterators 以及 function objects(什么是 function objects?)
【补充】什么是 function objects?
- 所谓 function objects,就是重载了
operator()的对象,也叫 functor(仿函数)。 - 看上去像函数一样的对象,可以和函数一样直接调用的对象,就是 function objects。
| |
作者最后还讨论了一个观点:
“Built-in types are small, so some people conclude that all small types are good candidates for pass-by-value, even if they’re user-defined.” (Dulimov, p. 89)
即:是否足够小的类型就可以使用 pass-by-value?作者认为不是如此,理由如下:
- 类对象本身的值小,不代表其管理的资源就小。如果它实际上管理很多资源,那么 deep copy 的开销是很大的。
- 一些 user-defined type,刚开始很小,但随着代码更新,可能变得很大。
- 就算这些类的对象确实很小,copy constructor 开销也很小,那么仍然会有性能问题,因为 compiler 可能区别对待同样大小的 built-in types 和 user-defined types。
Things to Remember:
- Prefer pass-by-reference-to-const over pass-by-value. It’s typically more efficient and it avoids the slicing problem.
- The rule doesn’t apply to built-in types and STL iterator and function object types. For them, pass-by-value is usually appropriate.
Item21: Don’t try to return a reference when you must return an object
有的同学在学习了前面“pass-by-reference”的相关内容之后,就想在各种地方都使用 reference 代替 value 来优化性能。但是,当函数需要返回一个对象的时候,千万不能使用 return reference 的方法,而只能老老实实使用 return value 的方法。
为什么?首先,我们要牢记一点:reference 一定是某个变量的别名。所以,看到 reference 的时候,我们第一时间就要去找:这个 reference 究竟是哪一个对象的别名?找到了原始对象,我们才能分辨这个 reference 的真正作用。
如果一个函数的 return 是某个对象的 reference,那么这个对象一定是在函数内部构造出来的。我们可以假设这样两种情况:
- 情况一:这个对象构造在栈上。
- 情况二:这个对象构造在堆上。
对于情况一,不用多说,因为构造在函数栈上的对象,在函数调用结束之后就自动析构销毁了,此时它对应的 reference 是无效引用,使用这种引用会造成未定义行为。所以,情况一肯定是不合法的。
对于情况二,函数先在堆上构造对象,然后返回这个对象的 reference。这下,确实没有对象自动析构的问题了,但新的问题是:谁来 delete 这个对象?在函数外部,我们只能使用该对象的引用,而无法获取其指针,因此无法 delete,从而造成内存泄露的问题。(那直接返回指针行吗?这个问题其实前面的 item 中讨论过:直接返回智能指针,而不要返回裸指针。)
那又有人要说了:
诶,那我用静态成员变量行不行?它既不会自动析构,也不会造成内存泄漏,感觉很合适。
使用静态成员变量的问题其实更大。
- 首先,如果有多个线程同时调用这个函数,修改同一个静态成员变量,那么必然存在线程安全的问题。
- 其次,函数的调用次数非常多,你不可能为每一个返回值都设置一个单独的静态成员变量,那样的开销更大了。
- 最后,如果你只设置了一个静态成员变量,那么所有的返回值事实上都是这个静态成员变量的引用。当你对这些值做 if 的相等判断时,就会出现“恒等”的问题。
具体可以看下面的例子:
| |
| |
看上去,如果老老实实直接返回 value 的话,那么在函数内部需要构造一次对象,然后 return 的时候还要调用 copy constructor 再构造一个新的对象作为返回值,并且析构函数内部的对象。
但其实 C++ 的 compiler 会自动帮我们完成优化,通过直接将对象构造在调用方的内存中,来消除 return value 时调用 constructor/destructor 的影响,完全不用我们自己来瞎操心。
所以,必须 return an object 的时候,就老老实实 return value,不要尝试 return reference。就像下面这样:
| |
Things to Remember:
- Never return a pointer or reference to a local stack object, a reference to a heap-allocated object, or a pointer or reference to a local static object if there is a chance that more than one such object will be needed. (Item 4 provides an example of a design where returning a reference to a local static is reasonable, at least in single-threaded environments.)
Item22: Declare data members private
这一条的核心就是:尽量把 class 的 data member 定义成 private 权限。
首先来看,不将 data member 定义成 private 会有什么问题。如果不定义为 private,那么无非就是定义成 public 或者 protected,分别来看这两类情况。
先看 public 成员的几个问题。
第一个问题:语法不统一。比如说,来看下面的例子。
在 average_ 为 private 的情况下,函数调用是统一的。
| |
如果 average_ 是 public 的话,那么语法就不统一了:
| |
第二个问题:无法精确控制每个成员的访问权限。
有时候,希望一些成员是 read-only 的,一些成员是 write-only 的,而一些成员是 read-write 的,但使用 public 的成员,只能让所有成员都是 read-write 的,而无法精确控制权限。
但使用 private 的成员就可以解决这个问题:
| |
第三个问题(也是最重要的问题):public 成员无法实现封装 (encapsulation)。
什么是封装?简单理解,就是将各个功能解耦,每个部分只做自己的事情,同时对外提供接口,在修改内部实现时,保持外部接口不变,使得调用这个功能的用户不用修改其代码。
public 成员显然是完全无法封装的。当我们修改这个 public 成员时,所有直接调用了这个 public 成员的代码都要被修改。这给代码的维护造成了极大的不便。
而 private 成员则很好地和封装兼容:用户只能调用开发者提供的特定接口,所以只需要保持对外接口不变,内部的成员可以任意修改。
此外,封装还能增加一些额外的好处,比如在函数内保持 class invariant 不变,或者增加一些日志功能。
那么 protected 成员呢?它似乎能规避前两个问题,但对于封装问题,它和 public 的状况是一样的:protected 成员会被派生类直接访问,它一样无法实现高效封装。
Things to Remember:
- Declare data members
private. It gives clients syntactically uniform access to data, affords fine-grained access control, allows invariants to be enforced, and offers class authors implementation flexibility. protectedis no more encapsulated thanpublic.
Item23: Prefer non-member non-friend functions to member functions
举一个例子:同样功能的 clearBrowser 和 clearEverything,应该选择谁?
| |
现在你想提供一个"一键清除所有"的功能,两种选择:
方案一:成员函数
| |
clearEverything 能访问 WebBrowser 的所有 private 成员。
方案二:非成员非友元函数
| |
clearBrowser 只能通过 public 接口访问 WebBrowser,看不到任何 private 数据。
下面我们就来解答这个问题。
面向对象的核心思想是“数据应该尽可能被封装”,我们实际要考虑的就是:上面两个函数,谁的封装性更好?
什么是 encapsulation(封装)?
- 封装,就是让一些部分不可见。不可见的部分越多,我们修改起来影响就越小:因为我们的修改只会影响那些“能看到我们修改的部分”,而封装正是在减少能看到我们修改的部分。
怎么判断有多少的代码能够“看”到某一个数据?
- 一个简单直接的方法是:看看有多少个函数能够访问这个数据。能访问对应数据的函数越多,这个数据的封装性就越差。
这里的访问,当然不是直接访问(因为在 Item22 中我们已经指出应当将 data member 声明为 private 类型,否则就毫无封装性),而是通过函数调用来访问。
那么谁能访问这些 private data member 呢?答案是:这个 class 自身的成员,或者 friend class 里面的成员。
所以,如果两个函数实现了完全相同的功能,其中一个是 member function,而另一个是 non-member non-friend funcion,那么应该选择后者——因为它能访问的部分更少,封装性更好。
【补充】通常这些函数被称为 convenience function(便利函数)
- 它不提供核心功能,而是基于核心功能封装出来的方便用户调用的快捷方式。
- 这些 convenience function 通常就被定义为 non-member non-friend function
这里有两点需要补充:
- 从封装性的角度来看,和 member function 对立的概念是 non-member non-friend function,因为 friend function 也能访问当前 class 的 private member,实际上并没有提供封装性。
- 我们说的"non-member non-friend function”,指的是当前 class 的 member 和 friend,对于其他 class 的成员不算在内,不会对封装性有损。
又有人会问了:既然我不使用 member function 来实现新功能,那么我怎么对我的新功能分区呢?答案是,可以利用 namespace 来分类。
【补充】什么是 namespace(命名空间)?
- 命名空间就是一个命名作用域,用来避免名字冲突。
- 命名空间同时还是一个逻辑分组,并且关键特性是可以跨越多个文件扩展。
注意 namespace 可以扩展到不同的文件中,因此可以在不同的头文件中定义不同的功能分区,但是它们同属于一个 namespace。
用户在使用时,只需要 #include 自己需要的头文件即可,而不需要引入整个命名空间。这样,既保证了解耦(减少编译依赖的开销),又保证了集成。
如果需要加入新功能,只需要创建新的头文件,设计好 convenience function 之后,再加入当前的 namespace即可。
Things to Remember:
- Prefer non-member non-friend functions to member functions. Doing so increases the encapsulation, packaging flexibility, and functional extensibility.
Item24: Declare non-member functions when type conversions should apply to all parameters
前面提过,通常我们不做参数的隐式转换(因为可能不太安全),但有些情况下也需要隐式转换,比如 numerical types 之间的转换。
如果我们需要一个函数(通常是实现加法、乘法这样的数值操作),并且其中的参数都需要支持隐式转换(多边隐式转换),那么这个函数应当是 non-member function,而不是 member function。
为什么?最常见的例子是运算符重载。比方说,我们需要实现 Rational 这个有理数类的乘法操作 operator*。如果我们这样写:
| |
那么,可以对比下面的式子:
| |
为什么第一个式子没问题,而第二个式子编译报错?我们直接将乘法展开为最原始的函数调用,就会明白:
| |
第一个式子没问题,因为 2 可以隐式转换为 Rational 形式;但第二个式子就不行了,因为整数类型的 2 没有对应的 operator* 函数,使得它能够输入一个 Rational 类型的值。
发现了吗?如果将 operator* 定义成 member function,那么乘法就无法实现交换律了——因为某个位置上的参数类型无法隐式转换!
所以,如果一个函数的所有参数都需要支持隐式转换,那么应当将对应的函数定义为 non-member function。
Things to Remember:
- If you need type conversions on all parameters to a function (including the one that would otherwise be pointed to by the
thispointer), the function must be non-member.
Item25: Consider support for a non-throwing swap
这一个 item 主要讲什么时候需要设计自己的 swap 函数、如何设计这个函数,以及如何调用这个 swap 函数。
核心就是三个层级,按场景选择:
第一层:你觉得不需要做
如果默认 swap(一次拷贝构造 + 两次赋值)对你的类来说效率足够,什么都不用做,直接用 std::swap。
| |
第二层:你需要自定义高效 swap(通常因为用了 pimpl)
做三件事:
1. 公开的成员 swap(不抛异常)
↓
2. 同 namespace 的非成员 swap(调成员 swap)
↓
3. 如果是普通类(不是模板),再特化 std::swap(也调成员 swap) | |
第三层:你该怎么调用 swap
| |
标准库的 swap 也大量使用这种写法(using std::swap; swap(...)),这是通用模式,可以记下来直接用。
核心限制:
- 函数模板不允许偏特化,只允许全特化。所以模板类的自定义 swap 只能做到第 2 步(同 namespace 的非成员版本),无法做第 3 步(
std::swap全特化)。不过 ADL (argument-dependent-lookup) 几乎总能找到自定义版本,大多数场景够用了。
Things to Remember:
- Provide a
swapmember function whenstd::swapwould be inefficient for your type. Make sure yourswapdoesn’t throw exceptions. - If you offer a member
swap, also offer a non-memberswapthat calls the member. For classes (not templates), specializestd::swap, too. - When calling swap, employ a using declaration for
std::swap, then callswapwithout namespace qualification. - It’s fine to totally specialize
stdtemplates for user-defined types, but never try to add something completely new tostd.