邓作恒的博客 +

总结C++中的swap

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全锁或全不锁的行为.

总结

综上:

Reference: