第25条:
最好不要让交换数值函数
swap
抛出异常
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 idiom
”(
pointer to implementation
,指向实现的指针,参见第
31
条
)。比如
Widget
类可以使用这种设计模式。请看下面的代码:
class WidgetImpl { //
保存
Widget
的数据的类
public: //
细节不重要
...
private:
int a, b, c; //
可能会有很多数据
std::vector<double> v; //
复制它们的代价是很高的!
...
};
class Widget { //
使用
pimpl idiom
的类
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) //
要复制一个
Widget
对象,只要
{ //
复制对应的
WidgetImpl
对象。
... //
关于
operator=
实现的一般信息
*pImpl = *(rhs.pImpl); //
参见第
10
、
11
、
12
条
...
}
...
private:
WidgetImpl *pImpl; // ptr to object with this
}; // Widget's data
为了交换两个
Widget
对象的值,我们所要做的仅仅是交换他们的
pImpl
指针,但是默认的
swap
算法是不可能知道这一切的,它不仅会复制三个
Widget
对象,同时也会复制三个
Widget
对象。这样做效率太低了。
我们要做的是告诉
std::swap
当交换
Widget
时,执行的交换操作应当仅仅针对它们内部的
pImpl
指针。有一种精确的说法来描述这一方法:将
Widget
特化。下面是基本的思想,尽管以这种方式不能通过编译:
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 idiom
),请按下面步骤进行:
1.
提供一个公用的
swap
成员函数,让它可以高效的交换你的类型的两个对象的值。理由将在后面列出,这个函数永远不要抛出异常。
2.
在你的类或模板的同一个名字空间中提供一个非成员的
swap
。让它调用你的
swap
成员函数。
3.
如果你正在编写一个类(而不是类模板),要为你的类提供一个
std::swap
的特化版本。同样让它调用你的
swap
成员函数。
最后,如果你正在调用
swap
,要确保使用一条
using
声明来使
std::swap
对你的函数可见,然后在调用
swap
时,不要做出任何名字空间的限制。
文中还有一处欠缺,那就是本文的标题中的敬告:不要让
swap
的成员函数版本抛出异常。这是因为
swap
最重要的用途之一就是帮助类(或类模板)来提供异常安全的保证。第
29
条中详细介绍了这一点,但是这一技术做出了“
swap
的成员函数版本永远不会抛出异常”这一假设。这一约束仅仅应用于成员函数版本,非成员版本则不受这一限制。这是因为
swap
的默认版本基于拷贝构造和拷贝赋值,而在一般情况下,这两种函数都可能抛出异常。因此,当你编写一个自定义版本的
swap
时,在典型情况下你不仅要提供一条更高效的交换对象值的方式,同时你也要提供一个不抛出异常的版本。作为一条一般的守则,这两条
swap
的特征是相辅相成的,因为高效的
swap
同时也基于内建数据类型的操作(诸如
pimpl idiom
中使用的指针),同时内建数据类型的操作决不会抛出异常。
铭记在心
l
在对你的类型使用
std::swap
时可能会造成效率低下时,可以提供一个
swap
成员函数。确保你的
swap
不要抛出异常。
l
如果你提供了一个
swap
的成员函数,那么同时要提供一个非成员函数
swap
来调用这一成员。对于类而言(而不是模板),还要提供一个
std::swap
的特化版本来调用
swap
成员函数。
l
在调用
swap
时,要为
std::swap
使用一条
using
声明,然后在调用
swap
时,不要做出名字空间的限制。
l
对用户自定义类型而言,提供
std
的完全特化版本不成问题,但是决不要尝试在
std
中添加全新的内容。