Effective C++条款25:考虑写出一个不抛异常的swap函数(Consider support for a non-throwing swap)
《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:
条款25:考虑写出一个不抛异常的swap函数
1、标准模板库中的swap函数
标准模板库中的swap函数,其是STL中的一部分,后来成为异常安全性编程(见条款29)的脊柱,以及用来处理自我赋值可能性(见条款11)的一个常见机制,Swap非常有用,恰当的实现swap是非常重要的,与重要性伴随而来的是一些并发症。在这个条款中,我们将探索这些并发症以及如何处理它们。
Swap函数就是将两个对象的值进行交换,可以通过使用标准的swap算法来实现:
//STL中swap函数源码
namespace std {
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
只要类型T支持拷贝(拷贝构造函数或者拷贝赋值运算符)缺省的swap函数就可能帮你对两个类型为T的对象进行交换。
2、swap函数默认形式的缺陷——效率低
默认的swap实现包括三次拷贝:a 拷贝到temp,b拷贝到a, temp拷贝到b。对于一些类型来说,这些拷贝不是必须的,默认的swap将你从快车道拉到了慢车道。
这些不需要拷贝的类型内部通常包含了指针,指针指向包含真实数据的其他类型。使用这种设计方法的一个普通的例子就是“pimpl idiom”(指向实现的指针 条款31).举个例子:
class WidgetImpl {
// 针对Widget数据而设计的class;
public:
...
private:
int a, b, c; // 可能有很多数据
std::vector<double> v; // 意味着复制时间长
...
};
class Widget {
// 这个class使用pimpl手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) //拷贝赋值运算符
{
...
*pImpl = *(rhs.pImpl); // operator= in general,
...
}
...
private:
WidgetImpl *pImpl;//此处是指针
};
为了交换两个Widget对象的值,我们实际上唯一需要做的是交换两个pImpl指针,但是默认的swap算法没有办法能够获知这些。它不仅拷贝了三个Widget对象,还拷贝了三个WidgetImpl对象。非常没有效率。
3、怎样实现一个高效的swap
① 使用全特化的std::swap()函数
我们需要做的就是告诉std::swap当Widget对象被swap的时候,执行swap的方式是swap内部的pImpl指针。也就是为Widget定制一个std::swap。这是最基本的想法,看下面的代码,但是不能通过编译。
//此swap用于表示这个函数针对“T是Widget”而设计的
namespace std {
//template<>用于表示这是一个std::Swap的全特化版本
template<>
void swap<Widget>(Widget& a, Widget& b)
{
//错误的,pImpl是private的,无法编译通过
swap(a.pImpl, b.pImpl);
}
}
开始的”templpate<>”说明这是对std::swap的模板全特化(total template specializaiton),名字后面的””是说明这个特化只针对T为Widget类型。也就是说,当泛化的swap模板被应用到Widget类型时,应该使用上面的实现方法。一般来说,我们不允许修改std命名空间的内容,但是却允许使用我们自己创建的类型对标准模板进行全特化。
但是这个函数不能编译通过。这是因为它尝试访问a和b中的pImpl指针,它们是private的。我们可以将我们的特化函数声明成friend,但和以往的规矩不太一样:我们令Widget声明一个名为swap的public成员函数做真正的交换工作,然后将std::swap特化,令它调用该成员函数:
class Widget
{
public:
void swap(Widget& rhs)
{
using std::swap; //为什么要有这个声明,见下
swap(pImpl, rhs.pImpl); //调用std::swap()函数,只交换两个对象的指针
}
private:
WidgetImpl* pImpl;
};
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); //调用Widget::swap()成员函数
}
}
这种做法不仅编译能通过,还和STL容器有一致性,它们都同时为swap提供了public成员函数版本和调用成员函数的std::swap版本。
② 为模板类定义偏特化版本swap
接上面,假设Widget和WidgetImpl换成了类模版,我们就将存储在WidgetImpl中的数据类型替换成一个模板参数:
//此时这两个类都变成模板类
template<typename T>
class WidgetImpl {
... };
template<typename T>
class Widget {
... };
在Widget中实现一个swap成员函数和原来一样简单,但是std::swap的特化遇到了麻烦。我们想写成下面这样:
template<typename T>
class WidgetImpl{
... };
template<typename T>
class Widget{
... };
namespace std {
//此处是错误的,C++只允许对类模板偏特化,不允许对函数模板偏特化
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
上面的代码看上去完全合理,但却是不合法的。我们尝试偏特化(partially specialize)一个模板(std::swap),虽然允许对类模版进行偏特化,却不允许对函数模板进行偏特化。因此这段代码不能通过编译(虽然有些编译器错误的通过了编译)。
当你想“偏特化”一个函数模板的时候,常见的方法是添加一个重载函数。像下面这样:
template<typename T>
class WidgetImpl{
... };
template<typename T>
class Widget{
... };
namespace std{
//这里std::swap的一个重载版本,而不是特化版本
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
一般来说,对函数模板进行重载是可以的,但是 std 是一个特殊的命名空间,使用它的规则也很特殊。在 std 中进行全特化是可以的,但是添加新的模板(类,函数或其他任何东西)不可以。
③ 在自己的命名空间中定义swap()函数
那该怎么做呢?我们仍然需要一种方式来让其他人调用我们的高效的模板特化版本的swap。答案很简单。我们仍然声明一个调用成员函数swap的非成员函数swap(有点拗口,其实就是通过一个非成员函数swap去调用一个成员函数swap),但我们不将非成员函数声明为std::swap的特化或者重载。举个例子,和Widget相关的功能被定义在命名空间WidgetStuff中,像下面这样:
//假设这是自己设计的命名空间
namespace WidgetStuff{
template<typename T>
class WidgetImpl{
... };
template<typename T>
class Widget{
... };
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
注意事项:
① 我们在自己的命名空间中定义的swap()函数不属于std::swap()的重载版本,因为它们作用域不一致
② 此处我们以命名空间为例,其实不使用命名空间也可以,我们主要是为了指出不要与std::swap()产生冲突。但是为了不让全局数据空间太过杂乱,我们建议使用命名空间。
③ 不要在std中进行全特化。因此此处的方法不仅适用于模板类,同样也适用于非模板类。
现在,如果在任何地方调用swap,C++ 中的名字搜寻策略(name lookup rules)将会在WidgetStuff中搜寻Widget的指定版本。这正是我们需要的。
这种方法对类和模板类同样有效,所以看上去我们应该在任何情况下都使用它。不幸的是,你还需要为类提供特化的std::swap(稍后解释)版本,所以如果你想在尽可能多的上下文环境中调用swap的类特定版本,你需要同时在类命名空间中定义swap的非成员函数版本和std::swap的特化版本。
4、调用swap时的查找法则
至今为止我已经实现的都和swap编写者有关,从客户角度来看有一种情况也有必要。假设你正在实现一个函数模板,函数中需要对两个对象的值进行swap:
template<typename T>
void doSomething(T& obj1,T& obj2)
{
//...
swap(obj1,obj2); //调用哪一个版本的swap()函数哪?
//...
}
它会调用swap的哪个版本?已存的std中的版本?可能存在也可能不存在的std中的特化版本?还是可能存在也可能不存在的,可能在一个命名空间内也可能不在一个命名空间内(肯定不应该在std中)T特定版本?你所需要的是如果有的话就调用一个T特定版本,没有的话就调用std中的普通版本。下面来实现你的需求:
template<typename T>
void doSomething(T& obj1,T& obj2)
{
//使std::swap在次函数内可用
//如果没有针对于T的特化swap版本,那么就调用std::swap
using std::swap;
//...
swap(obj1,obj2);
//...
}
一旦编译器看到了对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特化过了,特化版本将会调用。
5、调用swap时不要加std限定符
因此,调用正确的swap版本很容易。一件你需要注意的事情是不要对调用进行限定,因为这会影响c++决定调用哪个函数。举个例子,如果你像下面这样调用swap:
std::swap(obj1, obj2); // the wrong way to call swap
这便是强制编译器只考虑std中的swap版本(包含所有模板特化版本),这样就调不到在其他方定义的更加合适的T特定版本了(如果有的话)。一些被误导的程序员确实就对swap的调用进行了这种限定,因此为你的类对std::swap进行全特化很重要:它使得被误导的程序员即使使用错误的调用方式(加std限定)也能够调用特定类型的swap版本。
6、实现swap步骤小结
此刻,我们已经讨论了默认的swap函数,成员swap函数,非成员swap函数以及std::swap的特化版本,并且讨论了对swap的调用,让我们总结一下:
① 首先,如果为你的类或者类模版提供的swap默认实现版本在效率上可以满足你,你就什么都不需要做。任何人尝试对你定义类型的对象进行swap,只要调用默认版本就可以了,这会工作的很好。
② 其次,如果swap的默认实现在效率上达不到你的要求(通常就意味着你的类或者类模板在使用同指向实现的指针(pimpl idiom)类似的变量),那么按照下面的去做:
- 提供一个 public 的swap成员函数,对你的类型的两个对象值可以高效的swap。原因一会解释,这个函数永远不应该抛出异常。
- 在与你的类或模板相同的命名空间中提供一个非成员swap。让它调用你的swap成员函数版本。
- 如果你正在实现一个类(不是一个类模版),为你的类特化std::swap。让他也调用你的swap成员函数版本。
- 最后,如果你调用swap,确保在你的函数中include一个using声明来使得std::swap是可见的,然后调用swap时不要加std命名空间对其进行限定。
7、最后的劝告——不要让成员函数swap抛出异常
绝不能让成员函数版swap抛出异常。因为swap的一个最有用的地方就是帮助类(或类模版)提供强有力的异常安全保证。条款29中有详细解释,其中的技术也是建立在swap成员函数版本不会抛出异常的假设之上的。这个约束只针对成员函数版本!而不针对非成员函数版本,因为swap的默认版本是基于拷贝构造函数和拷贝赋值运算符的,而一般情况下,这两个函数都允许抛出异常。当你实现一个swap的个性化版本,你就不单单提供了对值进行swap的高效方法;你同时提供了一个不会抛出异常的函数。作为通用规则,swap的这两个特性总是会在一起的,因为高效的swap通常是建立在对内置类型进行操作的基础之上的(像底层的指向实现的指针),而内置类型永远不会抛出异常。
8、牢记
-
当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
-
如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于class(而非template class),也请特化std::swap。
-
调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
-
用“用户定义类型”进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
总结
期待大家和我交流,留言或者私信,一起学习,一起进步!