c++ rule of three




什麼是複制和交換成語? (4)

概觀

為什麼我們需要復制和交換成語?

任何管理資源的類( 包裝器 ,像智能指針)都需要實現三巨頭 。 雖然複製構造函數和析構函數的目標和實現很簡單,但是複制賦值運算符可以說是最細微和困難的。 應該怎麼做? 需要避免哪些陷阱?

複製和交換成語是解決方案,並且優雅地協助賦值運算符實現兩件事情:避免代碼重複 ,並提供強大的異常保證

它是如何工作的?

Conceptually ,它通過使用複制構造函數來創建數據的本地副本,然後使用swap功能獲取複製的數據,並使用新數據交換舊數據。 然後臨時副本破壞,將舊數據與它一起取出。 我們剩下一份新的數據。

為了使用copy-and-swap成語,我們需要三件事情:一個工作拷貝構造函數,一個工作析構函數(兩者都是任何包裝的基礎,所以應該是完整的)和swap函數。

交換函數是一個非拋出函數,用於交換類成員中的兩個對象。 我們可能會試圖使用std::swap而不是提供自己的,但這是不可能的; std::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子句,但這是這是一個非問題,因為一個類只能管理一個資源 !)

成功的解決方案

如前所述,copy-and-swap成語將解決所有這些問題。 但現在,除了一個要求外,我們擁有所有的要求: 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);
    }

    // ...
};

Herepublic 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)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

這裡發生了什麼? 回想一下移動建設的目標:從班級的另一個實例中獲取資源,使其保持可保證可分配和可破壞的狀態。

所以我們做的很簡單:通過默認構造函數(C ++ 11特性)初始化,然後與other交換; 我們知道我們類的默認構造實例可以安全地分配和銷毀,所以我們知道other人可以在交換後執行相同的操作。

(請注意,有些編譯器不支持構造函數委託;在這種情況下,我們必須手動默認構造類,這是一個不幸但幸運的小任務。)

為什麼這個工作?

這是我們需要對班級做出的唯一改變,為什麼它會起作用? 請記住我們為使參數成為一個值而不是參考而做出的至關重要的決定:

dumb_array& operator=(dumb_array other); // (1)

現在,如果other正在用右值初始化, 它將被移動構建 。 完善。 以同樣的方式,C ++ 03讓我們通過獲取參數的值來重新使用我們的拷貝構造函數,C ++ 11在適當的時候也會自動選擇移動構造函數。 (當然,正如前面鏈接文章中提到的那樣,價值的複制/移動可能完全消失。)

這樣就完成了複製和交換的習慣用法。

腳註

*為什麼我們將mArray設置為null? 因為如果運算符中的任何其他代碼都拋出,可能會調用dumb_array的析構函數; 如果發生這種情況而沒有將其設置為空,我們嘗試刪除已被刪除的內存! 我們通過將其設置為空來避免這種情況,因為刪除null是無效操作。

†還有其他一些聲明,我們應該為我們的類型專門開發std::swap ,提供一個在自由函數swap等方面的類內swap 。但是這是不必要的:任何正確使用swap將通過一個不合格的調用,我們的功能將通過ADL找到。 一個功能就可以做到。

‡原因很簡單:一旦你擁有了自己的資源,你可以在任何需要的地方交換和/或移動它(C ++ 11)。 通過在參數列表中復制副本,可以最大限度地優化。

什麼是這個成語,什麼時候應該使用它? 它解決了哪些問題? 當使用C ++ 11時,習語是否會發生變化?

儘管在很多地方都有提到,但我們沒有任何單數的“問題”和答案,所以在這裡。 這是以前提到的地方的部分列表:


分配的核心是兩個步驟: 拆除對象的舊狀態並將其新狀態構建為其他對象狀態的副本

基本上,這就是析構函數復制構造函數的作用,所以第一個想法是將工作委派給他們。 然而,既然破壞不會失敗,而建設可能會實現, 我們實際上想要做到這一點先執行建設性部分 ,如果成功, 那麼做破壞性部分 。 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的狀態, *this因為本地副本之後不需要任何特定狀態( 通常需要狀態適合析構函數運行,所以通常在沒有潛在故障/異常的情況下相對容易)對像從> = C ++中移動11)

什麼時候應該使用它? (它解決了哪些問題[/創建] ?)

  • 當你想讓分配的對像不受引發異常的賦值的影響時,假設你已經或者可以編寫一個具有強大異常保證的swap ,並且理想情況下不會失敗/ throw一個swap 。†

  • 當你想要一個乾淨,容易理解,強大的方式來定義賦值運算符的(簡單)複製構造函數, 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,不能拋出異常。 - 詹姆斯McNellis 12年10月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; )。 例如:

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由於使用額外的臨時(當操作員的參數是複制構造的時候),複製和交換成語會引入效率低下或限制:

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相同的服務器(如果有用,可能會發送一個“重置”代碼),而復制和交換方法會調用copy-構造函數可能會被寫入以打開一個獨特的套接字連接,然後關閉原始的連接。 這不僅意味著遠程網絡交互而代表簡單的進程內變量副本,它可能與客戶端或服務器對套接字資源或連接的限制相違背。 (當然這個類有一個非常可怕的界面,但這是另一回事; -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對象,因此將它作為參數傳遞在技術上是多餘的。







copy-and-swap