读书笔记 effective c++ Item 25 实现一个不抛出异常的swap

时间:2024-01-11 10:05:50

1. swap如此重要

Swap是一个非常有趣的函数,最初作为STL的一部分来介绍,它已然变成了异常安全编程的中流砥柱(Item 29),也是在拷贝中应对自我赋值的一种普通机制Item 11)。Swap非常有用,恰当的实现swap是非常重要的,与重要性伴随而来的是一些并发症。在这个条款中,我们将探索这些并发症以及如何处理它们。

2. swap的傻瓜实现方式及缺陷

2.1 swap函数的默认实现

Swap函数就是将两个对象的值进行交换,可以通过使用标准的swap算法来实现:

 namespace std {

 template<typename T> // typical implementation of std::swap;

 void swap(T& a, T& b) // swaps a’s and b’s values

 {

 T temp(a);

 a = b;

 b = temp;

 }

 }

只要你的类型支持拷贝(拷贝构造函数和拷贝赋值运算符),默认的swap实现不需要你做一些特别的工作来支持它。

2.2 swap函数默认实现的缺陷——有可能效率低

然而,默认的swap实现也许并没有让你激动,它包括三次拷贝:a 拷贝到temp,b拷贝到a, temp拷贝到b。对于一些类型来说,这些拷贝不是必须的,默认的swap将你从快车道拉到了慢车道。

这些不需要拷贝的类型内部通常包含了指针,指针指向包含真实数据的其他类型。使用这种设计方法的一个普通的例子就是“pimpl idiom”(指向实现的指针 Item 31).举个例子:

 class WidgetImpl { // class for Widget data;

 public: // details are unimportant

 ...

 private:

 int a, b, c; // possibly lots of data —

 std::vector<double> v; // expensive to copy!

 ...

 };

 class Widget { // class using the pimpl idiom

 public:

 Widget(const Widget& rhs);

 Widget& operator=(const Widget& rhs) // to copy a Widget, copy its

 { // WidgetImpl object. For

 ... // details on implementing

 *pImpl = *(rhs.pImpl); // operator= in general,

 ... // see Items 10, Item 11, and Item 12.

 }

 ...

 private:

 WidgetImpl *pImpl; // ptr to object with this

 }; // Widget’s data

为了交换两个Widget对象的值,我们实际上唯一需要做的是交换两个pImpl指针,但是默认的swap算法没有办法能够获知这些。它不仅拷贝了三个Widget对象,还拷贝了三个WidgetImpl对象。非常没有效率,也不令人鸡冻。

3. 如何实现一个高效的swap

3.1 为普通类定义全特化版本swap

我们需要做的就是告诉std::swap当Widget对象被swap的时候,执行swap的方式是swap内部的pImpl指针。也就是为Widget定制一个std::swap。这是最基本的想法,看下面的代码,但是不能通过编译。。

 namespace std {

 template<> // this is a specialized version

 void swap<Widget>(Widget& a, // of std::swap for when T is

 Widget& b) // Widget

 {

 swap(a.pImpl, b.pImpl); // to swap Widgets, swap their

 } // pImpl pointers; this won’t

 compile

 }

开始的”templpate<>”说明这是对std::swap的模板全特化(total template specializaiton),名字后面的”<Widget>”是说明这个特化只针对T为Widget类型。换句话说,当泛化的swap模板被应用到Widget类型时,应该使用上面的实现方法。一般来说,我们不允许修改std命名空间的内容,但是却允许使用我们自己创建的类型对标准模板进行全特化

但是这个函数不能编译通过。这是因为它尝试访问a和b中的pImpl指针,它们是private的。我们可以将我们的特化函数声明成friend,但是传统做法却是这样:在Widget中声明一个真正执行swap的public成员函数swap,让std::swap调用成员函数:

 class Widget { // same as above, except for the

 public: // addition of the swap mem func

 ...

 void swap(Widget& other)

 {

 using std::swap; // the need for this declaration

 // is explained later in this Item

 swap(pImpl, other.pImpl); // to swap Widgets, swap their

 } // pImpl pointers

 ...

 };

 namespace std {

 template<> // revised specialization of

 void swap<Widget>(Widget& a, // std::swap

 Widget& b)

 {

 a.swap(b); // to swap Widgets, call their

 } // swap member function

 }

这种做法不仅编译能通过,同STL容器一致,它们都同时为swap提供了public成员函数版本和调用成员函数的std::swap版本。

3.2 为模板类定义偏特化版本swap

然而假设Widget和WidgetImpl换成了类模版,我们就将存储在WidgetImpl中的数据类型替换成一个模板参数:

 template<typename T>

 class WidgetImpl { ... };

 template<typename T>

 class Widget { ... };

在Widget中实现一个swap成员函数和原来一样简单,但是std::swap的特化遇到了麻烦。下面是我们想写出来的:

 namespace std {

 template<typename T>

 void swap<Widget<T> >(Widget<T>& a, // error! illegal code!

 Widget<T>& b)

 { a.swap(b); }

 }

上面的代码看上去完全合理,但却是不合法的。我们尝试偏特化(partially specialize)一个模板(std::swap),虽然允许对类模版进行偏特化,却不允许对函数模板进行偏特化。因此这段代码不能通过编译(虽然有些编译器错误的通过了编译)。

当你想“偏特化”一个函数模板的时候,常见的方法是添加一个重载函数。像下面这样:

 namespace std {

 template<typename T> // an overloading of std::swap

 void swap(Widget<T>& a, // (note the lack of “<...>” after

 Widget<T>& b) // “swap”), but see below for

 { a.swap(b); } // why this isn’t valid code

 }

一般来说,对函数模板进行重载是可以的,但是std是一个特殊的命名空间,使用它的规则也很特殊。std中进行全特化是可以的,但是添加新的模板(类,函数或其他任何东西)不可以。Std的内容完全由C++标准委员会来决定。越过这条线的程序肯定可以通过编译并且能运行,但是行为未定义。如果你想你的软件有可预测的行为,不要向std中添加新东西。

那该怎么做呢?我们仍然需要一种方式来让其他人调用我们的高效的模板特化版本的swap。答案很简单。我们仍然声明一个调用成员函数swap的非成员swap,但我们不将非成员函数声明为std::swap的特化或者重载。举个例子,和 Widget相关的功能被定义在命名空间WidgetStuff中,像下面这样:

 namespace WidgetStuff {

 ... // templatized WidgetImpl, etc.

 template<typename T> // as before, including the swap

 class Widget { ... }; // member function

 ...

 template<typename T> // non-member swap function;

 void swap(Widget<T>& a, // not part of the std namespace

 Widget<T>& b)

 {

 a.swap(b);

 }

 }

现在,如果在任何地方调用swap,C++ 中的名字搜寻策略(name lookup rules)将会在WidgetStuff中搜寻Widget的指定版本。这正是我们需要的。

4. 普通类中swap的特化版本和非成员函数版本都需要提供

这种方法对类同样有效,所以看上去我们应该在任何情况下都使用它。不幸的是,你还需要为类提供特化的std::swap(稍后解释)版本,所以如果你想在尽可能多的上下文环境中调用swap的类特定版本,你需要同时在类命名空间中定义swap的非成员函数版本和std::swap的特化版本。

5. 调用swap时的搜寻策略

至今为止我已经实现的都要从属于swap的作者,但从客户角度来看有一种情况值得注意。假设你正在实现一个函数模板,函数中需要对两个对象的值进行swap:

 template<typename T>

 void doSomething(T& obj1, T& obj2)

 {

 ...

 swap(obj1, obj2);

 ...

 }

他会调用swap的哪个版本?已存的std中的版本?可能存在也可能不存在的std中的特化版本?还是可能存在也可能不存在的,可能在一个命名空间内也可能不在一个命名空间内(肯定不应该在std中)T特定版本?你所需要的是如果有的话就调用一个T特定版本,没有的话就调用std中的普通版本。下面来实现你的需求:

 template<typename T>

 void doSomething(T& obj1, T& obj2)

 {

 using std::swap; // make std::swap available in this function

 ...

 swap(obj1, obj2); // call the best swap for objects of type T

 ...

 }

当编译器看到了对swap的调用,它们会寻找swap的正确版本。C++名字搜寻策略先在全局范围内或者同一个命名空间内搜寻swap的T特定版本。(例如,如果T是命名空间WidgetStuff中的Widget,编译器会用参数依赖搜寻(argument-dependent lookup)在WidgetStuff中寻找swap).如果没有T特定的swap版本存在,编译器会使用std中的swap版本,多亏了using std::swap使得std::swap在函数中可见。但是编译器更喜欢普通模板std::swap上的T指定特化版本,因此如果std::swap已经为T特化过了,特化版本将会调用。

6. 调用swap时不要加std限定符

因此调用正确的swap版本很容易。一件你需要注意的事情是不要对调用进行限定,因为这会影响c++决定调用哪个函数。举个例子,如果你像下面这样调用swap:

 std::swap(obj1, obj2); // the wrong way to call swap

你强制编译器只考虑std中的swap版本(包含所有模板特化版本),这样就调不到在其他地方定义的更加合适的T特定版本了(如果有的话)。一些被误导的程序员确实就对swap的调用进行了这种限定,因此为你的类对std::swap进行全特化很重要:它使得被误导的程序员即使使用错误的调用方式(加std限定)也能够调用特定类型的swap版本。

7. 实现swap步骤小结

到现在我们已经讨论了默认swap,成员函数swap,非成员函数swap以及std::swap的特化版本,并且讨论了对swap的调用,让我们总结一下:

首先,如果为你的类或者类模版提供的swap默认实现版本在效率上可以满足你,你就什么都不需要做。任何人尝试对你定义类型的对象进行swap,只要调用默认版本就可以了,这会工作的很好。

其次,如果swap的默认实现在效率上达不到你的要求(通常就意味着你的类或者类模板在使用同指向实现的指针(pimpl idiom)类似的变量),那么按照下面的去做:

  1. 提供一个public 的swap成员函数,对你的类型的两个对象值可以高效的swap。原因一会解释,这个函数永远不应该抛出异常。
  2. 在与你的类或模板相同的命名空间中提供一个非成员swap。让它调用你的swap成员函数版本。
  3. 如果你正在实现一个类(不是一个类模版),为你的类特化std::swap。让他也调用你的swap成员函数版本。

最后,如果你正在调用swap,确保在你的函数中include一个using声明来使得std::swap是可见的,然后调用swap时不要加std命名空间对其进行限定。

8. 最后的警告——不要让成员函数swap抛出异常

我最后的警告是永远不要让swap成员函数版本抛出异常。因为swap的一个最有用的地方就是帮助类(或类模版)提供强有力的异常安全保证。Item 29中有详细解释,其中的技术也是建立在swap成员函数版本不会抛出异常的假设之上的。这个约束只针对成员函数版本!而不针对非成员函数版本,因为swap的默认版本是基于拷贝构造函数和拷贝赋值运算符的,而一般情况下,这两个函数都允许抛出异常。当你实现一个swap的个性化版本,你就不单单提供了对值进行swap的高效方法;你同时提供了一个不会抛出异常的函数。作为通用规则,swap的这两个特性总是会在一起的,因为高效的swap通常是建立在对内建类型进行操作的基础之上的(像底层的指向实现的指针),而内建类型永远不会抛出异常。

9. 总结

  • 当使用std::swap对你的自定义类型进行swap时,如果效率不够高,那么提供一个成员函数版本,并确保这个函数不会抛出异常。
  • 如果你提供了一个成员函数swap,同时提供了一个非成员swap来调用成员swap。在类(不是模板)上对std::swap进行特化。
  • 当调用swap时,使用using std::swap声明,对调用的swap不使用命名空间限定。
  • 为用户定义类型全特化std模板没有问题,但永远不要尝试像std中添加全新的东西。