总结C++中的swap
2016-09-04
swap的作用
对于类的operator=, 我们通常要考虑自我赋值和异常安全问题;
这里的异常安全主要因为我们在operator=内部会释放当前资源, 然后复制rhs的资源, 但复制可能用了会抛异常的操作, 比如new, 所以, operator=内部抛出异常时, 我们就可能得到一个相当于析构掉的对象, 导致未定义行为;
在自我赋值的频率和成本不那么高时, 我们经常会用copy and swap来实现异常安全的operator=, 形如:
Example& operator=(const Example& rhs){
Example temp(rhs);
swap(*this, temp);
return *this;
}
当然, 自定义一个swap会靠谱一些:
void swap(const Example& that){
...
}
Example& operator=(const Example& rhs){
Example temp(rhs);
this->swap(temp);
return *this;
}
声明也可以改成pass by value:
Example& operator=(Example rhs){
this->swap(rhs);
return *this;
}
不仅仅是operator=, 还有在其他场景下, 我们也可以把copy and swap作为一个保证异常安全的可靠模式, 就是如果我们要对一个实例做可能异常的一系列操作, 而任何一项操作异常, 都可能破坏掉这个实例; 而我们可以做的是, 先复制这个实例, 然后对复制品施加操作, 操作完还没异常, 我们就将复制品和目标实例swap一下; 当然这要求了swap是不能抛异常的:
void DoSomething(Example& e){
Example temp(e);
temp.func1(); //may except;
temp.func2(); //may except;
...
swap(e, temp); //no except
} //如果func1, func2之类的抛异常, e的状态却没有改变, 仍然可用
这在Effective C++中被称为”异常安全性编程的脊柱”; 另外一个用到swap的地方, 就是STL了, 比如, std::sort的快排, 会有大量的交换操作, 就是调swap来完成的; vector的空闲内存, 如果我们很在意, 也可以用copy and swap一个小的vector来释放掉…总的来说, swap还是挺常用的, 我们要把它实现好;
swap的实现
默认条件下, swap的实现是这样的:
template<typename T>
inline void swap(T& a, T& b){
T temp(a);
a=b;
b=temp;
}
这调用了一次复制构造函数和两次复制运算符, 看起来如果对象内部有大量数据, 或者pImpl, 我们就无意义地将这些数据复制了几次;
我只是想swap一下, 能不能别全部复制那么浪费?
可以, 我们还是以pImpl为例:
class Example{
public:
Example(const Example& e){
copy(e);
}
Example& operator=(const Example& e){
if(&e==this) return *this;
copy(e);
return *this;
}
private:
void copy(const Example& e){
ExampleImpl* temp = new ExampleImpl(*e.pImpl);
delete pImpl;
pImpl=temp;
}
ExampleImpl* pImpl;//这玩意复制成本很高
}
实际上我们swap两个Example实例时, 我们只需要交换pImpl, 没必要复制来复制去; 那么我们也许可以特化一个std::swap的模板, 使其有专门针对Example的特化版本:
namespace std{
template<>
inline void swap<Example>(Example& a, Example& b){
swap(a.pImpl,b.pImpl);
}
然而, 这并不能通过编译, 因为pImpl成员是私有的, 我们可以将其声明为Example的friend:
class Example{
...
friend inline void std::swap<Example>(Example& , Example& );
}
但声明为friend可能不是好习惯, 我们不如干脆给Example弄一个公有的swap成员函数好了:
class Example{
...
void swap(Example& that){
std::swap(pImpl, that->pImpl);
}
...
};
然后我们特化std::swap
namespace std{
template<>
inline void swap<Example>(Example& a, Example& b){
a.swap(b);
}
现在我们对两Example调用std::swap的时候, 就会调到swap成员函数去;
现在我们考虑成员函数里面要交换的东西也有定制的swap, 那么我们上面写的成员版swap中调用std::swap的方式就不那么合适了, 当pImpl有定制的swap时这样写并没有调到;
我们应该先using std::swap使得此处std::swap可见, 然后让编译器自己决定调用哪个版本的swap函数, 这样写起来就像:
class Example{
...
void swap(Example& that){
using std::swap;
swap(pImpl, that->pImpl;
}
...
};
现在无论pImpl有没有定制的swap, 我们都会正确调用之; 同理, 我们要使用Example的swap的时候, 也应该如此using swap;
保证了一般类的定制swap之后, 我们就该考虑一下模板类的swap了; 由于C++98标准不允许模板函数的偏特化, 所以我们不能这样写:
namespace std{
template<typename T>
inline void swap<Example<T>>(Example<T>& a, Example<T>& b){
//可能有些编译器支持, 参考[2]
a.swap(b);
}
}
那我写成模板重载行不行? 像这样:
namespace std{
template<typename T>
inline void swap(Example<T>& a, Example<T>& b){
a.swap(b);
}
}
这样得到的是std::swap的重载版本, 但这样不靠谱, 一般而言std命名空间内的东西只允许特化, 而不允许添加, 我们这样加了一个重载版本, 编译是可以过的, 但是难说会不会有什么未定义行为;
所以到这里我们是不能通过该std命名空间来达到我们的目的了, 为了达到目的, 我们只好将非成员版本的swap声明到std命名空间外, 至于是不是全局命名空间内声明, 就看有没有需要避免名字污染了, 假设是需要的, 我们弄一个ExampleSpace:
namespace ExampleSpace{
template<typename T> class Example{
...
void swap(Example<T>& that){
using std::swap;
swap(m_something, that->m_something);
}
...
};//class Example
}//namespace ExampleSpace
这个方法不错, ADL会帮我们确定具体调用那个版本, 那么, 如果我们能访问到定制非成员版本的swap, 用using std::swap然后swap的话, 我们就能调用定制的版本; 但是啊, 我们可不能保证所有使用者都using std:swap然后swap呀, 可能一不小心就直接调std::swap了呀;
所以, 如果可能, 我们还是要写一个特化std::swap的版本, 但是我想所有程序员都讨厌重复代码, 所以, boost提供了一个小工具来解决这个麻烦:boost::swap, 它在自己的实现中using了一次namespace std, 就像我们上面using了std::swap一样;另外, boost::swap也能swap数组, 其内部用for循环对数组的每个元素swap, 具体实现如下:
namespace boost_swap_impl
{
template<class T>
BOOST_GPU_ENABLED
void swap_impl(T& left, T& right) {
using namespace std;//use std::swap if argument dependent lookup fails
swap(left,right);
}
template<class T, std::size_t N>
BOOST_GPU_ENABLED
void swap_impl(T (& left)[N], T (& right)[N]) {
for (std::size_t i = 0; i < N; ++i) {
::boost_swap_impl::swap_impl(left[i], right[i]);
}
}
}
namespace boost
{
template<class T1, class T2>
void swap(T1& left, T2& right) {
::boost_swap_impl::swap_impl(left, right);
}
}
namespace boost_swap_impl {
template<class T> void swap_impl(T& left, T& right) {
using namespace std;//use std::swap if argument dependent lookup fails
swap(left,right);
}
template<class T, std::size_t N> void swap_impl(T (& left)[N], T (& right)[N]) {
for (std::size_t i = 0; i < N; ++i) {
::boost_swap_impl::swap_impl(left[i], right[i]);
}
}
}
namespace boost {
template<class T1, class T2> void swap(T1& left, T2& right) {
::boost_swap_impl::swap_impl(left, right);
}
}
C++11下如何
然而, 到了C++11, std::swap的实现改成了移动构造和移动赋值:
//c++11
template<typename T>
inline void swap(T& a, T& b){
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
到了这里, 看起来效率好像还挺高的, 那么, 我们还需要特地写一个swap吗?
那就得看一下move和swap有什么区别?
首先move的lhs释放自己的资源, 然后接管rhs的资源, 移动之后, rhs应该具有更少的资源(比如直接置空各种指针), 移动之后需假设rhs除非接收其他移动, 否则就是个无效的对象了; 而swap则是交换lhs和rhs的资源, 两者都没有析构操作, swap之后两者均可用;
考虑上面的c++11的std::swap来swap有一个pImpl指针的类, 有三次move操作, 每次会有两个指针赋值(lhs被赋值, rhs被置空), 共6次指针赋值; 如果我们自己写一个swap, 则只有3次指针赋值; 所以听起来还是自己写一个swap比较快, 如果性能比较敏感, 还是需要自己写一个定制的swap;
如果性能不那么敏感, 那我感觉就没必要特地写一个定制的swap了, 毕竟如果类的组成很复杂, 资源管理相关的代码本来就容易出错, 没必要增加一份风险;
那如果我们原来就有一个定制的swap, 现在升级c++11了, 我们能不能复用swap来实现移动呢?比如说这样:
Example& operator=(Example&& rhs){
this->swap(rhs);
return *this;
}
这样的写法有时是行的, 比如只有内存资源的类; 另外的情况, 比如我们前面说的, 移动操作中, lhs应该先释放自己的资源, 所以move之后, lhs的资源已经释放了; 用swap来实现的话, lhs的资源只是被交换到了rhs上, 并没有马上被释放, 如果这个类对lhs资源释放这一步比较看重(比如说, 锁), 就不能用swap来做; 另一方面, swap成员函数至少得有3次指针赋值, 而直接写的话, 有2次指针赋值;
所以, 用swap来实现move只适用于性能不敏感, 对lhs资源释放也不敏感的类;
线程安全问题
考虑两个实例在不同线程可能swap, 如何加锁才能避免死锁呢?, 比如:
// 错误的范例
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class Example {
private:
some_big_object m_detail;
std::mutex m_mutex;
public:
Example(const some_big_object& d) : m_detail(d) {}
friend void swap(Example& lhs, Example& rhs) {
if (&lhs == &rhs) {
return;
}
std::lock_guard<std::mutex> lock_l(lhs.m_mutex);
std::lock_guard<std::mutex> lock_r(rhs.m_mutex);
swap(lhs.m_detail, rhs.m_detail);
}
}
上面的例子中, 我们先对lhs加锁, 然后在对rhs加锁, 这其实会死锁, 因为如果在线程1中, swap(a, b) 而线程2中, swap(b, a), 然后某一时刻, 线程1中, a对象已经被锁, 线程切换到线程2, b对象被锁; 线程再切换回线程1, 此时b对象已经在线程2被锁了;
所以无论先锁hs, 还是先锁rhs, 都无法解决死锁的问题, 我们只能希望线程库提供一次性锁多个mutex的方式;
c++11的std::lock函数正好可以做到:
// 更正确的范例
class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class Example {
private:
some_big_object m_detail;
std::mutex m_mutex;
public:
Example(const some_big_object& d) : m_detail(d) {}
friend void swap(Example& lhs, Example& rhs) {
if (&lhs == &rhs) {
return;
}
std::lock(lhs.m_mutex, rhs.m_mutex);
std::lock_guard<std::mutex> lock_l(lhs.m_mutex, std::adopt_lock);
std::lock_guard<std::mutex> lock_r(rhs.m_mutex, std::adopt_lock);
swap(lhs.m_detail, rhs.m_detail);
}
}
上面的例子中, lhs和rhs同时被锁, 然后构造lock_guard以便异常安全地解锁; std::adopt_lock告诉lock_guard此mutex已经被锁, 不需要在构造函数中试图锁定;
值得一提的是, [5]中提到, std::lock中锁定lhs或是rhs都可能引发异常, 在这种情况下, 该异常会传播出std::lock; 如果已经锁上了其中一个, 而另一个异常了, 则会自动释放已经锁上的mutex. std::lock保证了这些mutex全锁或全不锁的行为.
总结
综上:
- 当默认的swap性能不够时, 实现一个swap成员函数, 并保证其不抛异常;
- swap和move语义是不同的, 仅在部分情况他们可以相互利用;
- 如果提供了一个swap成员函数, 那么也应该提供一个非成员的, 然后调用成员函数, 对非模板类, 则特化std::swap;
- 当你要使用swap时, 要么using std::swap;swap(…), 要么使用boost::swap;
- 需要考虑线程安全问题时, 注意避免死锁, 可以使用std::lock一次锁定多个mutex;
Reference:
- [1] Scott Mayers. Effective C++ 3rd. item11, item25, item29
- [2] C++ function template partial specialization? - Stackoverflow
- [3] Why do some people use swap for move assignments? - Stackoverflow
- [4]: 罗剑锋. Boost程序库完全开发指南: 深入C++”准”标准库. 第2版. 北京:电子工业出版社. p121-p124
- [5]: Anthony Williams. C++并发编程实战. 人民邮电出版社. p45-p46