[Effective C++]条款25: 考虑写出一个不抛异常的swap函数

193 阅读7分钟

1: 类的交换

所谓swap(置换)两对象值,意思是将两对象的值彼此赋予对方。缺省情况下swap动作可由std标准程序库提供的swap算法完成。 代码如下:

namespace std {
    template<typename T>
    void swap(T& a, T& b)
    {
        T temp(a);
        a = b;
        b = temp;
    }
}

只要类型T支持拷贝函数(通过拷贝构造函数和拷贝赋值运算符完成),默认的swap实现代码就会帮你置换类型为T的对象,你无需再做任何工作。 然而这个默认的swap函数的实现非常普通,涉及到了三个对象的复制: a, b, temp. 如果T类型很大, 那么会消耗很大的内存.

有个常用的方法叫做pimpl(pointer to implementation), 即以指针指向一个对象内含真正的数据. 用这种方法看来改写Test 类,看起来会像这样:

//这个类包含Test类的数据
class TestImpl {
public:
    ...
private:
    int a,b,c;				//可能有许多数据,意味复制时间长
    std::vector<double> v; 	        //高成本拷贝警告!
};

//这个类使用pimpl手法
class Test {  
public:
    Test(const Test& rhs);
    //重写 =
    //复制Test时,令它复制TestImpl对象
    Test& operator=(const Test& rhs)//赋值运算符的实现见条款10,11,12
    {
      ...
      *pImpl = *(rhs.pImpl);
      ...
   }
  ...
private:
    TestImpl* pImpl; //使用pimpl指针来指向Test数据
};

当需要交换两个Test对象的值, 我们可以交换其pImpl指针. 但是std里默认的swap方法并不知道这一点, 他不止复制三个Test, 还会复制三个TestImpl. 非常缺乏效率.

所以, 我希望告诉std::swap: 当Test对象被置换时, 需要置换内部的pImpl指针, 为此试着重写std::swap, 如下所示:

代码如下所示:

namespace std{
    //特殊化的std::swap,当T是Test类型时使用如下实现
    template<>
    void swap<Test>(Test& a, Test& b)
    {
        swap(a.pImpl,b.pImpl); //交换Test时只要交换它们的pImpl指针
    }
}

虽然我们通常不被允许改变std命名空间里的任何东西, 但是可以为标准template设计特化版本(例如swap). 在上面代码里, template<> 代表了下面的代码是对 std::swap 的完全特殊化(total template specialization)实现, 函数名称后的 则代表了当T是 Test类型时使用这个特殊的swap。也就是对于其它类型依然使用默认的 std::swap,仅仅对于Test类型才使用特殊化.

但是我们上面的程序是无法通过编译的, 因为Test 内的 pImpl 指针在 private 域内,外部无法访问。但是我们可以在Test 内声明一个名为 swap 的 public 成员函数做真正的交换工作,然后将 std::swap 特殊化,令它调用该成员函数, 代码如下所示:

using namespace std;
class TestImpl {
};

class Test {
public:
    void swap(Test& other) {
    using std::swap;             //这句是必要的, 稍后解释
     swap(pImpl, other.pImpl);    //执行真正的swap,只交换指针
    }

private:
    TestImpl* pImpl;
};

namespace std {
    template<>
    void swap<Test>(Test& a, Test& b)
    {
        a.swap(b);
    }
}

这种做法不止能通过编译,还与STL容器有一致性。因为 STL 容器也使用了 public swap成员函数和一个特殊化的std::swap(用来调用前者)来调用这个成员函数实现高效交换功能。

2: 类模版的交换

C++ 只允许对类模版偏特化(partially specialize), 而不能对函数模版进行偏特化, 所以当我们想写成如下这样, 是行不通的:

namespace std 
{ 
    template<typename T> 
    void swap<Test<T>>(Test<T>& a, 
                        Test<T>& b) { //非法代码 a.swap(b); } 
}

当我们想偏特化一个类模版时, 通常的做法是添加一个重载版本, 如下所示:

namespace std 
{ 
    template<typename T> 
    void swap<>(Test<T>& a, 
                Test<T>& b) {} 
}

这个版本和上面的区别是swap后面没有<> 一般而言重载函数模版没有问题, 但是std 是一个特殊的命名空间, 我们可以全特化std里面的templates, 但是不可以添加新的templates(函数模版/类模版)到std里面. 所以上面的程序依然编译不过.

那现在怎么办呢?我们可以声明一个非成员函数 swap,让它调用成员函数 swap,但不再将那个非成员函数 swap 声明为 std::swap 的特殊化版本或重载版本。为了简化,假设 Test 的所有相关机能都在命名空间 TestStuff (不能放在 std 命名空间)内:

//我们自己的名空间
namespace TestStuff{
    //我们的类模板
    template<typename T>
    class Test{...}; // 内含swap函数
    ...
    //swap函数和类模板在同一命名空间
    template<typename T>
    void swap(Test<T>& a, Test<T>& b){ // 非成员函数
        a.swap(b);
    }
    ...
}

现在任何地点任何代码如果打算置换两个Test对象, 都会调用swap, C++称为名称查找法则(name lookup rules)或者实参查找法则(argument-dependent lookup), 会找到TestStuff内Test的专属版本.

这个方法既适用于类也适用于类模板,所以我们应该可以在任何时候都能使用它. 如果想让我们的 swap 函数适用于更多情况,那么除了在我们自己的命名空间里写一个 swap,在 std 里面依然要特殊化一个 swap.

从用户观点看事情也有必要。假如我们正在写一个函数模板,其内需要交换两个对象值:

template<typename T> 
void doSomething(T& obj1, T& obj2)
{
    ... 
    swap(obj1,obj2); 
    ...
}

执行上面的代码应该调用哪个swap呢?

1: 是 std::默认的那个版本,

2: 还是某个可能存在的特殊化版本,

3: 抑或是一个可能存在的 T 专属版本而且可能栖身于某个命名空间(但当然不可以是std)内。

最理想的情况是,调用 T 专属版本,并在它不存在的情况下调用 std 内的一般化版本:

template<typename T>
void doSomething(T1& obj1, T2& obj2)
{
    ..
    using std::swap; //让std::swap对编译器可见
    swap(obj1,obj2); //为T类型对象调用最佳swap版本
    ...
}

当编译器看到要调用 swap 的时候,实参依赖查找会让编译器在全局作用域和实参所在的命名空间里搜索适当的 swap 调用。例如,如果T是 Test 类型,那么编译器就会使用实参依赖查找找到Test的命名空间里的 swap。如果没有的话,编译器使用 std 内的 swap,这归功于 using std::swap 让 std::swap 在函数内曝光。然而编辑器还是比较喜欢 std::swap 的 T 专属特殊化版本,而非默认的 swap,所以如果你已针对 T 将 std::swap 特殊化,特殊化版本会被编译器挑中。

因此,令适当的 swap 被调用是很容易的,但是需要注意的是,别为这一调用添加额外的修饰符,因为这会影响C++挑选适当的函数,例如:

std::swap(obj1,obj2); //这是错误的调用方式

这强迫了编译器只认 std 内的swap(包括其任何特殊化的模板),因此不可能再调用一个适合它的 swap 函数。

3: 总结

  • 1: 首先,如果 swap 的默认版本对你的类或类模板提供可接受的效率,你不需要额外做任何事
  • 2: 其次,如果 swap 的默认版本的效率不足(意味着你的类和模板使用了某种 pimpl 手法),试着做以下事情:
    • 提供一个 public swap 成员函数,让它高效的交换你的类型的两个对象值,这个函数绝不能抛出异常
    • 在你的类或模板所在的命名空间内提供一个非成员函数 swap ,并令它调用上面的 swap 成员函数
    • 如果你正编写一个类(而非类模板),为你的类特殊化 std::swap ,并令它调用你的 swap 成员函数
  • 3: 最后,如果你调用 swap ,请加上 using std::swap,以便让 std::swap 在你的函数内曝光,然后不加任何命名空间名字调用 swap

最后我们来了解一下为什么成员函数 swap 不能抛出异常? 因为 swap 这个功能本身经常会被用来实现异常安全。但是非成员函数的 swap 则可能会抛出异常,因为它还包括了拷贝构造等功能,而这些功能则是允许抛出异常的。当你写一个高效的 swap 实现时,要记住不仅仅是为了高效,更要保证异常安全,但总体来讲,高效和异常安全是相辅相成的。

Note:

  • 当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常
  • 如果你提供一个成员函数 swap,也该提供一个非成员函数 swap 用来调用前者,对于 类(而非模板),也请特殊化 std::swap
  • 调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何命名空间资格修饰符
  • 为用户定义类型进行 std 模板全特殊化是好的,但千万不能在 std 内加入任何新模板

4: Reference: