簡體   English   中英

什么是三法則?

[英]What is The Rule of Three?

  • 復制一個object是什么意思?
  • 什么是復制構造函數復制賦值運算符
  • 我什么時候需要自己申報?
  • 如何防止我的對象被復制?

介紹

C++ 使用值語義處理用戶定義類型的變量。 這意味着對象在各種上下文中被隱式復制,我們應該了解“復制對象”的實際含義。

讓我們考慮一個簡單的例子:

class person
{
    std::string name;
    int age;

public:

    person(const std::string& name, int age) : name(name), age(age)
    {
    }
};

int main()
{
    person a("Bjarne Stroustrup", 60);
    person b(a);   // What happens here?
    b = a;         // And here?
}

(如果你對name(name), age(age)部分感到困惑,這被稱為成員初始化列表。)

特殊成員函數

復制一個person object是什么意思? main的 function 顯示了兩種不同的復制場景。 初始化person b(a); 復制構造函數執行。 它的工作是在現有 object 的 state 的基礎上構建一個新的 object。 賦值b = a復制賦值運算符執行。 它的工作通常稍微復雜一些,因為目標 object 已經在一些需要處理的有效 state 中。

由於我們自己既沒有聲明復制構造函數也沒有聲明賦值運算符(也沒有析構函數),所以這些都是為我們隱式定義的。 引用標准:

[...] 復制構造函數和復制賦值運算符,[...] 和析構函數是特殊的成員函數。 [注意當程序沒有顯式聲明它們時,實現將為某些 class 類型隱式聲明這些成員函數。 如果使用它們,實現將隱式定義它們。 [...]結束注] [n3126.pdf 第 12 節 §1]

默認情況下,復制 object 意味着復制其成員:

非聯合 class X 的隱式定義的復制構造函數執行其子對象的成員復制。 [n3126.pdf 第 12.8 節 §16]

非聯合 class X 的隱式定義復制賦值運算符對其子對象執行按成員復制賦值。 [n3126.pdf 第 12.8 節 §30]

隱式定義

person隱式定義的特殊成員函數如下所示:

// 1. copy constructor
person(const person& that) : name(that.name), age(that.age)
{
}

// 2. copy assignment operator
person& operator=(const person& that)
{
    name = that.name;
    age = that.age;
    return *this;
}

// 3. destructor
~person()
{
}

在這種情況下,按成員復制正是我們想要的:復制nameage ,因此我們得到一個獨立的、獨立的person object。 隱式定義的析構函數始終為空。 在這種情況下這也很好,因為我們沒有在構造函數中獲取任何資源。 成員的析構函數在person析構函數完成后被隱式調用:

在執行析構函數的主體並銷毀主體內分配的任何自動對象后,class X 的析構函數調用 X 的直接 [...] 成員的析構函數 [n3126.pdf 12.4 §6]

管理資源

那么我們什么時候應該顯式聲明這些特殊的成員函數呢? 當我們的 class管理一個資源時,即當 class 的一個 object負責該資源時。 這通常意味着資源在構造函數中獲取(或傳遞給構造函數)並在析構函數中釋放

讓我們 go 及時回到標准前的 C++。 沒有std::string這樣的東西,程序員愛上了指針。 class 這個person可能看起來像這樣:

class person
{
    char* name;
    int age;

public:

    // the constructor acquires a resource:
    // in this case, dynamic memory obtained via new[]
    person(const char* the_name, int the_age)
    {
        name = new char[strlen(the_name) + 1];
        strcpy(name, the_name);
        age = the_age;
    }

    // the destructor must release this resource via delete[]
    ~person()
    {
        delete[] name;
    }
};

即使在今天,人們仍然用這種風格編寫類並遇到麻煩:“我將一個人推入向量中,現在我發瘋了 memory 錯誤”請記住,默認情況下,復制 object 意味着復制其成員,但僅復制name成員復制一個指針,而不是它指向的字符數組:這有幾個不愉快的效果

  1. 通過a變化可以通過b觀察到。
  2. 一旦b被銷毀, a.name就是一個懸空指針。
  3. 如果a被銷毀,則刪除懸空指針會產生未定義的行為
  4. 由於分配沒有考慮分配之前指向的name ,因此您遲早會得到 memory 到處都是泄漏。

顯式定義

由於按成員復制沒有達到預期的效果,我們必須顯式定義復制構造函數和復制賦值運算符來對字符數組進行深度復制:

// 1. copy constructor
person(const person& that)
{
    name = new char[strlen(that.name) + 1];
    strcpy(name, that.name);
    age = that.age;
}

// 2. copy assignment operator
person& operator=(const person& that)
{
    if (this != &that)
    {
        delete[] name;
        // This is a dangerous point in the flow of execution!
        // We have temporarily invalidated the class invariants,
        // and the next statement might throw an exception,
        // leaving the object in an invalid state :(
        name = new char[strlen(that.name) + 1];
        strcpy(name, that.name);
        age = that.age;
    }
    return *this;
}

注意初始化和賦值之間的區別:我們必須在賦值之前拆除舊的name以防止 memory 泄漏。 此外,我們必須防止x = x形式的自賦值。 如果沒有該檢查, delete[] name將刪除包含字符串的數組,因為當您編寫x = x時, this->namethat.name都包含相同的指針。

異常安全

不幸的是,如果new char[...]由於 memory 耗盡而引發異常,此解決方案將失敗。 一種可能的解決方案是引入局部變量並重新排序語句:

// 2. copy assignment operator
person& operator=(const person& that)
{
    char* local_name = new char[strlen(that.name) + 1];
    // If the above statement throws,
    // the object is still in the same state as before.
    // None of the following statements will throw an exception :)
    strcpy(local_name, that.name);
    delete[] name;
    name = local_name;
    age = that.age;
    return *this;
}

這也可以在沒有明確檢查的情況下處理自分配。 這個問題的一個更強大的解決方案是copy-and-swap idiom ,但我不會 go 在這里詳細介紹異常安全。 我只提到例外是為了說明以下幾點:編寫管理資源的類很困難。

不可復制的資源

某些資源不能或不應該被復制,例如文件句柄或互斥體。 在這種情況下,只需將復制構造函數和復制賦值運算符聲明為private而不給出定義:

private:

    person(const person& that);
    person& operator=(const person& that);

或者,您可以從boost::noncopyable繼承或將它們聲明為已刪除(在 C++11 及更高版本中):

person(const person& that) = delete;
person& operator=(const person& that) = delete;

三分法則

有時您需要實現一個管理資源的 class。 (永遠不要在一個 class 中管理多個資源,這只會導致痛苦。)在這種情況下,請記住三原則

如果您需要自己顯式聲明析構函數、復制構造函數或復制賦值運算符,您可能需要顯式聲明所有這三個。

(不幸的是,C++ 標准或我知道的任何編譯器都沒有強制執行此“規則”。)

五分法則

從 C++11 開始,object 有 2 個額外的特殊成員函數:移動構造函數和移動賦值。 實現這些功能的五國規則也是如此。

帶有簽名的示例:

class person
{
    std::string name;
    int age;

public:
    person(const std::string& name, int age);        // Ctor
    person(const person &) = default;                // 1/5: Copy Ctor
    person(person &&) noexcept = default;            // 4/5: Move Ctor
    person& operator=(const person &) = default;     // 2/5: Copy Assignment
    person& operator=(person &&) noexcept = default; // 5/5: Move Assignment
    ~person() noexcept = default;                    // 3/5: Dtor
};

零法則

3/5 規則也稱為 0/3/5 規則。 規則的零部分規定在創建 class 時,您可以不編寫任何特殊成員函數。

建議

大多數時候,您不需要自己管理資源,因為現有的 class(例如std::string )已經為您完成了。 只需將使用std::string成員的簡單代碼與使用char*的復雜且容易出錯的替代代碼進行比較,您就會被說服。 只要您遠離原始指針成員,三法則就不太可能涉及您自己的代碼。

法則是 C++ 的經驗法則,基本上是說

如果您的 class 需要任何

  • 一個復制構造函數
  • 賦值運算符
  • 析構函數

明確定義,那么很可能需要這三個

原因是它們三個通常都用於管理資源,如果您的 class 管理資源,則通常需要管理復制和釋放。

如果復制 class 管理的資源沒有良好的語義,則考慮通過將復制構造函數和賦值運算符聲明( 未定義)為private來禁止復制。

(請注意,即將發布的新版 C++ 標准(即 C++11)將移動語義添加到 C++,這可能會改變三規則。但是,我對此知之甚少,無法編寫有關規則的 Z657433F6E884DAA5066EDZF 部分三個。)

三巨頭的法則如前所述。

一個簡單的例子,用簡單的英語,它解決了什么樣的問題:

非默認析構函數

您在構造函數中分配了 memory ,因此您需要編寫一個析構函數來刪除它。 否則會導致 memory 泄漏。

你可能會認為這已經完成了。

問題是,如果您的 object 復制了一個副本,那么該副本將指向與原始 object 相同的 memory。

有一次,其中一個在其析構函數中刪除 memory,另一個將有一個指向無效 memory 的指針(這稱為懸空指針),當它嘗試使用它時,事情會變得棘手。

因此,您編寫了一個復制構造函數,以便它為新對象分配自己的 memory 塊來銷毀。

賦值運算符和復制構造函數

您在構造函數中將 memory 分配給 class 的成員指針。 當您復制此 class 的 object 時,默認賦值運算符和復制構造函數將將此成員指針的值復制到新的 object。

This means that the new object and the old object will be pointing at the same piece of memory so when you change it in one object it will be changed for the other objerct too. 如果一個 object 刪除這個 memory 另一個將繼續嘗試使用它 - eek。

為了解決這個問題,您編寫自己版本的復制構造函數和賦值運算符。 您的版本將單獨的 memory 分配給新對象,並復制第一個指針指向的值而不是其地址。

基本上,如果您有一個析構函數(不是默認析構函數),則意味着您定義的 class 具有一些 memory 分配。 假設某些客戶端代碼或您在外部使用 class。

    MyClass x(a, b);
    MyClass y(c, d);
    x = y; // This is a shallow copy if assignment operator is not provided

如果 MyClass 只有一些原始類型的成員,則默認賦值運算符會起作用,但如果它有一些指針成員和沒有賦值運算符的對象,則結果將是不可預測的。 因此,我們可以說,如果在 class 的析構函數中有要刪除的內容,我們可能需要一個深拷貝運算符,這意味着我們應該提供一個拷貝構造函數和賦值運算符。

復制 object 是什么意思? 有幾種方法可以復制對象——讓我們談談你最有可能提到的兩種——深拷貝和淺拷貝。

由於我們使用的是面向對象的語言(或者至少假設如此),假設您分配了一塊 memory。 由於它是一種面向對象語言,我們可以很容易地引用我們分配的 memory 塊,因為它們通常是原始變量(整數、字符、字節)或我們定義的由我們自己的類型和原語組成的類。 所以假設我們有一個汽車的 class 如下:

class Car //A very simple class just to demonstrate what these definitions mean.
//It's pseudocode C++/Javaish, I assume strings do not need to be allocated.
{
private String sPrintColor;
private String sModel;
private String sMake;

public changePaint(String newColor)
{
   this.sPrintColor = newColor;
}

public Car(String model, String make, String color) //Constructor
{
   this.sPrintColor = color;
   this.sModel = model;
   this.sMake = make;
}

public ~Car() //Destructor
{
//Because we did not create any custom types, we aren't adding more code.
//Anytime your object goes out of scope / program collects garbage / etc. this guy gets called + all other related destructors.
//Since we did not use anything but strings, we have nothing additional to handle.
//The assumption is being made that the 3 strings will be handled by string's destructor and that it is being called automatically--if this were not the case you would need to do it here.
}

public Car(const Car &other) // Copy Constructor
{
   this.sPrintColor = other.sPrintColor;
   this.sModel = other.sModel;
   this.sMake = other.sMake;
}
public Car &operator =(const Car &other) // Assignment Operator
{
   if(this != &other)
   {
      this.sPrintColor = other.sPrintColor;
      this.sModel = other.sModel;
      this.sMake = other.sMake;
   }
   return *this;
}

}

深拷貝是如果我們聲明一個 object,然后創建一個完全獨立的 object 副本......我們最終會在 2 個完整的 memory 集合中得到 2 個對象。

Car car1 = new Car("mustang", "ford", "red");
Car car2 = car1; //Call the copy constructor
car2.changePaint("green");
//car2 is now green but car1 is still red.

現在讓我們做一些奇怪的事情。 假設 car2 要么編程錯誤,要么故意共享由 car1 構成的實際 memory。 (這樣做通常是錯誤的,並且在課堂上通常是它在下面討論的毯子。)假裝每當你詢問 car2 時,你真的在​​解析指向 car1 的 memory 空間的指針......這或多或少是一個淺拷貝是。

//Shallow copy example
//Assume we're in C++ because it's standard behavior is to shallow copy objects if you do not have a constructor written for an operation.
//Now let's assume I do not have any code for the assignment or copy operations like I do above...with those now gone, C++ will use the default.

 Car car1 = new Car("ford", "mustang", "red"); 
 Car car2 = car1; 
 car2.changePaint("green");//car1 is also now green 
 delete car2;/*I get rid of my car which is also really your car...I told C++ to resolve 
 the address of where car2 exists and delete the memory...which is also
 the memory associated with your car.*/
 car1.changePaint("red");/*program will likely crash because this area is
 no longer allocated to the program.*/

因此,無論您使用哪種語言編寫,在復制對象時都要非常小心您的意思,因為大多數時候您想要一個深層副本。

什么是復制構造函數和復制賦值運算符? 我已經在上面使用過它們。 當您鍵入諸如Car car2 = car1;類的代碼時,將調用復制構造函數。 本質上,如果您聲明一個變量並在一行中分配它,那就是調用復制構造函數的時候。 賦值運算符是使用等號時發生的情況—— car2 = car1; . 注意car2沒有在同一個語句中聲明。 您為這些操作編寫的兩段代碼可能非常相似。 事實上,典型的設計模式還有另一個 function,一旦您對初始復制/分配是合法的感到滿意,您就可以調用它來設置所有內容——如果您查看我編寫的速記代碼,功能幾乎相同。

我什么時候需要自己申報? 如果您不以某種方式編寫要共享或用於生產的代碼,那么您實際上只需要在需要它們時聲明它們。 如果您“偶然”選擇使用程序語言並且沒有使用它,您確實需要了解您的程序語言會做什么 - 即您獲得編譯器默認值。 例如,我很少使用復制構造函數,但賦值運算符覆蓋很常見。 您是否知道您也可以覆蓋加法、減法等的含義?

如何防止我的對象被復制? 用私有 function 覆蓋您被允許為 object 分配 memory 的所有方式是一個合理的開始。 如果您真的不希望人們復制它們,您可以將其公開並通過拋出異常來提醒程序員並且不復制 object。

我什么時候需要自己申報?

三法則規定,如果您聲明任何

  1. 復制構造函數
  2. 復制賦值運算符
  3. 析構函數

那么你應該聲明所有三個。 它源於以下觀察:需要接管復制操作的含義幾乎總是源於 class 執行某種資源管理,這幾乎總是暗示

  • 在一個復制操作中進行的任何資源管理都可能需要在另一個復制操作中完成,並且

  • class 析構函數也將參與資源的管理(通常是釋放它)。 The classic resource to be managed was memory, and this is why all Standard Library classes that manage memory (eg, the STL containers that perform dynamic memory management) all declare “the big three”: both copy operations and a destructor.

三規則的結果是用戶聲明的析構函數的存在表明簡單的成員明智復制不太可能適合 class 中的復制操作。 反過來,這表明如果 class 聲明了一個析構函數,則可能不應該自動生成復制操作,因為它們不會做正確的事情。 在采用 C++98 的時候,這種推理的重要性還沒有被完全理解,所以在 C++98 中,用戶聲明的析構函數的存在對編譯器生成復制操作的意願沒有影響。 C++11 中的情況仍然如此,但這只是因為限制生成復制操作的條件會破壞太多遺留代碼。

如何防止我的對象被復制?

將復制構造函數和復制賦值運算符聲明為私有訪問說明符。

class MemoryBlock
{
public:

//code here

private:
MemoryBlock(const MemoryBlock& other)
{
   cout<<"copy constructor"<<endl;
}

// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other)
{
 return *this;
}
};

int main()
{
   MemoryBlock a;
   MemoryBlock b(a);
}

在 C++11 之后,您還可以聲明復制構造函數和賦值運算符已刪除

class MemoryBlock
{
public:
MemoryBlock(const MemoryBlock& other) = delete

// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other) =delete
};


int main()
{
   MemoryBlock a;
   MemoryBlock b(a);
}

許多現有的答案已經涉及復制構造函數、賦值運算符和析構函數。 然而,在 C++11 之后,引入移動語義可能會將其擴展到 3 之外。

最近Michael Claisse做了一個涉及到這個話題的演講: http://channel9.msdn.com/events/CPP/C-PP-Con-2014/The-Canonical-Class

C++ 中的三規則是設計和開發三要求的基本原則,即如果在以下成員之一 function 中有明確定義,則程序員應將其他兩個成員函數一起定義。 即以下三個成員函數是必不可少的:析構函數、復制構造函數、復制賦值運算符。

C++ 中的復制構造函數是一個特殊的構造函數。 它用於構建新的 object,即新的 object,相當於現有 object 的副本。

復制賦值運算符是一種特殊的賦值運算符,通常用於將現有的 object 指定給其他同類型的 object。

有一些簡單的例子:

// default constructor
My_Class a;

// copy constructor
My_Class b(a);

// copy constructor
My_Class c = a;

// copy assignment operator
b = a;

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

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