swap是一個非常有趣的程序。它起初是作為STL的一部分引入C++的,而后就成為了異常安全編程的一個重要的支柱(參見條目29),同時對于可以自賦值的對象而言它還是一個常用的復制處理機制。由于swap如此神通廣大,那么以一個恰當的方式去實現它就顯得十分重要了,但是它舉足輕重的地位也決定了實現它并不是一件手到擒來的事情。在本條目中,我們就會針對swap函數展開探索,逐步掌握如何去駕馭它。
swap函數的功能是交換兩個對象的值。在默認情況下,交換工作是通過標準庫的swap算法完成的。它的標準實現方式就能精確地完成你所期望的工作:
namespace std {
template<typename T> // std::swap的標準實現
void swap(T& a, T& b) // 交換a與b的值
{
T temp(a);
a = b;
b = temp;
}
}
只要你的類型支持復制(通過拷貝構造函數和拷貝復制運算符),那么默認的swap實現就可以讓兩個該類型的對象互相交換,你不需要專門做任何工作來支持這一功能。
然而,你可能對默認的swap實現抱有諸多不滿。它會帶來3次對象復制工作:a復制到temp,b到a,temp到b。對于一些類型來說,這些復制操作并不都是必需的。對于這些類型來說,默認的swap會成為你程序的桎梏。
上述的那種類型大都符合下面的特征:它的主要成分是一個指針,這一指針會指向另一個類型,真實的數據包含在另一個類型中。對這一設計的一種常見的形式是“pimpl慣用法”(pointer to implementation,指向實現的指針,參見條目31)。舉例說明,Widget類可以使用這種設計模式:
class WidgetImpl { // 保存Widget的數據的類
public: // 細節不重要
...
private:
int a, b, c; // 可能會有很多數據
std::vector<double> v; // 復制它們的代價是很高的!
...
};
class Widget { // 使用pimpl慣用法的類
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) // 要復制一個Widget對象,只要
{ // 復制對應的WidgetImpl對象。
... // 關于operator=實現的一般信息
*pImpl = *(rhs.pImpl); // 參見條目10、11、12
...
}
...
private:
WidgetImpl *pImpl; // 指向包含當前Widget數據的對象
};
為了交換兩個Widget對象的值,我們所要做的僅僅是交換他們的pImpl指針,但是默認的swap算法是不可能知道這一切的,它不僅會復制三個Widget對象,同時也會復制三個Widget對象。這樣做效率太低了。
我們要做的是告訴std::swap當交換Widget時,執行的交換操作應當僅僅針對它們內部的pImpl指針。有一種精確的說法來描述這一方法:將Widget的std::swap特化。下面是基本的思想,盡管以這種方式不能通過編譯:
namespace std {
template<> // 在T為Widget時,
void swap<Widget>(Widget& a, // 這是std::swap的一個特化版本
Widget& b) // 這段代碼不能通過編譯
{
swap(a.pImpl, b.pImpl); // 要交換兩個Widget,
} // 只需要交換它們的pImpl指針
}
程序開端的“template<>”告訴我們這是std::swap的一個完全特化模板,函數名后面的“<Widget>”告訴我們當前的特化針對T是Widget的情況。換種說法,當一般的swap模板應用于Widget時,應當使用這一具體實現。一般情況下,我們沒有權限去改動std名字空間內部的內容,但是我們有權針對我們自己創建的類型(比如Widget)來完整地特化標準模板(就像swap)。這就是我們所要做的。
然而,就像我說過的,這段代碼是不能通過編譯的。這是因為它嘗試訪問a與b內部的pImpl指針,但是它們是私有的。我們可以將我們的特化函數聲明為友元,但這里的規則有些不同:這里要求我們讓Widget包含一個名為swap的公共成員函數,讓這個swap進行實際的交換工作,然后特化std:swap來調用這一成員函數。
class Widget { // 同上,
public: // 僅添加了一個swap成員函數
...
void swap(Widget& other)
{
using std::swap; // 本節后面會解釋為什么這樣聲明
swap(pImpl, other.pImpl); // 交換pImpl指針來交換Widget
}
...
};
namespace std {
template<> // 特化的std::swap (已修正)
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); // 要交換Widget,
} // 只要調用它們的swap成員函數
}
這樣的代碼不僅僅可以通過編譯,而且也與STL容器相協調,它不僅僅提供了公有的swap成員函數,而且還提供了特化的std::swap來調用這些成員函數。
然而,我們不難發現,Widget和WidgetImpl都是類模板,而不是類,似乎我們可以自定義WidgetImpl中保存的數據的類型:
template<typename T> class WidgetImpl { ... };
template<typename T> class Widget { ... };
將一個swap成員函數放入Widget中(如果需要,也可以是WidgetImpl)仍然十分簡單,但是我們對std::swap特化時將會遇到問題。下面是我們希望編寫的代碼:
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, Widget<T>& b)
// 錯誤!非法代碼
{ a.swap(b); }
}
這樣的代碼看上去完美無瑕,但是它是非法的。因為其中嘗試對一個函數模板(std::swap)進行不完全的特化,但是,盡管C++允許對類模板進行不完全特化,而函數模板就不行了。這一代碼不應通過編譯(盡管一些編譯器會錯誤的接受)。
當你期望對一個函數模板進行“不完全特化”時,通常的做法非常簡單,就是添加一個該函數的重載。代碼可能是下面的樣子:
namespace std {
template<typename T> // std::swap的一個重載
void swap(Widget<T>& a, Widget<T>& b)
// (注意swap后邊沒有<...>)
{ a.swap(b); } // 下文解釋了為什么這樣做不合法
}
一般情況下,重載函數模板是可以的,但是std是一個很特殊的名字空間,它的規則也是獨特的。對std中的模板進行完全特化是合法的,但是為std添加一個新的模板卻是不合法的(類或函數或其他一切都不可以)。std的內容是由C++標準化委員會一手確定的,我們無法修改他們所規定的任何形式,只能“望碼興嘆”。越軌的代碼似乎可以運行,但它們的行為卻是未定義的。如果你希望你的代碼擁有可預知的行為,你就不應該在std中添加新的內容。
那么應該怎么辦呢?我們仍然需要一種方法來讓其他人通過調用swap來訪問我們更加高效的特化版本。答案很簡單。我們仍然可以通過聲明一個非成員函數swap來調用成員函數swap實現,只要這個非成員函數不是std::swap的特化或者重載版本即可。比如說,如果我們所有與Widget相關的功能都在名字空間WidgetStuff中,那么代碼看上去應該是這樣:
namespace WidgetStuff {
... // 模板化的WidgetImpl,等等
template<typename T> // 同上,包括swap成員函數
class Widget { ... };
...
template<typename T> // 非成員函數swap
void swap(Widget<T>& a, Widget<T>& b)
// 不屬于std名字空間
{
a.swap(b);
}
}
現在,如果任意位置的代碼對兩個Widget對象調用了swap,C++的名字搜尋守則(更具體地說,就是所謂的參數依賴搜尋或Koenig搜尋)將會在WidgetStuff中查找具體到Widget的版本。這恰恰是我們需要的。
由于這種方法針對類或者類模板可以正常運行,所以看上去似乎我們應該在任何情況下都使用它。但是遺憾的是,我們還是要對于類的std::swap進行特化(稍后會交代理由),所以如果你想要在盡可能多的上下文中(你所需要的)調用具體到類的swap版本,你就需要在你的類所在的名字空間編寫一個非成員版本的swap,同時還需要一個std::swap的特化版本。
順便說一下,即使你沒有使用名字空間,上述內容仍然有效(也就是說,你仍需要一個非成員的swap去調用成員函數swap),但是為什么你要把所有的類、模板、函數、枚舉類型、enumerant、typedef的名字統統塞進全局名字空間里呢?如果你對編程規范有一點概念的話,都不會這樣做的。
到目前為止我所介紹的一切內容都是以swap的作者的角度展開的,但是以一個客戶的眼光來審視一下swap也是很有價值的。假設你正在編寫一個函數模板,這里你需要交換兩個對象的值:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
}
這里應該調用哪一個swap呢? std中存在一個通用版本,這是你所知道的;另外std中可能還有一個針對這一通用版本的特化版本,它可能存在也可能不存在;或者一個模板的版本,它可能存在也可能不存在,它是否在一個名字空間中也不能確定(但可以肯定不在std名字空間中)?此時你所希望的是,如果存在一個模板版本的話,就調用它;如果不存在,就返回調用std中的通用版本。以下是滿足這一要求的代碼:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // 確保std::swap在此函數中可用
...
swap(obj1, obj2); // 為類型T的對象調用最佳的swap
...
}
當編譯器看到對swap的調用時,它們會尋找恰當的swap來進行調用。C++的名字搜尋原則確保了在全局或T類型所在的名字空間中來查找所有的精確到T的swap。(舉例說,如果T是位于WidgetStuff名字空間中的Widget,那么編譯器將會使用參數依賴搜尋方式來查找WidgetStuff中的swap。)如果沒有精確到T的swap存在,那么編譯器將會使用std中的swap,多虧了using聲明可以使std::swap在本函數中可見。然而即使這樣,編譯器也更期望得到一個精確到T的std::swap的特化版本,而不是未確定類型的模板,因此如果std::swap特化為T版本,那么這一特化的版本將會得到使用。
因此,調用正確的swap十分簡單。你所需要關心的事僅僅是不去限制對它的調用,因為如果這樣做會使C++如何決定去調用函數的方式受到影響。舉例說,如果你用下面的方式調用了swap:
std::swap(obj1, obj2); // 調用swap的錯誤方法
你強迫編譯器僅僅去考慮std中的swap(包括所有的模板特化版本),這樣做就排除了得到一個位于其他位置的精確到T版本的swap的可能,即使它是更加合理的。然而,一些進入誤區的程序員還是會以這種方式限制swap的調用,這里你就可以看出,為你的類提供一個std::swap的完全特化版本是多么重要:對于那些使用不恰當的編碼風格寫出的代碼(這樣的代碼也存在于一些標準庫的實現當中,如果你感興趣可以自己編寫一些代碼,來幫助這樣的代碼盡可能的提高效率),精確到類的swap實現仍然有效。
此刻,我們已經介紹了默認的swap、成員swap、非成員swap、std::swap的特化版本,以及對swap的調用,現在讓我們來做一個總結。
首先,如果對你的類或者類模板使用默認的swap實現能夠得到可以接受的效率,你就不需要做任何事情。任何人想要交換你創建的類型的對象時,都會去調用默認的版本,此時可以正常工作。
其次,如果默認的swap實現并不夠高效(大多數情況下意味著你的類或模板正在運用pimpl慣用法),請按下面步驟進行:
1. 提供一個公用的swap成員函數,讓它可以高效的交換你的類型的兩個對象的值。理由將在后面列出,這個函數永遠不要拋出異常。
2. 在你的類或模板的同一個名字空間中提供一個非成員的swap。讓它調用你的swap成員函數。
3. 如果你正在編寫一個類(而不是類模板),要為你的類提供一個std::swap的特化版本。同樣讓它調用你的swap成員函數。
最后,如果你正在調用swap,要確保使用一條using聲明來使std::swap對你的函數可見,然后在調用swap時,不要做出任何名字空間的限制。
文中還有一處欠缺,那就是本文的標題中的敬告:不要讓swap的成員函數版本拋出異常。這是因為swap最重要的用途之一就是幫助類(或類模板)來提供異常安全的保證。條目29中詳細介紹了這一點,但是這一技術做出了“swap的成員函數版本永遠不會拋出異常”這一假設。這一約束僅僅應用于成員函數版本,非成員版本則不受這一限制。這是因為swap的默認版本基于拷貝構造和拷貝賦值,而在一般情況下,這兩種函數都可能拋出異常。因此,當你編寫一個自定義版本的swap時,在典型情況下你不僅要提供一條更高效的交換對象值的方式,同時你也要提供一個不拋出異常的版本。作為一條一般的守則,這兩條swap的特征是相輔相成的,因為高效的swap同時也基于內建數據類型的操作(諸如pimpl慣用法中使用的指針),同時內建數據類型的操作決不會拋出異常。
時刻牢記
l 在對你的類型使用std::swap時可能會造成效率低下時,可以提供一個swap成員函數。確保你的swap不要拋出異常。
l 如果你提供了一個swap的成員函數,那么同時要提供一個非成員函數swap來調用這一成員。對于類而言(而不是模板),還要提供一個std::swap的特化版本來調用swap成員函數。
l 在調用swap時,要為std::swap使用一條using聲明,然后在調用swap時,不要做出名字空間的限制。
l 對用戶自定義類型而言,提供std的完全特化版本不成問題,但是決不要嘗試在std中添加全新的內容。