天天看點

C++設計模式由淺入深(四)——swap抽絲剝繭

四、深入淺出剖析

swap

1 STL與

swap

swap

操作被廣泛應用與C++标準庫中。所有的标準模闆庫(STL)容器都提供了

swap

函數,并且同時支援非成員版本的函數模闆

std::swap

swap

在STL的算法中也有廣泛應用。标準庫也是常常被用來實作與之類似自定義功能的模闆庫。是以,我們将會開始學習swap操作并且仔細研究标準庫提供的函數細節。

1.1

swap

與STL容器

從概念上講,

swap

所做的行為就如同下面所示的操作:

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

當我們調用

swap()

時,

x

y

對象中的内容就被交換了。然而,這種實作隻是最糟糕的一種實作方式。這個實作中最顯著的問題是發生了不必要的對象拷貝(實際上過程中發生了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)

對配置設定器進行交換(在下一節中會介紹這到底發生了什麼)。如果這個

value

true

,那麼這些配置設定器将不會被交換,并且這兩個容器必須使用完全一緻的配置設定器。如果連這個條件也無法滿足,那麼這種情況就是未定義行為。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::vector

swap

操作不會抛出異常,那麼代碼執行到最後,整個函數就能保證成功,并且結果會被正确地傳回給調用者。那麼輸出數組中原先存在的内容怎麼辦呢?這些内容現在被臨時數組變量所擁有,并且即将在下一個大括号處被(隐式地)析構。假設

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!
           

注意這些舊的變量名,如

vec

vec1

,在新的資料被計算出來後仍然可通路。是以在後續的代碼中,舊更有可能發生變量選擇的錯誤,即當我們想要用

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()

函數調用中

c1

c2

變量所攜帶的

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)。