[英]What is the copy-and-swap idiom?
什么是復制和交換習語,什么時候應該使用它? 它解決了哪些問題? C++11 有變化嗎?
有關的:
任何管理資源的類(包裝器,如智能指針)都需要實現三巨頭。 雖然復制構造函數和析構函數的目標和實現很簡單,但復制賦值運算符可以說是最微妙和最困難的。 應該怎么做? 需要避免哪些陷阱?
復制和交換習語是解決方案,它優雅地協助賦值運算符實現兩件事:避免代碼重復,並提供強大的異常保證。
從概念上講,它通過使用復制構造函數的功能來創建數據的本地副本,然后使用swap
函數獲取復制的數據,將舊數據與新數據交換。 然后臨時副本銷毀,同時帶走舊數據。 我們留下了新數據的副本。
為了使用復制和交換習語,我們需要三樣東西:一個有效的復制構造函數、一個有效的析構函數(兩者都是任何包裝器的基礎,所以無論如何都應該是完整的)和一個swap
函數。
交換函數是一個非拋出函數,它交換一個類的兩個對象,成員對成員。 我們可能會傾向於使用std::swap
而不是提供我們自己的,但這是不可能的; std::swap
在其實現中使用復制構造函數和復制賦值運算符,我們最終會嘗試根據自身定義賦值運算符!
(不僅如此,對swap
非限定調用將使用我們的自定義交換運算符,跳過std::swap
將需要的不必要的類構造和銷毀。)
讓我們考慮一個具體的案例。 我們想在一個其他無用的類中管理一個動態數組。 我們從一個有效的構造函數、復制構造函數和析構函數開始:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr)
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
這個類幾乎成功地管理了數組,但它需要operator=
才能正常工作。
下面是一個簡單的實現的樣子:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
我們說我們完成了; 這現在管理一個數組,沒有泄漏。 但是,它存在三個問題,在代碼中按順序標記為(n)
。
首先是自我分配測試。
這個檢查有兩個目的:它是一種防止我們在自賦值時運行不必要代碼的簡單方法,它保護我們免受細微的錯誤(例如刪除數組只是為了嘗試和復制它)。 但在所有其他情況下,它只會減慢程序速度,並在代碼中充當噪音; 自賦值很少發生,所以大部分時間這個檢查都是浪費。
如果操作員沒有它也能正常工作就更好了。
第二個是它只提供基本的異常保證。 如果new int[mSize]
失敗, *this
將被修改。 (即大小不對,數據沒了!)
對於強大的異常保證,它需要類似於:
dumb_array& operator=(const dumb_array& other) { if (this != &other) // (1) { // get the new data ready before we replace the old std::size_t newSize = other.mSize; int* newArray = newSize ? new int[newSize]() : nullptr; // (3) std::copy(other.mArray, other.mArray + newSize, newArray); // (3) // replace the old data (all are non-throwing) delete [] mArray; mSize = newSize; mArray = newArray; } return *this; }
代碼已擴展! 這就引出了第三個問題:代碼重復。
我們的賦值運算符有效地復制了我們已經在別處編寫的所有代碼,這是一件可怕的事情。
在我們的例子中,它的核心只有兩行(分配和復制),但是對於更復雜的資源,這個代碼膨脹可能會很麻煩。 我們應該努力不再重蹈覆轍。
(有人可能會想:如果正確管理一個資源需要這么多代碼,如果我的類管理多個資源怎么辦?
雖然這似乎是一個有效的問題,並且確實需要非平凡的try
/ catch
子句,但這不是問題。
那是因為一個類應該只管理一個資源!)
如前所述,復制和交換習語將解決所有這些問題。 但是現在,我們有所有的要求,除了一個: swap
函數。 雖然三定律成功地包含了我們的復制構造函數、賦值運算符和析構函數,但它確實應該被稱為“三巨頭”:任何時候你的類管理一個資源,提供swap
也是有意義的功能。
我們需要為我們的類添加交換功能,我們這樣做如下†:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
( 這里解釋了為什么public friend swap
。)現在我們不僅可以交換我們的dumb_array
,而且交換通常會更有效; 它只是交換指針和大小,而不是分配和復制整個數組。 除了功能和效率方面的這一優勢外,我們現在准備實施復制和交換習語。
閑話少說,我們的賦值運算符是:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
就是這樣! 一舉一動,所有三個問題都得到了優雅的解決。
我們首先注意到一個重要的選擇:參數參數是按值取的。 雖然人們可以很容易地執行以下操作(實際上,該習語的許多幼稚實現都是這樣做的):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
我們失去了一個重要的優化機會。 不僅如此,這個選擇在 C++11 中也很關鍵,后面會討論。 (一般來說,一個非常有用的指導方針如下:如果您要在函數中復制某些內容,請讓編譯器在參數列表中進行復制。‡)
無論哪種方式,這種獲取資源的方法都是消除代碼重復的關鍵:我們可以使用復制構造函數中的代碼進行復制,而無需重復任何部分。 現在副本已經制作完成,我們准備好交換了。
觀察到,在進入函數時,所有新數據都已分配、復制並准備好使用。 這就是免費為我們提供強大異常保證的原因:如果復制的構造失敗,我們甚至不會進入該函數,因此不可能更改*this
的狀態。 (我們之前手動為強大的異常保證所做的,編譯器現在正在為我們做;怎么樣。)
在這一點上,我們是無家可歸的,因為swap
是非拋出的。 我們用復制的數據交換當前數據,安全地改變我們的狀態,舊數據被放入臨時數據。 然后在函數返回時釋放舊數據。 (在參數的范圍結束並調用其析構函數時。)
因為習語沒有重復代碼,所以我們不能在操作符中引入錯誤。 請注意,這意味着我們不再需要自賦值檢查,從而允許operator=
的單一統一實現。 (此外,我們不再對非自我分配有性能損失。)
這就是復制和交換習語。
C++ 的下一個版本,C++11,對我們管理資源的方式進行了一個非常重要的改變:三的規則現在是四的規則(半)。 為什么? 因為我們不僅需要能夠復制構造我們的資源, 我們還需要移動構造它。
幸運的是,這很容易:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
這里發生了什么? 回想一下移動構造的目標:從類的另一個實例中獲取資源,使其處於保證可分配和可破壞的狀態。
所以我們所做的很簡單:通過默認構造函數(C++11 特性)進行初始化,然后與other
交換; 我們知道我們類的默認構造實例可以安全地分配和銷毀,因此我們知道other
將能夠在交換后執行相同的操作。
(請注意,有些編譯器不支持構造函數委托;在這種情況下,我們必須手動默認構造類。這是一項不幸但幸運的微不足道的任務。)
這是我們需要對類進行的唯一更改,那么它為什么會起作用呢? 記住我們做出的讓參數成為值而不是引用的重要決定:
dumb_array& operator=(dumb_array other); // (1)
現在,如果other
用右值初始化,它將是 move-constructed 。 完美的。 與 C++03 讓我們通過按值獲取參數來重用我們的復制構造函數功能一樣,C++11 也會在適當的時候自動選擇移動構造函數。 (當然,正如之前鏈接的文章中提到的,值的復制/移動可能會被完全省略。)
復制和交換習語到此結束。
*為什么我們將mArray
設置為 null? 因為如果運算符中的任何其他代碼拋出,則可能會調用dumb_array
的析構函數; 如果發生這種情況而未將其設置為 null,我們將嘗試刪除已刪除的內存! 我們通過將其設置為 null 來避免這種情況,因為刪除 null 是一種無操作。
†還有其他說法,我們應該為我們的類型專門化std::swap
,提供類內swap
以及自由函數swap
等。但這都是不必要的:任何正確使用swap
都將通過不合格的調用,我們的函數將通過ADL找到。 一個功能就行。
‡原因很簡單:一旦您擁有自己的資源,您就可以將其交換和/或移動 (C++11) 到任何需要的地方。 通過在參數列表中進行復制,您可以最大限度地優化。
††移動構造函數通常應該是noexcept
,否則noexcept
移動noexcept
,某些代碼(例如std::vector
調整大小邏輯)也會使用復制構造函數。 當然,只有在里面的代碼沒有拋出異常的情況下才標記為noexcept。
賦值的核心是兩個步驟:拆除對象的舊狀態並構建其新狀態作為其他對象狀態的副本。
基本上,這就是析構函數和復制構造函數所做的,所以第一個想法是將工作委托給它們。 但是,由於破壞不能失敗,而構建可能會失敗,我們實際上想以相反的方式進行:首先執行建設性部分,如果成功,則執行破壞性部分。 copy-and-swap 習慣用法就是這樣做的:它首先調用類的復制構造函數來創建一個臨時對象,然后將其數據與臨時對象交換,然后讓臨時對象的析構函數破壞舊狀態。
由於swap()
應該永遠不會失敗,唯一可能失敗的部分是復制構造。 這是首先執行的,如果失敗,則目標對象中的任何內容都不會更改。
在其改進的形式中,復制和交換是通過初始化賦值運算符的(非引用)參數來執行復制來實現的:
T& operator=(T tmp)
{
this->swap(tmp);
return *this;
}
已經有一些很好的答案。 我將主要關注我認為他們缺乏的東西 - 用復制和交換習語解釋“缺點”......
什么是復制和交換習語?
一種根據交換函數實現賦值運算符的方法:
X& operator=(X rhs)
{
swap(rhs);
return *this;
}
基本思想是:
分配給對象最容易出錯的部分是確保獲取新狀態所需的任何資源(例如內存、描述符)
如果創建了新值的副本,則可以在修改對象的當前狀態(即*this
)之前嘗試該獲取,這就是為什么rhs
通過值(即復制)而不是通過引用接受
交換本地副本rhs
和*this
的狀態通常相對容易,沒有潛在的失敗/異常,因為本地副本之后不需要任何特定狀態(只需要適合析構函數運行的狀態,就像一個從 >= C++11 中移動的對象)
應該什么時候使用? (它解決了哪些問題[/create] ?)
當您希望被賦值對象不受引發異常的賦值的影響時,假設您有或可以編寫具有強異常保證的swap
,並且理想情況下不能失敗/ throw
..†
當您想要一種干凈、易於理解、健壯的方式來根據(更簡單的)復制構造函數、 swap
函數和析構函數定義賦值運算符時。
† swap
拋出:通常可以可靠地交換對象通過指針跟蹤的數據成員,但沒有無拋出交換的非指針數據成員,或者交換必須實現為X tmp = lhs; lhs = rhs; rhs = tmp;
X tmp = lhs; lhs = rhs; rhs = tmp;
並且復制構造或賦值可能會拋出,仍然有可能失敗,使某些數據成員被交換而其他數據成員則不會。 這種潛力甚至適用於 C++03 std::string
,正如 James 對另一個答案的評論:
@wilhelmtell:在 C++03 中,沒有提到 std::string::swap(由 std::swap 調用)可能拋出的異常。 在 C++0x 中,std::string::swap 是 noexcept 並且不能拋出異常。 – 詹姆斯·麥克內利斯 10 年 12 月 22 日 15:24
‡ 賦值運算符實現在從不同對象賦值時看起來很合理,但很容易因自賦值而失敗。 雖然客戶端代碼甚至會嘗試自賦值似乎是不可想象的,但在容器上的算法操作期間,它可以相對容易地發生, x = f(x);
其中f
是(可能僅適用於某些#ifdef
分支)宏 ala #define f(x) x
或返回對x
的引用的x
,或什至(可能效率低下但簡潔)像x = c1 ? x * 2 : c2 ? x / 2 : x;
這樣的代碼x = c1 ? x * 2 : c2 ? x / 2 : x;
x = c1 ? x * 2 : c2 ? x / 2 : x;
)。 例如:
struct X
{
T* p_;
size_t size_;
X& operator=(const X& rhs)
{
delete[] p_; // OUCH!
p_ = new T[size_ = rhs.size_];
std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
}
...
};
在自賦值上,上面的代碼刪除了x.p_;
, 將p_
指向新分配的堆區域,然后嘗試讀取其中的未初始化數據(未定義行為),如果這不會做任何太奇怪的事情,則copy
嘗試對每個剛剛銷毀的 'T' 進行自賦值!
⁂ 由於使用了額外的臨時變量(當操作符的參數是復制構造的),copy-and-swap 習慣用法可能會導致效率低下或限制:
struct Client
{
IP_Address ip_address_;
int socket_;
X(const X& rhs)
: ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
{ }
};
在這里,一個手寫的Client::operator=
可能會檢查*this
是否已經連接到與rhs
相同的服務器(如果有用,可能會發送“重置”代碼),而復制和交換方法會調用復制-構造函數可能會被編寫為打開一個不同的套接字連接,然后關閉原來的連接。 這不僅意味着遠程網絡交互而不是簡單的進程內變量復制,它還可能違反客戶端或服務器對套接字資源或連接的限制。 (當然這個類有一個非常可怕的界面,但那是另一回事;-P)。
這個答案更像是對上述答案的補充和輕微修改。
在 Visual Studio 的某些版本(可能還有其他編譯器)中,存在一個非常煩人且沒有意義的錯誤。 因此,如果您像這樣聲明/定義您的swap
函數:
friend void swap(A& first, A& second) {
std::swap(first.size, second.size);
std::swap(first.arr, second.arr);
}
...當你調用swap
函數時,編譯器會對你大喊大叫:
這與調用friend
函數以及將this
對象作為參數傳遞有關。
解決這個問題的一種方法是不使用friend
關鍵字並重新定義swap
函數:
void swap(A& other) {
std::swap(size, other.size);
std::swap(arr, other.arr);
}
這一次,您可以調用swap
並傳入other
,從而使編譯器滿意:
畢竟,您不需要使用friend
函數來交換 2 個對象。 讓swap
成為一個以other
對象作為參數的成員函數同樣有意義。
您已經有權訪問this
對象,因此將其作為參數傳入在技術上是多余的。
當您處理 C++11-style allocator-aware 容器時,我想添加一個警告。 交換和賦值的語義略有不同。
為了具體起見,讓我們考慮一個容器std::vector<T, A>
,其中A
是某種有狀態分配器類型,我們將比較以下函數:
void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{
a.swap(b);
b.clear(); // not important what you do with b
}
void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
a = std::move(b);
}
函數fs
和fm
的目的都是讓a
具有b
最初的狀態。 然而,有一個隱藏的問題:如果a.get_allocator() != b.get_allocator()
會發生什么? 答案是:視情況而定。 讓我們寫AT = std::allocator_traits<A>
。
如果AT::propagate_on_container_move_assignment
是std::true_type
,則fm
用b.get_allocator()
的值重新分配a
的分配器,否則它不會,並且a
繼續使用其原始分配器。 在這種情況下,數據元素需要單獨交換,因為a
和b
的存儲不兼容。
如果AT::propagate_on_container_swap
是std::true_type
,則fs
以預期的方式交換數據和分配器。
如果AT::propagate_on_container_swap
是std::false_type
,那么我們需要動態檢查。
a.get_allocator() == b.get_allocator()
,則兩個容器使用兼容的存儲,並以通常的方式進行交換。a.get_allocator() != b.get_allocator()
,則程序具有未定義的行為(參見 [container.requirements.general/8]。結果是,一旦您的容器開始支持有狀態分配器,交換就成為 C++11 中的一項重要操作。 這是一個有點“高級用例”,但並非完全不可能,因為移動優化通常只有在您的類管理資源時才會變得有趣,而內存是最受歡迎的資源之一。
這個 C++11 實現復制和交換習語是錯誤的,因為在存在 move assign 的情況下,使用右值調用時會出現歧義錯誤。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.