C++设计模式由浅入深(四)——swap抽丝剥茧

四、深入浅出剖析swap

1 STL与swap

swap操作被广泛应用与C++标准库中。所有的标准模板库(STL)容器都提供了swap函数,并且同时支持非成员版本的函数模板std::swapswap在STL的算法中也有广泛应用。标准库也是常常被用来实现与之类似自定义功能的模板库。因此,我们将会开始学习swap操作并且仔细研究标准库提供的函数细节。

1.1 swap与STL容器

从概念上讲,swap所做的行为就如同下面所示的操作:

template <typename T> void swap(T& x, T& y) {
	T tmp(x);
	x = y;
	y = tmp;
}

当我们调用swap()时,xy对象中的内容就被交换了。然而,这种实现只是最糟糕的一种实现方式。这个实现中最显著的问题是发生了不必要的对象拷贝(实际上过程中发生了3次拷贝)。这个操作的执行时间与T类型的大小成比例。对于STL容器而言,这里所说的大小(size)表示的是容器的大小而非其中元素类型的大小:

void swap(std::vector<int>& x, std::vector<int>& y) {
	std::vector<int> tmp(x);
	x = y;
	y = tmp;
}

上面的例子可以通过编译,并且在大多数情况,甚至能够正常运作。然而,这样会对vector容器中的元素进行多次拷贝。第二个问题是,他临时分配了资源。例如,在交换的过程中,我们创建了第三个vector容器,而它将与两个参数其中之一的所占据的大小相同。这样的内存分配看似毫无必要,因为对于最终结果而言,我们所需的空间没有发生变化,需要变化的仅仅是访问两个数据空间的名称。最后一点问题是,这种幼稚的实现方式没有考虑到内存分配失败的情况。

整个交换操作,应当尽可能的简单并且防呆,就好比仅仅交换了用来访问数据的两个变量的名称一样简单,而不需要担心会发生内存分配失败这样的错误。但是这不是唯一可能失败的情况,因为拷贝构造函数与赋值运算符也都有可能抛出异常。

所有的STL容器,包括std::vector,提供了可以在常数时间复杂度下交换的保证。假如考虑STL容器对象仅使用指针指向数据区域,并仅有一些额外的状态,例如对象大小(元素的数量),那么实现这种需求的方式可以非常直截了当。为了对两个容器进行交换,我们只需要交换它们持有的指向数据区域指针(当然也包括各自存储的状态指标);而它们持有的元素并不需要被拷贝,而是留在原地,因为它们是通过动态内存分配出来的。这种交换函数的实现中,我们仅仅需要交换指针,容器大小,以及其他的状态指标(真实的STL实现中,一个容器类,如vector,并不直接由内建类型,如指针,组成其数据成员,而是通过一个或者多个class数据成员,并且各自由指针或者其他的内建类型组成)。

由于任何指针或者vector的数据成员都不是公开可访问的,这个交换操作必须实现为容器的成员方法,或者被声明为友元函数。在STL中采用了前者,即所有的STL容器都有swap()成员方法,可以借此与同样类型的容器对象交换其中存放的对象数据。

这种通过交换指针进行的swap方法的实现方式解决了,至少是间接地解决了我们上述提到的两个问题。首先,因为仅仅对容器中存放的指针进行了交换,因此不会引发额外内存分配。其次,对于内建类型和指针的拷贝行为不会抛出异常,因此整个交换操作不会抛出异常(因此也不会失败)。

到目前为止,我们描绘的这个场景既简单又一致,但仅仅是通常情况下如此。一个显然的问题就是,当容器存放的元素并非普通类型,而是可调用对象时,可能会导致一些问题。例如,std::map容器可以接受可选的比较函数,用于对其中元素进行大小比较,而在默认情况下它使用的是std::less。这样的可调用对象需要存储在容器中。由于它们经常会被调用,因此出于对性能的考虑,它们通常会被分配到容器对象本身的内存空间中,并且事实上它们就是容器类的数据成员之一。

然而,上述的优化方式引出了一个代价,那就是当我们交换两个容器时,同时也会交换它们的比较函数,并且这两个比较函数是对象而并非指针。而通常这些比较函数是由程序库的客户所实现的,因此就不能保证交换时不会发生异常,甚至不能保证能它们够发生交换。

因此,对于std::map,标准库提供了如下保证:为了使其可交换,这些可调用对象也必须可交换。进一步地,交换两个map对象也不允许抛出异常,除非此异常在交换比较函数所属的仿函数抛出,在这种情况下,任何被这次交换抛出的异常都会从std::map的交换函数逸出。这个考虑并不适用于类似std::vector这样的容器,因为它不使用任何可调用对象,因此交换这样的容器依然不会抛出异常(据我所知目前是这样)。

另一个关于这种交换行为的隐患是由于分配器的天然属性造成的,并且很难被解决。考虑如下的问题:两个发生交换的容器必须拥有同样类型的分配器(allocator),但是不一定是同一个对象。每个容器都会通过自己的分配器对其元素进行内存分配,同时这些元素也必须被同样的分配器析构。在经过交换之后,第一个容器会拥有第二个容器的元素,并且必须负责它们的销毁。这种销毁只可能被第一个容器通过其分配器正确地执行,因此在这种情况下,当容器发生交换的适合,各自的分配器也必须同时交换。

在C++11之前的标准中,这个问题被完全忽视了,并且规定了两个同样类型的分配器对象必须能够相互析构各自分配的内存。如果真的是这样的话,那么我们就完全不需要交换容器的适配器;然而如果事实并非如此的话,那么我们的行为就会违反标准,并且进入未定义行为的领域。C++11标准允许分配器具有“非平凡(non-trivial)”的属性,因此我们必须要在交换时同时交换分配器。但是分配器对象未必一定可以被交换。C++标准对于这个问题描述如下:如果对于任何的allocator_type分配器类,都有一个trait类被定义,使得这个值std::allocator_traits<allocator_type>::propagate_on_container_swap::value被正确定义且为true的情况下,那么这些分配器将无条件地使用非成员交换函数进行交换;也就是说,这种情况下会调用swap(allocator1, allocator2)对分配器进行交换(在下一节中会介绍这到底发生了什么)。如果这个valuetrue,那么这些分配器将不会被交换,并且这两个容器必须使用完全一致的分配器。如果连这个条件也无法满足,那么这种情况就是未定义行为。C++17标准对此作出了更为正式的修饰,在这个标准中,STL容器的swap()成员函数被声明为在某些限制条件下的noexcept()函数。

对于两个容器交换不允许抛出异常的标准规范而言,至少可以说,只要我们对容器的交换不涉及到分配器,并且容器不包含可调用对象或者仅使用不抛异常的可调用对象时,它对容器的实现能够产生的影响和限制微乎其微,仅仅会阻止我们对局部缓存优化技术的使用。

我们会在第10章详细讨论这种优化技术,但是简单说,这种优化技术就是通过在类中定义一小段缓存,而避免了在小容量容器中使用动态内存分配情况。然而这种优化通常与异常安全的交换函数不兼容,因为这种情况下容器内的对象不能简单地仅通过交换指针来进行,拷贝行为将必然发生。

1.2 非成员的swap函数

标准库为我们提供了函数模板std::swap()。在C++11之前,它被定义在<algorithm>头文件中;而在C++11后,他被移动到了<utility>中。这个函数模板的声明如下:

template <typename T>
void swap (T& a, T& b);
template <typename T, size_t N>
void swap(T (&a)[N], T (&b)[N]); // C++11 后支持

C++11中添加了对数组类型参数的重载。在C++20中,两种版本的函数又同时被额外声明为constexpr。对于STL容器而言,std::swap()会调用容器的swap()成员函数。我们本节后续看到,swap()函数的行为也可以对不同的类型自定义,但是如果没有特殊处理的话,默认的实现将会被使用。这个实现确实通过临时变量进行交换。在C++11以前,这个临时变量是通过拷贝构造创建的,并且在这个交换函数中,还使用了2此赋值运算符,正如我们在上一小节中实现的那样。因此发生交换的类型必须是可拷贝的(必须同时支持拷贝构造与拷贝赋值),否则,std::swap()将不会通过编译。在C++11以后,std::swap()被重新定义为使用移动构造和移动赋值来进行交换。和往常一样,如果交换的对象可拷贝但未声明任何移动操作的话,那么该交换函数就会使用拷贝构造和拷贝赋值来进行交换。请注意,如果一个类声明了拷贝行为却删除了移动操作的话,那么交换函数不会自动退化到拷贝版本的std::swap(),也就是说,这种情况下对一个不可移动的对象调用std::swap()将会引发编译错误。

由于拷贝一个对象时,通常来说可能抛出异常,因此对于没有自定义交换行为的两个对象实施交换操作也可能抛出异常。移动操作通常不会抛出异常,并且在C++11中,如果移动构造和移动赋值运算符都不抛出异常,那么std::swap()也可以提供异常安全的保证。这个行为在C++17中被规范为noexcep()函数异常规格。

1.3 如标准库一样进行交换操作

前面我们回顾了标准库是如何处理交换操作的,因此我们可以归纳如下的准则:

  • 支持交换操作的类应当实现swap()成员函数以达成常数时间复杂度的交换操作;
  • 一个独立的非成员swap()函数应当为所有可交换类型提供;
  • 交换两个对象的行为应当不抛出异常,否则只能失败;

后面的准则并没有那么严格,也并不一定总能遵循。通常来说,如果一个类型拥有移动操作,并且保证不抛出异常,那么实现一个异常安全的交换函数是可能的。注意,对于许多尤其是标准库提供的异常安全保证,都要求移动和交换操作不抛出异常。

2 何时与为何使用交换操作

为什么交换函数如此重要,以至于我们要花费一整个章节的篇幅来讨论它?为什么不继续使用原来的名字去指涉一个对象,而需要使用交换呢?最主要的原因是,它与异常安全有关。这就是为什么我们常常需要讨论交换行为是否会抛出异常的原因。

2.1 交换与异常安全

C++中交换操作最重要的应用场景与编写异常安全的代码相关,或者说,与编写不容易出错的代码有关。这就是问题的所在了,简而言之,一个异常安全的程序,代表了其在抛出异常时不会使得程序进入未定义的状态。广泛而言,一个错误的发生不能导致程序进入未定义的状态。注意,程序中的错误并不一定需要通过异常的手段来处理,例如,C语言传统中使用函数返回的异常代码(或errno)来表达错误的发生,而不需要创造未定义的行为。尤其是当一个操作造成了错误,而这个操作所消费的资源需要被释放的场景下。通常,我们需要一个一个更为强大的保证,即每个操作要么成功,要么整体回滚。

让我们考虑下面的一个例子,我们将会对一个vector中的元素进行变换操作,并将其结果存放于新的vector中:

class C; // 假设的一个类
C transmogrify(C x) { return C(...); } // 某种对C类型进行的操作
void transmogrify(const std::vector<C>& in, std::vector<C>& out) {
	out.resize(0);
	out.reserve(in.size());
	for (const auto& x : in) {
		out.push_back(transmogrify(x));
	}
}

此时当我们通过out参数返回vector的时候(在C++17中我们也可以通过编译器拷贝省略优化来操作,但是在早期的标准库拷贝函数中,通常不保证拷贝省略优化)。这个vector最初被置为空,并且在随后的操作中容量增加到与输入的vector一样大。out容器中原先存放的任何数据都可能被干掉。注意到这里使用了reserve()方法来避免vector容量增长过程中可能重复发生的内存分配和析构。

这个代码目前看来是没有错误且正常运作的,或者说目前还没有抛出异常。但是这种情况不能一直得到保证。首先,reserve()方法确实发生了内存分配,它可能会失败。如果这种情况发生了,那么transmogrify()函数将会在异常处理中退出,并且out容器将会是空的,因为resize(0)被首先执行了。这样一来,out容器中原先的部分数据就会丢失,并且没有任何数据会代替它。其次,对于vector容器中元素的遍历行为也可能抛出异常。这个异常可能被out中新元素的构造函数抛出,或者被变换行为本身(即第一个函数)抛出。不管怎样,这个循环都会被中断。STL保证了即使在push_back()中调用的构造函数失败,新的元素不会被部分构造(partially created),作为输出的vector不会进入未定义状态,且其大小不会增长。然而,已经保存在输出容器里的元素将会被保留(当然原先存在的早就不见了)。这可能就不会是我们想要的结局,毕竟,transmogrify()的操作对于整个数组“要么成功,要么什么也不发生”的要求显得有点不切实际。

解决这类异常安全实现的关键在于swap操作:

void transmogrify(const std::vector<C>& in, std::vector<C>& out) {
	std::vector<C> tmp;
	tmp.reserve(in.size());
	for (const auto& x : in) {
		tmp.push_back(transmogrify(x));
	}
	out.swap(tmp); // 保证不抛出异常!
}

这个例子中,我们改变了在变换操作中对于临时数组操作的代码。注意,在典型的场景中,输出数组首先应当为空,并且在使用中不会发生容量的增长。如果输出数组中一开始就有数据,那么直到函数执行完毕前,新旧数据都应该存在内存中。如果要保证“除非新的数据能够被完全计算,否则旧的数据会保证不被删除”这一要求,那么这种设计就是必须的。如果要这么做,可以通过减少内存的使用量来获得这个保证,只需要在函数最开始阶段将输出数组清空即可(或者调用者也可以在调用函数前手动清空数组中的内容)。

如果在执行函数的过程中抛出了异常,临时数组将保证被删除,因为vector是在栈上构造的局部变量(在第五章中我们将介绍RAII)。函数的最后一行是异常安全的关键,它交换了临时数组和输出数组中的内容弄个。如果这一行代码可能抛出异常,那就前功尽弃了,因为一旦swap抛出异常,那么输出数组的状态就会会定义。但是在这个例子中,我们对于std::vectorswap操作不会抛出异常,那么代码执行到最后,整个函数就能保证成功,并且结果会被正确地返回给调用者。那么输出数组中原先存在的内容怎么办呢?这些内容现在被临时数组变量所拥有,并且即将在下一个大括号处被(隐式地)析构。假设C类的析构函数遵循C++的准则不抛出异常,我们的函数就达成了异常安全的条件。

这种惯用法通常被称为"copy-and-swap"技巧,并且可能是用来实现"commit-or-rollback"语义的最简单方式,也可以是实现强异常安全保证的最简便方式。这种惯用法的关键就在于交换对象的开销足够低,并且不会抛出异常。

2.2 其他常见的交换操作惯用法

还有一些常见的通过交换实现的技巧,不过相比之下它们没有上面提到的异常安全的交换那么至关重要。让我们从重置一个容器这样简单例子入手,这对于任何可交换的对象都适用:

C c = ....; // c里面包含一些东西
{
C tmp;
c.swap(tmp); // c 现在是空白的了
} // 原来的c在离开作用域后就析构了

注意,这段代码显式地创建了一个空的对象用来交换,并使用了额外的作用域(一对大括号)来保证这个新创建的对象会尽快析构。为了更优雅地实现它,我们可以通过临时变量的手段:

C c = ....; // c里面包含一堆东西
C().swap(c); // 临时对象被创建后马上被销毁

第二行代码创建的临时变量会在这行结束时携带着c对象中原有的内容被析构。注意这一行代码书写的顺序非常重要,swap()函数必须由临时对象调用,如果写成如下形式会报错:

C c = ....; // 同样的c对象
c.swap(C()); // 看起来很对但是无法通过编译

这是因为作为成员函数的swap()需要接受一个非常量的引用作为入参,然而由于临时对象无法绑定在非常量引用上(因为临时对象是右值,右值无法绑定在非常量的左值引用上)。注意,由于同样的原因,作为非成员函数的swap()函数也无法作用在临时变量上。因此,如果对象不提供成员函数版本的swap()函数,那么必须显式创建一个具名对象来交换。

这种用法更广泛的形式是,在不改变对象名称的情况下使对象内容发生变化。假设我们程序中有一个vector数组需要对其施加之前提到过的transmogrify()函数变换。然而,我们并不想为此创建一个新的数组,而是沿用之前的那个数组(或者说,至少沿用之前的数组名),但使用新的数据替换它。下面是实现这种惯用法的一种优雅实现例子:

std::vector<C> vec;
... // 假设vec在使用过后包含了一些数据
{
std::vector<C> tmp;
transmogrify(vec, tmp); // tmp 存放了函数运算结果
swap(vec, tmp); // 此时vec中存放了运算结果!
} // 此时旧的vec被销毁了
... // 继续使用vec变量,但是包含了新的数据

这种模式可以被重复任意次,它使得对象的内容被替换而不会在程序中引入新的变量名。作为反例,传统的不使用swap方法的C风格编写方式是这样的:

std::vector<C> vec;
... // 向vec中写入一些数据
std::vector<C> vec1;
transmogrify(vec, vec1); // 从现在起必须使用vec1!
std::vector<C> vec2;
transmogrify_other(vec1, vec2); // 从现在其必须使用vec2!

注意这些旧的变量名,如vecvec1,在新的数据被计算出来后仍然可访问。因此在后续的代码中,旧更有可能发生变量选择的错误,即当我们想要用vec1的时候,很容易犯下笔误写成vec,而且不会引发编译错误。如果用到前面的交换技巧,我们的程序就不会发生变量名污染的问题。

3 如何正确实现swap

我们已经了解了标准库中swap的工作原理,以及一个swap实现应具备的准则。接下来就让我们一起研究如何正确的为自定义类型实现交换操作。

3.1 实现swap

前面我们已经了解了,所有的STL容器以及标准库中提供的类型(例如std::thread)都提供了swap()成员函数。尽管这不是必须的,但是成员函数版本的swap()是用来实现涉及私有数据成员交换,以及临时对象交换的最简便方式。正确声明成员swap()函数的方式如下:

class C {
	public:
	void swap(C& rhs) noexcept;
};

当然,noexcept异常规格关键字仅当我们能够提供不抛异常保证时才写到声明中。在某些情况下,甚至是有条件的,这取决于实际对象的属性。

那么如何实现swap函数体呢?有几种方法可以办到。对于大多数类,我们可以简单地通过一次性交换全部数据成员来实现。如此一来,交换对象时可能引发的问题就落在了这些成员对象本身的类型身上,也就是说,如果它们遵循swap的设计准则,那么我们的交换函数实际上就是对组成这些对象的内建对象进行了交换。如果你知道你的数据成员具有swap()成员函数,那么就应当调用它。否则,你就需要使用非成员的swap()函数。这个行为很有可能会造成std::swap()函数模板的实例化,但我们不应该直接调用它,原因在下一节中会介绍:

#include <utility> // 如果在C++11之前 应引入<algorithm> 头文件
...
class C {
public:
	void swap(C& rhs) noexcept {
		using std::swap; // 将std命名空间引入当前作用域
		v_.swap(rhs.v_);
		swap(i_, rhs.i_); // 调用std::swap
	}
//...
private:
	std::vector<int> v_;
	int i_;
};

一个特别适用于swap的惯用法就是pimpl,它也被称为handle-body惯用法。它的主要思想就是最小化编译依赖,并且避免在头文件中暴露过多的实现细节。在这种惯用法中,类在头文件中的整个声明仅仅包含了必要的公有成员函数,以及一个指向实际实现的指针。这个实现以及成员函数的本体都在.c/.cpp源文件中。这个指向内部实现的指针数据成员通常被命名为p_impl或者pimpl,也就是这个惯用法的名称。对于使用pimpl实现的类而言,交换的操作就是简单地交换两个指针:

// 下面是头文件中的内容:
class C_impl; // 前向声明实现类
class C {
public:
void swap(C& rhs) noexcept {
swap(pimpl_, rhs.pimpl_);
}
void f(...); // 头文件中仅作声明
...
private:
C_impl* pimpl_;
    };
// 下面是源文件中的内容:
class C_impl {
//... 真实的实现代码 ...
};
void C::f(...) { pimpl_->f(...); } // C::f()的实现代码

上面的例子仅仅照顾到了成员函数版本的swap(),那么对于非成员版本的swap()该怎么做呢?正如我们之前写过的那样,非成员版本会调用std::swap()来实现,如果他可见的话(由于使用了using std::swap声明),那么一定是使用移动或者拷贝操作的:

class C {
public:
	void swap(C& rhs) noexcept;
};
//...
C c1(...), c2(...);
swap(c1, c2); // 要么无法通过编译,要么实际上调用的是std::swap

显然,我们自定义的类必须要有一个支持它的非成员版本的swap()函数。我们可以很轻易地声明它,紧随类的声明体之后。然而,我们应当考虑到如果类并非声明在全局作用域内的情况下,而在独立命名空间内,会发生什么诡异的问题:

namespace N {
class C {
public:
	void swap(C& rhs) noexcept;
};
void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}
//...
N::C c1(...), c2(...);
swap(c1, c2); // Calls non-member N::swap()

对于这个swap调用,它无条件地调用了命名空间N中的非成员版本的swap()函数,而它将转而对其中的一个参数调用成员版本的swap()函数(标准库中的传统是它将会调用lhs.swap())。然而请注意,我们实际上并没有调用N::swap(),而是调用了一个不具有作用域限定符的swap()函数。在命名空间N之外,如果我们不使用using namespace N声明的情况下,一个未指定的函数调用通常不会被编译器解析为调用命名空间内的函数。然而,在本例中却不然,它确实调用到了N命名空间中的swap。这是由于语言标准中所谓的Argument-Dependent Lookup (ADL)规则,也被称为Koenig lookup。所谓ADL将会把变量声明时的携带的作用域限定符中的函数也加入到函数重载决议列表中。

在我们的例子中,编译器在判断swap()函数所代表的真实意义前就看到了swap()函数调用中c1c2变量所携带的N::C作用域限定符。由于这些变量属于命名空间N,因此所有该命名空间中被声明的函数就同时加入到了重载决议中。因此,N::swap函数也被暴露出来了。

如果此时这个类型具有swap()成员函数的话,实现非成员函数的最简便方式就是使用成员swap()函数来实现。然而,这样的成员函数并不是必要的。如果决定不使用成员版本的swap()函数,那么为了使得非成员版本的swap()能够访问私有成员,那么这个非成员函数必须声明为友元函数:

class C {
	friend void swap(C& rhs) noexcept;
};
void swap(C& lhs, C& rhs) noexcept {
	//... 具体的交换操作 ...
}

通过内联方式在定义友元swap()函数也是可行的:

class C {
	friend void swap(C& lhs, C& rhs) noexcept {
	//... 具体的交换操作 ...
	}
};

这种技巧对模板类的实现特别方便。在11章中,我们会进一步研究友元工厂模式。

另外,一个经常被以往的实现细节是对于自身交换的情况,如swap(x, x),或者类似这种成员函数的调用:x.swap(x)。这个行为是允许的,但是它做了什么呢?答案是它应当什么也不做。这种行为在C++03乃至C++11以后的标准中都不属于未定义行为,只是这种操作什么也不会发生,换言之,他不会改变对象的任何内容(尽管不改变内容,但是这种行为也是可能产生开销的)。一个用户定义的交换操作应当隐式地保证子交换行为的安全性,同时也应当显式地通过测试来验证其安全性。如果交换的操作通过拷贝或者移动进行,那么就应当注意到语言标准规范种要求拷贝赋值对自赋值情况的安全性。对于移动操作而言,尽管移动赋值可能会改变对象,但是必须要让对象保持一个合法的状态,称之为moved-from状态(在这个状态下,我们仍然可以继续给这个对象赋值)。

3.2 正确地使用swap

到目前为止,我们已经回顾了成员版本的swap()函数,非成员版本的swap()函数,以及显式调用std::swap()等操作。接下来我们就要讲讲使用过程中的规范。

首先,只要你知道swap()成员函数存在,那么使用它就是安全并且合适的。在编写模板代码时,常常会出现后面的这种要求:当我们面对某些具体类型时,我们通常了解它们所提供的接口。这就带来了一个问题,当我们调用非成员版本的swap()时,是否应该使用或者说添加std::作用域限定符?

设想一下如果我们这样做会如何:

namespace N {
class C {
public:
	void swap(C& rhs) noexcept;
};
void swap(C& lhs, C& rhs) noexcept { lhs.swap(rhs); }
}
//...
N::C c1(...), c2(...);
std::swap(c1, c2); // 调用 std::swap()
swap(c1, c2); // 调用 N::swap()

注意,ADL不会对已经添加作用域限定符的东西上面生效,这样一来,调用std::swap()时,依然调用的是STL库的<utility>头文件中的函数模板实例化。出于这个原因,我们建议永远不要显式地调用std::swap(),而是把那个重载通过using声明符引入到当前的作用域中,从而调用非限定的swap()函数:

using std::swap; // 使std::swap可见
swap(c1, c2); // 如果提供了N::swap(),则会调用之,否则调用std::swap()

不幸的是,在许多程序中我们能看到完全限定的std::swap()调用。为了防止出现这种代码,并保证我们自己实现的自定义swap总能被调用,我们可以按照如下的方式为自定义类型手动实例化一个std::swap()模板:

namespace std {
void swap(N::C& lhs, N::C& rhs) noexcept { lhs.swap(rhs); }
}

一般来讲,通过预留的std::命名空间来实现自定义的函数或类是不符合语言标准的。然而语言标准中,对于某些模板函数的显式特化则是例外(std::swap()就是其中之一)。只要这种特化被实现,对于std::swap()的调用就会实际上调用你所实现的特化版本。注意,这并不是特化std::swap()的充分理由,因为这样的特化不会参与ADL。如果此时没有提供非成员swap()函数,那么又会产生另一种问题:

using std::swap; // 使std::swap()可见
std::swap(c1, c2); // 调用我们的std::swap()重载
swap(c1, c2); // 调用默认的std::swap()

此时,非限定的swap()调用将会调用std::swap()的默认操作,就是那个采用移动构造和移动赋值行为的版本。为了在这种情况下能够正常处理swap()函数的调用,非成员版本的swap()函数和std::swap()的特化都必须被实现(当然也可以偷懒都指向同一个实现)。最后,我们需要注意的是,标准库允许我们通过模板实例化扩展std::命名空间。,但是不允许额外的模板重载。因此,如果我们面对的是一个类模板而非普通类,那么我们就无法对std::swap()进行特化。尽管这种代码可以通过编译,但是语言标准不保证我们想要的函数重载会被正确地选择(从技术上讲,这是一种未定义行为,且不提供任何保证)。因此,出于这种理由,我们应当避免直接调用std::swap()

总结

C++中的交换功能是用来实现许多设计模式的重要手段。最重要的一个就是在异常安全事务下的copy-and-swap手法。所有的STL容器,以及大部分标准库对象都提供了高效的成员swap()函数,并且极可能都不抛出异常。用户定义的类型应当遵循同样的设计规范。然而需要注意的是,实现一个非成员swap函数时通常需要额外的抽象以及特殊的优化手段。除了成员版本的swap函数,我们还回顾了非成员swap函数的实现方式。尽管std::swap()总是对可移动或者可拷贝对象有效,程序员也仍然应当注重实现非成员版本的swap函数。特别地,对于提供成员swap函数的类型而言,同时也应该实现一个非成员的swap重载以调用其成员函数版本。

最后,尽管对于使用非成员版本的swap时,倾向于不添加std::前缀,我们也应当了解到,对于后者的使用会产生隐式的函数模板实例化。

下一个章节,我将带领大家速览C++中最强大也最受欢迎的惯用法——C++中的资源管理手段(RAII)。

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值