本章将介绍设计和声明 C++ 接口的一些原则。

Item18: Make interfaces easy to use correctly and hard to use incorrectly

核心思想

  • 好的接口应该引导正确使用,阻止错误使用。如果用户用错了,编译器应该报错,而不是运行时才发现 bug。

所以,我们在设计接口的时候,最好多问自己一个问题:

如果别人用我的接口,最可能的几种用错方式是什么?我能不能在编译期就拦住,从而让用户正确地使用接口?

具体来说,主要有下面四个点需要检查:

  1. 参数顺序容易搞混吗? -> 用类型区分
  2. 参数有合法范围吗? -> 用 enum 或者 enum class 限定范围
  3. 行为和内置类型一致吗? -> 该 constconst,该重载运算符就重载
  4. 涉及资源管理吗? -> 返回智能指针,不要返回裸指针

参数顺序容易搞混吗?

比如说,我有一个 Date 类,它的构造函数需要三个参数:month-day-year。如果我全都定义成 int 类型,那么在传错顺序时很难检查出来。

所以,我可以自定义新的三个类:MonthDayYear

1
2
3
4
5
6
7
8
// 危险:三个 int,顺序很容易传反
Date(int month, int day, int year);

// 改进:用类型区分
struct Month { explicit Month(int m); };
struct Day   { explicit Day(int d); };
struct Year  { explicit Year(int y); };
Date(const Month& m, const Day& d, const Year& y);

这样,就可以轻松区分下面三种情况,用户很难传错参数:

1
2
3
Date d(30, 3, 1995); 					// error! wrong types
Date d(Day(30), Month(3), Year(1995));	// error! wrong types
Date d(Month(3), Day(30), Year(1995));	// okay, types are correct

参数有合法范围吗?

如果参数有范围限制(比如上例中的日期、月份等参数),那么我们可以在上面自定义的类中,加入一些静态成员函数来返回对应的值,而不是自己直接传入。

比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Month {
public:
    static Month Jan() { return Month(1); }
    static Month Feb() { return Month(2); }
    static Month Mar() { return Month(3); }
    static Month Apr() { return Month(4); }
    static Month May() { return Month(5); }
    static Month Jun() { return Month(6); }
    static Month Jul() { return Month(7); }
    static Month Aug() { return Month(8); }
    static Month Sep() { return Month(9); }
    static Month Oct() { return Month(10); }
    static Month Nov() { return Month(11); }
    static Month Dec() { return Month(12); }
    // ...其他成员函数
private:
    explicit Month(int m);  // 阻止外部随意创建非法月份
    int value;
};

Date d(Month::Mar(), Day(30), Year(1995));

那么,为什么用静态成员函数,而不是而不是 enum 或者 static 对象来提供合法的枚举值呢?

第一,为什么不用 enum 呢?因为 C++03 的 enum 可以隐式转成 int,类型安全性不够:

1
2
enum Month { Jan, Feb, ... };
Month m = 42;  // 编译通过,但不合理

第二,为什么用函数而不是 static 对象呢?这个在 Item4 中提过:非局部静态对象在不同翻译单元中的初始化顺序是未定义的,而用静态成员函数返回局部对象则没有这个问题。

1
2
3
4
5
// 看起来更直觉的写法
static const Month Jan;  // 但这是非局部静态对象

// 书里推荐的写法
static Month Jan() { return Month(1); }  // 函数返回局部对象

但是,在 C++11 中,其实可以直接使用 enum class,这是更简洁且合规的选择:

1
2
enum class Month { Jan = 1, Feb, Mar, Apr, May, Jun,
                   Jul, Aug, Sep, Oct, Nov, Dec };

enum class 不会隐式转成 int,彻底解决了类型安全问题,也比书里的方案简洁得多。书里写 Month 类的方式在 C++11 之后不需要了。

行为和内置类型一致吗?

这段有点懒得自己写了,因为和前面一些内容重复(比如 const),而且比较抽象,让 AI 帮忙代劳一下。

简单来说,我们自定义的类型应该让用户像使用 intdouble 一样自然。

1. const 正确性

内置类型不会允许你对临时值赋值:

1
2
int a = 1, b = 2;
(a + b) = 3;  // 错误,编译器不让

如果你的类重载了 operator*,也应该阻止这种操作:

1
2
3
4
5
6
7
class Widget {
public:
    const Widget operator*(const Widget& rhs) const;  // 返回 const
};

Widget a, b, c;
(a * b) = c;  // 错误!因为返回的是 const

如果返回的不是 const(a * b) = c 居然能编译通过,用户会很困惑——这不像内置类型的行为。

2. 与 STL 容器保持一致的接口

1
2
3
4
5
6
7
8
9
class MyContainer {
public:
    using iterator = ...;
    iterator begin();
    iterator end();
    size_t size() const;
    bool empty() const;
    // ...
};

用户已经熟悉了 begin() / end() / size() / empty(),我们的容器也提供同样的接口,学习成本就是零。

3. 支持 copy/move 语义

内置类型天然支持拷贝和移动,我们的类也应该做到一致的行为(或者明确禁止,比如 unique_ptr= delete)。

4. 支持比较运算

内置 int 能比大小,我们的类如果概念上有大小关系,也应该重载 operator<operator== 等。这就是为什么 STL 算法要求元素支持 < ——因为这是内置类型的默认行为。

涉及资源管理吗?

这条也主要是 AI 写的,因为感觉自己没啥好写的。

涉及资源的接口,不要让调用方手动管理生命周期。返回智能指针,把资源管理的责任留在我们的模块内部。

返回智能指针而不是裸指针

这是最核心的一条。如果我们的接口涉及动态分配的资源,返回裸指针就是把隐患甩给调用方:

1
2
3
4
5
6
7
8
9
// 危险
Investment* createInvestment();
// 调用方可能忘记 delete → 泄漏
// 调用方可能 delete 两次 → double free
// 调用方在另一个 DLL 里 delete → cross-DLL 问题

// 安全
std::unique_ptr<Investment> createInvestment();
// 不可能忘记释放,不可能 double delete,不可能跨模块不匹配

自定义删除器

有时候资源的释放方式不是 delete。比如 C 库分配的内存要用 free,文件句柄要用 fclose,互斥锁要用 pthread_mutex_unlock

1
2
3
4
5
6
7
8
9
// C 风格分配的内存
std::unique_ptr<char, decltype(&free)> buffer(
    (char*)malloc(1024), free
);

// 文件句柄
std::unique_ptr<FILE, decltype(&fclose)> file(
    fopen("data.txt", "r"), fclose
);

智能指针记住了我们指定的删除方式,析构时用正确的方式释放,调用方不需要关心。

解决 cross-DLL 问题

在 A.dll 里 new 的对象,如果在 B.exe 里 delete,堆不匹配会崩溃。智能指针携带的删除器确保对象永远在创建它的模块里被销毁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// A.dll 里
std::shared_ptr<Widget> createWidget() {
    return std::shared_ptr<Widget>(
        new Widget(),
        [](Widget* p) { delete p; }  // 删除器在 A.dll 内定义
    );
}

// B.exe 里只需要用,不需要关心销毁
auto pw = createWidget();
// pw 析构时,自动调 A.dll 里的删除器,堆匹配

总结

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_ptr supports 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),我们应该像语言设计者设计 intdouble 那样认真对待自己的类。

书里列出了 12 个在设计一个 effective classes 时需要关注的问题,我们直接按类型分类如下。

对象生命周期

  1. 对象怎么创建和销毁?(构造函数、析构函数、operator new/delete
  2. 初始化和赋值有什么区别?(构造 vs operator=,详见 Item 4)
  3. 按值传递意味着什么?(拷贝构造函数)

类型约束 4. 合法的值有哪些?(不变量/invariants,决定错误检查逻辑) 5. 允许哪些类型转换?(隐式 vs 显式,explicit) 6. 哪些默认生成的标准函数应该被禁止?(= deleteprivate,详见 Item 6)

继承关系 7. 是否在继承体系中?(基类的 virtual 设计) 8. 是否允许被继承?(final、虚析构)

接口设计 9. 哪些运算符和函数有意义?(成员 vs 非成员,Item 23-24) 10. 谁能访问成员?(public / protected / privatefriend

性能与泛型 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 这样的传递值的参数形式。

这主要解决了两个问题:

  1. 性能问题:pass-by-value,需要调用对应的 copy constructor 来构造一个新的参数,同时在函数 return 时还需要调用 destructor 来析构,开销比较大。而使用 pass-by-reference-to-const 来传递参数,就完全没有这些开销。
  2. 类型隐患:在 pass-by-value 时,如果形式参数类型为基类,而实际传入的参数类型是派生类,那么调用 copy constructor 只会构造出基类的对象,而派生类独有的成员会被“切掉”,这就是所谓的 slicing problem。而使用 pass-by-reference-to-const 则不会有这样的问题,因为引用基本上就是指针。(所以引用具体是怎么实现的?和指针有什么区别和联系?

【补充】为什么说引用基本上就是指针?

  • 引用在底层实现上确实是一个被编译器隐藏了 dereference 操作的指针。编译的时候,引用和指针生成的机器码基本一样,区别只在于语言层面上的约束。具体可以看下面的表格。
指针引用
可以为空nullptr不能
可以重新绑定p = &b不行
语法*pp->直接 .
需要解引用需要显式 *编译器自动帮你做

那么什么时候更适合用 pass-by-value 呢?答案是:对于 built-in types 和 STL 中的 iterators 以及 function objects(什么是 function objects?

【补充】什么是 function objects?

  • 所谓 function objects,就是重载了 operator() 的对象,也叫 functor(仿函数)。
  • 看上去像函数一样的对象,可以和函数一样直接调用的对象,就是 function objects。
1
2
3
4
5
6
7
8
9
struct Greater {
	bool operator()(int a, int b) const {
		return a > b;
	}
}

// 定义一个Greater类型的对象
Greater comp;
comp(3, 5);		// 看起来像是函数调用,但实际上是 comp.operator()(3, 5)

作者最后还讨论了一个观点:

“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?作者认为不是如此,理由如下:

  1. 类对象本身的值小,不代表其管理的资源就小。如果它实际上管理很多资源,那么 deep copy 的开销是很大的。
  2. 一些 user-defined type,刚开始很小,但随着代码更新,可能变得很大。
  3. 就算这些类的对象确实很小,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 的相等判断时,就会出现“恒等”的问题。

具体可以看下面的例子:

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    static Rational result;
    result = Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}
1
2
3
4
5
6
7
Rational a, b, c, d;
if ((a * b) == (c * d)) { ... }
// 等价于
if (operator==(operator*(a, b), operator*(c, d)))
// 先算 a*b,result = 某个值
// 再算 c*d,result 被覆盖成另一个值
// 比较的是同一个对象和自己,永远 true!

看上去,如果老老实实直接返回 value 的话,那么在函数内部需要构造一次对象,然后 return 的时候还要调用 copy constructor 再构造一个新的对象作为返回值,并且析构函数内部的对象。

但其实 C++ 的 compiler 会自动帮我们完成优化,通过直接将对象构造在调用方的内存中,来消除 return value 时调用 constructor/destructor 的影响,完全不用我们自己来瞎操心。

所以,必须 return an object 的时候,就老老实实 return value,不要尝试 return reference。就像下面这样:

1
2
3
inline const Rational operator*(const Rational& lhs, const Rational& rhs) {
	return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

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 的情况下,函数调用是统一的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Student {
public:
    double getAverage() const { return average_; }  // 直接存储的值
    int getTotalScore() const { return s1_ + s2_ + s3_; }  // 计算得出的值
private:
    double average_;
    int s1_, s2_, s3_;
};

s.getAverage();  // 语法一样
s.getTotalScore(); // 语法一样

如果 average_public 的话,那么语法就不统一了:

1
2
s.average_;
s.getTotalScore();

第二个问题:无法精确控制每个成员的访问权限。

有时候,希望一些成员是 read-only 的,一些成员是 write-only 的,而一些成员是 read-write 的,但使用 public 的成员,只能让所有成员都是 read-write 的,而无法精确控制权限。

但使用 private 的成员就可以解决这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class AccessLevels {
public:
	int getReadOnly() const {return readOnly;}
	void setReadWrite(int value) {readWrite = value;}
	int getReadWrite() const {return readWrite;}
	void setWriteOnly(int value) {writeOnly = value;}
private:
	int noAccess;
	int readOnly;
	int readWrite;
	int writeOnly;
};

第三个问题(也是最重要的问题):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.
  • protected is no more encapsulated than public.

Item23: Prefer non-member non-friend functions to member functions

举一个例子:同样功能的 clearBrowserclearEverything,应该选择谁?

1
2
3
4
5
6
7
class WebBrowser {
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    // ...
};

现在你想提供一个"一键清除所有"的功能,两种选择:

方案一:成员函数

1
2
3
4
5
6
7
8
class WebBrowser {
public:
    void clearEverything() {
        clearCache();
        clearHistory();
        removeCookies();
    }
};

clearEverything 能访问 WebBrowser 的所有 private 成员。

方案二:非成员非友元函数

1
2
3
4
5
void clearBrowser(WebBrowser& wb) {
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

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

这里有两点需要补充:

  1. 从封装性的角度来看,和 member function 对立的概念是 non-member non-friend function,因为 friend function 也能访问当前 class 的 private member,实际上并没有提供封装性。
  2. 我们说的"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*。如果我们这样写:

1
2
3
4
class Rational {
public:
	const Rational operator*(const Rational& rhs) const;
};

那么,可以对比下面的式子:

1
2
3
4
5
Rational oneHalf(1, 2);
Rational result;

result = oneHalf * 2;	// fine
result = 2 * oneHalf;	// error!

为什么第一个式子没问题,而第二个式子编译报错?我们直接将乘法展开为最原始的函数调用,就会明白:

1
2
result = oneHalf.operator*(2);	// fine
result = 2.operator*(oneHalf);	// error

第一个式子没问题,因为 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 this pointer), the function must be non-member.

Item25: Consider support for a non-throwing swap

这一个 item 主要讲什么时候需要设计自己的 swap 函数、如何设计这个函数,以及如何调用这个 swap 函数。

核心就是三个层级,按场景选择:

第一层:你觉得不需要做

如果默认 swap(一次拷贝构造 + 两次赋值)对你的类来说效率足够,什么都不用做,直接用 std::swap

1
2
3
4
5
6
7
8
9
// 默认行为,够用就不用操心
namespace std {
    template<typename T>
    void swap(T& a, T& b) {
        T tmp(a);
        a = b;
        b = tmp;
    }
}

第二层:你需要自定义高效 swap(通常因为用了 pimpl)

做三件事:

1. 公开的成员 swap(不抛异常)
   ↓
2. 同 namespace 的非成员 swap(调成员 swap)
   ↓
3. 如果是普通类(不是模板),再特化 std::swap(也调成员 swap)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Widget {                    // 普通类
public:
    void swap(Widget& other) {
        std::swap(pImpl, other.pImpl);  // 只交换指针,不深拷贝
    }
};

namespace WidgetStuff {
    void swap(Widget& a, Widget& b) { a.swap(b); }  // ADL 能找到
}

namespace std {
    template<> void swap<Widget>(Widget& a, Widget& b) {
        a.swap(b);              // std::swap 也能用
    }
}

第三层:你该怎么调用 swap

1
2
3
4
5
template<typename T>
void doSomething(T& a, T& b) {
    using std::swap;   // 让 std::swap 可见
    swap(a, b);        // 不加 std::,让 ADL 优先找自定义版本
}

标准库的 swap 也大量使用这种写法(using std::swap; swap(...)),这是通用模式,可以记下来直接用。

核心限制:

  • 函数模板不允许偏特化,只允许全特化。所以模板类的自定义 swap 只能做到第 2 步(同 namespace 的非成员版本),无法做第 3 步(std::swap 全特化)。不过 ADL (argument-dependent-lookup) 几乎总能找到自定义版本,大多数场景够用了。

Things to Remember:

  • Provide a swap member function when std::swap would be inefficient for your type. Make sure your swap doesn’t throw exceptions.
  • If you offer a member swap, also offer a non-member swap that calls the member. For classes (not templates), specialize std::swap, too.
  • When calling swap, employ a using declaration for std::swap, then call swap without namespace qualification.
  • It’s fine to totally specialize std templates for user-defined types, but never try to add something completely new to std.