簡體   English   中英

什么是復制和交換成語?

[英]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)

  1. 首先是自我分配測試。
    這個檢查有兩個目的:它是一種防止我們在自賦值時運行不必要代碼的簡單方法,它保護我們免受細微的錯誤(例如刪除數組只是為了嘗試和復制它)。 但在所有其他情況下,它只會減慢程序速度,並在代碼中充當噪音; 自賦值很少發生,所以大部分時間這個檢查都是浪費。
    如果操作員沒有它也能正常工作就更好了。

  2. 第二個是它只提供基本的異常保證。 如果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; }
  3. 代碼已擴展! 這就引出了第三個問題:代碼重復。

我們的賦值運算符有效地復制了我們已經在別處編寫的所有代碼,這是一件可怕的事情。

在我們的例子中,它的核心只有兩行(分配和復制),但是對於更復雜的資源,這個代碼膨脹可能會很麻煩。 我們應該努力不再重蹈覆轍。

(有人可能會想:如果正確管理一個資源需要這么多代碼,如果我的類管理多個資源怎么辦?
雖然這似乎是一個有效的問題,並且確實需要非平凡的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++11 怎么樣?

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);
}

函數fsfm的目的都是讓a具有b最初的狀態。 然而,有一個隱藏的問題:如果a.get_allocator() != b.get_allocator()會發生什么? 答案是:視情況而定。 讓我們寫AT = std::allocator_traits<A>

  • 如果AT::propagate_on_container_move_assignmentstd::true_type ,則fmb.get_allocator()的值重新分配a的分配器,否則它不會,並且a繼續使用其原始分配器。 在這種情況下,數據元素需要單獨交換,因為ab的存儲不兼容。

  • 如果AT::propagate_on_container_swapstd::true_type ,則fs以預期的方式交換數據和分配器。

  • 如果AT::propagate_on_container_swapstd::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.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM