swap是一个非常有趣的程序。它起初是作为STL的一部分引入C++的,而后就成为了异常安全编程的一个重要的支柱(参见条目29),同时对于可以自赋值的对象而言它还是一个常用的复制处理机制。由于swap如此神通广大,那么以一个恰当的方式去实现它就显得十分重要了,但是它举足轻重的地位也决定了实现它并不是一件手到擒来的事情。在本条目中,我们就会针对swap函数展开探索,逐步掌握如何去驾驭它。
swap函数的功能是交换两个对象的值。在默认情况下,交换工作是通过标准库的swap算法完成的。它的标准实现方式就能精确地完成你所期望的工作:
namespace std {
template<typename T> // std::swap的标准实现
void swap(T& a, T& b) // 交换a与b的值
{
T temp(a);
a = b;
b = temp;
}
}
只要你的类型支持复制(通过拷贝构造函数和拷贝复制运算符),那么默认的swap实现就可以让两个该类型的对象互相交换,你不需要专门做任何工作来支持这一功能。
然而,你可能对默认的swap实现抱有诸多不满。它会带来3次对象复制工作:a复制到temp,b到a,temp到b。对于一些类型来说,这些复制操作并不都是必需的。对于这些类型来说,默认的swap会成为你程序的桎梏。
上述的那种类型大都符合下面的特征:它的主要成分是一个指针,这一指针会指向另一个类型,真实的数据包含在另一个类型中。对这一设计的一种常见的形式是“pimpl惯用法”(pointer to implementation,指向实现的指针,参见条目31)。举例说明,Widget类可以使用这种设计模式:
class WidgetImpl { // 保存Widget的数据的类
public: // 细节不重要
...
private:
int a, b, c; // 可能会有很多数据
std::vector<double> v; // 复制它们的代价是很高的!
...
};
class Widget { // 使用pimpl惯用法的类
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) // 要复制一个Widget对象,只要
{ // 复制对应的WidgetImpl对象。
... // 关于operator=实现的一般信息
*pImpl = *(rhs.pImpl); // 参见条目10、11、12
...
}
...
private:
WidgetImpl *pImpl; // 指向包含当前Widget数据的对象
};
为了交换两个Widget对象的值,我们所要做的仅仅是交换他们的pImpl指针,但是默认的swap算法是不可能知道这一切的,它不仅会复制三个Widget对象,同时也会复制三个Widget对象。这样做效率太低了。
我们要做的是告诉std::swap当交换Widget时,执行的交换操作应当仅仅针对它们内部的pImpl指针。有一种精确的说法来描述这一方法:将Widget的std::swap特化。下面是基本的思想,尽管以这种方式不能通过编译:
namespace std {
template<> // 在T为Widget时,
void swap<Widget>(Widget& a, // 这是std::swap的一个特化版本
Widget& b) // 这段代码不能通过编译
{
swap(a.pImpl, b.pImpl); // 要交换两个Widget,
} // 只需要交换它们的pImpl指针
}
程序开端的“template<>”告诉我们这是std::swap的一个完全特化模板,函数名后面的“<Widget>”告诉我们当前的特化针对T是Widget的情况。换种说法,当一般的swap模板应用于Widget时,应当使用这一具体实现。一般情况下,我们没有权限去改动std名字空间内部的内容,但是我们有权针对我们自己创建的类型(比如Widget)来完整地特化标准模板(就像swap)。这就是我们所要做的。
然而,就像我说过的,这段代码是不能通过编译的。这是因为它尝试访问a与b内部的pImpl指针,但是它们是私有的。我们可以将我们的特化函数声明为友元,但这里的规则有些不同:这里要求我们让Widget包含一个名为swap的公共成员函数,让这个swap进行实际的交换工作,然后特化std:swap来调用这一成员函数。
class Widget { // 同上,
public: // 仅添加了一个swap成员函数
...
void swap(Widget& other)
{
using std::swap; // 本节后面会解释为什么这样声明
swap(pImpl, other.pImpl); // 交换pImpl指针来交换Widget
}
...
};
namespace std {
template<> // 特化的std::swap (已修正)
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); // 要交换Widget,
} // 只要调用它们的swap成员函数
}
这样的代码不仅仅可以通过编译,而且也与STL容器相协调,它不仅仅提供了公有的swap成员函数,而且还提供了特化的std::swap来调用这些成员函数。
然而,我们不难发现,Widget和WidgetImpl都是类模板,而不是类,似乎我们可以自定义WidgetImpl中保存的数据的类型:
template<typename T> class WidgetImpl { ... };
template<typename T> class Widget { ... };
将一个swap成员函数放入Widget中(如果需要,也可以是WidgetImpl)仍然十分简单,但是我们对std::swap特化时将会遇到问题。下面是我们希望编写的代码:
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, Widget<T>& b)
// 错误!非法代码
{ a.swap(b); }
}
这样的代码看上去完美无瑕,但是它是非法的。因为其中尝试对一个函数模板(std::swap)进行不完全的特化,但是,尽管C++允许对类模板进行不完全特化,而函数模板就不行了。这一代码不应通过编译(尽管一些编译器会错误的接受)。
当你期望对一个函数模板进行“不完全特化”时,通常的做法非常简单,就是添加一个该函数的重载。代码可能是下面的样子:
namespace std {
template<typename T> // std::swap的一个重载
void swap(Widget<T>& a, Widget<T>& b)
// (注意swap后边没有<...>)
{ a.swap(b); } // 下文解释了为什么这样做不合法
}
一般情况下,重载函数模板是可以的,但是std是一个很特殊的名字空间,它的规则也是独特的。对std中的模板进行完全特化是合法的,但是为std添加一个新的模板却是不合法的(类或函数或其他一切都不可以)。std的内容是由C++标准化委员会一手确定的,我们无法修改他们所规定的任何形式,只能“望码兴叹”。越轨的代码似乎可以运行,但它们的行为却是未定义的。如果你希望你的代码拥有可预知的行为,你就不应该在std中添加新的内容。
那么应该怎么办呢?我们仍然需要一种方法来让其他人通过调用swap来访问我们更加高效的特化版本。答案很简单。我们仍然可以通过声明一个非成员函数swap来调用成员函数swap实现,只要这个非成员函数不是std::swap的特化或者重载版本即可。比如说,如果我们所有与Widget相关的功能都在名字空间WidgetStuff中,那么代码看上去应该是这样:
namespace WidgetStuff {
... // 模板化的WidgetImpl,等等
template<typename T> // 同上,包括swap成员函数
class Widget { ... };
...
template<typename T> // 非成员函数swap
void swap(Widget<T>& a, Widget<T>& b)
// 不属于std名字空间
{
a.swap(b);
}
}
现在,如果任意位置的代码对两个Widget对象调用了swap,C++的名字搜寻守则(更具体地说,就是所谓的参数依赖搜寻或Koenig搜寻)将会在WidgetStuff中查找具体到Widget的版本。这恰恰是我们需要的。
由于这种方法针对类或者类模板可以正常运行,所以看上去似乎我们应该在任何情况下都使用它。但是遗憾的是,我们还是要对于类的std::swap进行特化(稍后会交代理由),所以如果你想要在尽可能多的上下文中(你所需要的)调用具体到类的swap版本,你就需要在你的类所在的名字空间编写一个非成员版本的swap,同时还需要一个std::swap的特化版本。
顺便说一下,即使你没有使用名字空间,上述内容仍然有效(也就是说,你仍需要一个非成员的swap去调用成员函数swap),但是为什么你要把所有的类、模板、函数、枚举类型、enumerant、typedef的名字统统塞进全局名字空间里呢?如果你对编程规范有一点概念的话,都不会这样做的。
到目前为止我所介绍的一切内容都是以swap的作者的角度展开的,但是以一个客户的眼光来审视一下swap也是很有价值的。假设你正在编写一个函数模板,这里你需要交换两个对象的值:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
}
这里应该调用哪一个swap呢? std中存在一个通用版本,这是你所知道的;另外std中可能还有一个针对这一通用版本的特化版本,它可能存在也可能不存在;或者一个模板的版本,它可能存在也可能不存在,它是否在一个名字空间中也不能确定(但可以肯定不在std名字空间中)?此时你所希望的是,如果存在一个模板版本的话,就调用它;如果不存在,就返回调用std中的通用版本。以下是满足这一要求的代码:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // 确保std::swap在此函数中可用
...
swap(obj1, obj2); // 为类型T的对象调用最佳的swap
...
}
当编译器看到对swap的调用时,它们会寻找恰当的swap来进行调用。C++的名字搜寻原则确保了在全局或T类型所在的名字空间中来查找所有的精确到T的swap。(举例说,如果T是位于WidgetStuff名字空间中的Widget,那么编译器将会使用参数依赖搜寻方式来查找WidgetStuff中的swap。)如果没有精确到T的swap存在,那么编译器将会使用std中的swap,多亏了using声明可以使std::swap在本函数中可见。然而即使这样,编译器也更期望得到一个精确到T的std::swap的特化版本,而不是未确定类型的模板,因此如果std::swap特化为T版本,那么这一特化的版本将会得到使用。
因此,调用正确的swap十分简单。你所需要关心的事仅仅是不去限制对它的调用,因为如果这样做会使C++如何决定去调用函数的方式受到影响。举例说,如果你用下面的方式调用了swap:
std::swap(obj1, obj2); // 调用swap的错误方法
你强迫编译器仅仅去考虑std中的swap(包括所有的模板特化版本),这样做就排除了得到一个位于其他位置的精确到T版本的swap的可能,即使它是更加合理的。然而,一些进入误区的程序员还是会以这种方式限制swap的调用,这里你就可以看出,为你的类提供一个std::swap的完全特化版本是多么重要:对于那些使用不恰当的编码风格写出的代码(这样的代码也存在于一些标准库的实现当中,如果你感兴趣可以自己编写一些代码,来帮助这样的代码尽可能的提高效率),精确到类的swap实现仍然有效。
此刻,我们已经介绍了默认的swap、成员swap、非成员swap、std::swap的特化版本,以及对swap的调用,现在让我们来做一个总结。
首先,如果对你的类或者类模板使用默认的swap实现能够得到可以接受的效率,你就不需要做任何事情。任何人想要交换你创建的类型的对象时,都会去调用默认的版本,此时可以正常工作。
其次,如果默认的swap实现并不够高效(大多数情况下意味着你的类或模板正在运用pimpl惯用法),请按下面步骤进行:
1. 提供一个公用的swap成员函数,让它可以高效的交换你的类型的两个对象的值。理由将在后面列出,这个函数永远不要抛出异常。
2. 在你的类或模板的同一个名字空间中提供一个非成员的swap。让它调用你的swap成员函数。
3. 如果你正在编写一个类(而不是类模板),要为你的类提供一个std::swap的特化版本。同样让它调用你的swap成员函数。
最后,如果你正在调用swap,要确保使用一条using声明来使std::swap对你的函数可见,然后在调用swap时,不要做出任何名字空间的限制。
文中还有一处欠缺,那就是本文的标题中的敬告:不要让swap的成员函数版本抛出异常。这是因为swap最重要的用途之一就是帮助类(或类模板)来提供异常安全的保证。条目29中详细介绍了这一点,但是这一技术做出了“swap的成员函数版本永远不会抛出异常”这一假设。这一约束仅仅应用于成员函数版本,非成员版本则不受这一限制。这是因为swap的默认版本基于拷贝构造和拷贝赋值,而在一般情况下,这两种函数都可能抛出异常。因此,当你编写一个自定义版本的swap时,在典型情况下你不仅要提供一条更高效的交换对象值的方式,同时你也要提供一个不抛出异常的版本。作为一条一般的守则,这两条swap的特征是相辅相成的,因为高效的swap同时也基于内建数据类型的操作(诸如pimpl惯用法中使用的指针),同时内建数据类型的操作决不会抛出异常。
时刻牢记
l 在对你的类型使用std::swap时可能会造成效率低下时,可以提供一个swap成员函数。确保你的swap不要抛出异常。
l 如果你提供了一个swap的成员函数,那么同时要提供一个非成员函数swap来调用这一成员。对于类而言(而不是模板),还要提供一个std::swap的特化版本来调用swap成员函数。
l 在调用swap时,要为std::swap使用一条using声明,然后在调用swap时,不要做出名字空间的限制。
l 对用户自定义类型而言,提供std的完全特化版本不成问题,但是决不要尝试在std中添加全新的内容。