什么是三法则?


2145
  • 什么是抄袭的对象是什么意思?
  • 什么是拷贝构造函数拷贝赋值运算符
  • 我什么时候需要自己声明?
  • 如何防止对象被复制?

52
阅读这整个主题c++-faq标签维基你投票关闭之前
2010年

13
@Binary:在投票之前,至少要花一些时间阅读评论讨论。文字过去要简单得多,但要求弗雷德对此进行扩展。同样,尽管从语法上讲这是四个问题,但实际上这只是一个涉及多个方面的问题。(如果您不同意,则可以通过
单独

1
弗雷德(Fred),这是您对C ++ 1x的回答的一个有趣的补充:stackoverflow.com/questions/4782757/…。我们该如何处理?
2011年


4
请记住,从C ++ 11开始,我认为这已升级为5或类似的规则。
paxdiablo

Answers:


1793

介绍

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对象是什么意思?该main功能显示了两种不同的复制方案。初始化person b(a);复制构造函数执行。它的工作是根据现有对象的状态构造一个新对象。分配b = a副本分配运算符执行。它的工作通常要复杂一些,因为目标对象已经处于某种有效状态,需要处理。

由于我们既没有声明拷贝构造函数,也没有声明赋值运算符(也没有析构函数),因此它们是为我们隐式定义的。从标准引用:

复制构造函数和复制赋值运算符,析构函数是特殊的成员函数。[ 注意如果程序未明确声明它们,则实现将隐式声明某些类类型的这些成员函数。 如果使用它们,实现将隐式定义它们。[...]尾注 ] [n3126.pdf第12节§1]

默认情况下,复制对象意味着复制其成员:

非联合类X的隐式定义的复制构造函数执行其子对象的成员复制。[n3126.pdf第12.8§16节]

非联合类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()
{
}

在这种情况下name,按age成员复制正是我们想要的: 并且被复制,因此我们得到了一个独立的,独立的person对象。隐式定义的析构函数始终为空。在这种情况下,这也很好,因为我们没有在构造函数中获取任何资源。析person构函数完成后,将隐式调用成员的析构函数:

在执行析构函数的主体并销毁主体中分配的所有自动对象后,类X的析构函数调用X的直接成员的析构函数[n3126.pdf 12.4§6]

管理资源

那么什么时候应该显式声明那些特殊的成员函数呢?当我们的类管理资源时,也就是说,当类的对象负责该资源时。这通常意味着资源是在构造函数中获取的(或传递到构造函数中)并在析构函数中释放的。

让我们回到过去的标准C ++。没有这样的东西std::string,并且程序员爱上了指针。该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;
    }
};

即使在今天,人们仍然以这种风格编写类并陷入麻烦:“ 我将一个人推入向量中,现在我得到了疯狂的内存错误! ”请记住,默认情况下,复制对象意味着复制其成员,但name仅复制成员复制一个指针,而不是它指向的字符数组!这有几个令人不愉快的影响:

  1. 通过a可以观察通过的变化b
  2. 一旦b被摧毁a.name是一个悬空的指针。
  3. 如果a被破坏,则删除悬空指针会产生未定义的行为
  4. 由于分配未考虑name分配前所指的内容,因此迟早您会在各处发现内存泄漏。

明确定义

由于逐成员复制没有达到预期的效果,因此我们必须显式定义复制构造函数和复制赋值运算符以制作字符数组的深层副本:

// 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以防止内存泄漏。另外,我们必须防止表单的自我分配x = x。如果没有该检查,delete[] name将删除包含字符串的数组,因为在编写时x = x,两个this->namethat.name都包含相同的指针。

异常安全

不幸的是,如果new char[...]由于内存耗尽而引发异常,此解决方案将失败。一种可能的解决方案是引入局部变量并对语句重新排序:

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

这也可以在没有明确检查的情况下进行自我分配。解决此问题的一个更强大的解决方案是“ 复制和交换”习惯用法,但在此我将不讨论异常安全性的详细信息。我只提到了例外情况以说明以下几点:编写用于管理资源的类很困难。

不可复制的资源

某些资源不能或不应被复制,例如文件句柄或互斥锁。在这种情况下,只需将复制构造函数和复制赋值运算符声明为,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;

三法则

有时您需要实现一个管理资源的类。(永远不要在一个类中管理多个资源,这只会导致痛苦。)在这种情况下,请记住以下三个规则

如果您需要自己显式声明析构函数,复制构造函数或复制赋值运算符,则可能需要显式声明这三个函数。

(不幸的是,该“规则”不是由C ++标准或我所知道的任何编译器强制执行的。)

五法则

从C ++ 11开始,对象具有2个额外的特殊成员函数:move构造函数和move赋值。五个州的规则也要实现这些功能。

带有签名的示例:

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

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

零法则

3/5的规则也称为0/3/5的规则。规则的零部分表示在创建类时不允许编写任何特殊成员函数。

忠告

大多数时候,您不需要自己管理资源,因为诸如此类的现有类std::string已经为您完成了。只需将使用std::string成员的简单代码与使用a 进行卷积且容易出错的替代方法进行比较char*,您应该被说服。只要您远离原始指针成员,三个规则就不太可能涉及您自己的代码。


4
弗雷德(Fred),如果(A)您不会在可复制的代码中拼出执行不当的作业,并添加注释说错了并在细则中的其他地方,我会感到更好。在代码中使用c&s或只是跳过实现所有这些成员(B),您将缩短前半部分,而这与RoT无关。(C)您将讨论移动语义的引入以及这对RoT意味着什么。
2010年

7
但是我认为应该将职位设为C / W。我喜欢您将术语保持为最准确(即您说的是“ 副本分配运算符”,并且您没有利用分配不能隐含副本的常见陷阱)。
Johannes Schaub-litb

4
@Prasoon:我认为删掉一半答案不会被视为对非CW答案的“公平编辑”。
2010年

69
如果您更新C ++ 11的帖子(即移动构造函数/赋值),那就太好了
Alexander Malakhov 2012年

5
@solalito使用后必须释放的所有内容:并发锁,文件句柄,数据库连接,网络套接字,堆内存...
fredoverflow

509

基本原则是,三原则是C ++的经验法则

如果您的班级需要以下任何一项

  • 一个拷贝构造函数
  • 一个赋值运算符
  • 破坏者

明确定义,则可能需要全部三个

这样做的原因是,这三个方法通常都用于管理资源,如果您的班级管理资源,则通常需要管理复制和释放。

如果没有很好的语义来复制类管理的资源,请考虑通过将复制构造函数和赋值运算符声明为(未定义)来禁止复制private

(请注意,即将发布的C ++标准的新版本(即C ++ 11)向C ++添加了移动语义,这可能会更改“三规则”。但是,我对此知之甚少,无法编写C ++ 11部分关于三法则。)


3
防止复制的另一种解决方案是(私有地)从无法复制的类(如boost::noncopyable)继承。它也可以更加清晰。我认为C ++ 0x和“删除”功能的可能性可能会有所帮助,但忘记了语法:/
Matthieu M.

2
@Matthieu:是的,也可以。但是除非noncopyable是std lib的一部分,否则我认为它不会带来太大的改进。(哦,如果您忘记了删除语法,就忘记了我所知道的时间。:)
sbi

3
@Daan:看到这个答案。不过,我建议你坚持Martinho零规则。对我来说,这是最近十年创造的C ++最重要的经验法则之一。
sbi 2014年

3
马丁纽(Martinho)的零规则现在更好(没有明显的广告软件收购)位于archive.org
内森·基德

161

三巨头的法律如上所述。

用简单的英语简单说明其解决的问题:

非默认析构函数

您在构造函数中分配了内存,因此需要编写一个析构函数才能将其删除。否则会导致内存泄漏。

您可能会认为这已经完成。

问题将是,如果对您的对象进行了复制,则该副本将指向与原始对象相同的内存。

一旦,其中一个删除其析构函数中的内存,另一个试图使用它时,将有一个指向无效内存的指针(称为悬挂指针)。

因此,您编写了一个复制构造函数,以便它为新对象分配自己的内存以销毁。

赋值运算符和副本构造函数

您将构造函数中的内存分配给了类的成员指针。当您复制此类的对象时,默认的赋值运算符和复制构造函数会将此成员指针的值复制到新对象。

这意味着新对象和旧对象将指向同一块内存,因此当您在一个对象中对其进行更改时,另一个对象也将被更改。如果一个对象删除了该内存,另一个对象将继续尝试使用它-eek。

要解决此问题,您可以编写自己的版本的复制构造函数和赋值运算符。您的版本为新对象分配了单独的内存,并跨第一个指针指向的值(而不是其地址)进行复制。


4
因此,如果我们使用复制构造函数,则将在完全不同的内存位置进行复制,如果不使用复制构造函数,则将进行复制,但指向相同的内存位置。这就是你想说的吗?因此,没有副本构造函数的副本意味着将有一个新指针,但指向相同的内存位置,但是,如果我们有用户明确定义的副本构造函数,则将有一个单独的指针指向另一个内存位置,但具有数据。
2015年

4
抱歉,我已经回答了这个年龄,但是我的回答似乎仍然不在这里:-(基本上,是的-你明白了:-)
Stefan

1
原理如何扩展到副本分配运算符?如果提到三规则中的第三,那么这个答案将更有用。
DBedrenko

1
@DBedrenko,“您编写一个复制构造函数,以便它为新对象分配自己的内存...”,这与扩展到复制赋值运算符的原理相同。您是否认为我已经明确了?
Stefan's

2
@DBedrenko,我添加了更多信息。这样更清楚吗?
Stefan's

44

基本上,如果您有析构函数(不是默认的析构函数),则意味着您定义的类具有一定的内存分配。假设该类由某些客户端代码或您在外部使用。

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

如果MyClass仅具有一些原始类型的成员,则默认的赋值运算符将起作用,但是如果它具有一些没有赋值运算符的指针成员和对象,则结果将是不可预测的。因此,我们可以说,如果在类的析构函数中有一些要删除的内容,则可能需要一个深层复制运算符,这意味着我们应该提供一个复制构造函数和赋值运算符。


36

复制对象是什么意思?有几种复制对象的方法-让我们谈谈您最可能引用的两种-深层复制和浅层复制。

由于我们使用的是面向对象的语言(或至少假设是这样),因此假设您分配了一块内存。由于它是一种面向对象的语言,我们可以轻松地引用我们分配的内存块,因为它们通常是我们定义的由我们自己的类型和基元构成的基元变量(int,char,bytes)或类。假设我们有以下类别的Car:

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

}

深层复制是如果我们声明一个对象,然后创建该对象的完全独立的副本...我们最终将在2套完整的内存集中拥有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组成的实际内存。(这样做通常是一个错误,并且在类中通常是讨论该主题的毯子。)假装每次询问car2时,您实际上是在解析指向car1的内存空间的指针...或多或少是一个浅表副本是。

//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不在同一语句中声明。您为这些操作编写的两个代码块可能非常相似。实际上,典型的设计模式还具有另一个函数,一旦您满意初始复制/分配是合法的,就可以调用它来设置所有内容。

我什么时候需要自己声明?如果您不是在编写要以某种方式共享或用于生产的代码,则实际上只需要在需要它们时声明它们即可。如果您选择“偶然”使用它而没有编写一种语言,那么您确实需要知道您的程序语言会做什么,即,使编译器成为默认语言。例如,我很少使用复制构造函数,但是赋值运算符的覆盖很常见。您是否知道您还可以覆盖加法,减法等含义?

如何防止对象被复制?合理的做法是,使用私有函数覆盖所有允许为对象分配内存的方式。如果您确实不希望人们复制它们,则可以将其公开并通过引发异常并且不复制对象来警告程序员。


5
这个问题被标记为C ++。这种伪代码说明最多并不能澄清关于定义明确的“三人制”的任何信息,而最坏情况下只会分散混乱。
sehe

26

我什么时候需要自己声明?

三法则指出,如果您声明任何

  1. 复制构造函数
  2. 复制分配运算符
  3. 析构函数

那么您应该声明所有三个。它源于这样的观察:接管复制操作的含义的需求几乎总是源自执行某种资源管理的类,并且几乎总是暗示着

  • 在一个复制操作中完成的资源管理可能需要在另一复制操作中完成,并且

  • 类析构函数也将参与资源的管理(通常是释放资源)。要管理的经典资源是内存,这就是为什么所有管理内存的标准库类(例如,执行动态内存管理的STL容器)都声明“三者”:复制操作和析构函数。

三规则的结果是,用户声明的析构函数的存在指示简单的成员明智的复制不太适合该类中的复制操作。反过来,这表明,如果类声明了析构函数,则可能不应该自动生成复制操作,因为它们不会做正确的事情。在采用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);
}

16

许多现有答案已经触及复制构造函数,赋值运算符和析构函数。但是,在C ++ 11之后的版本中,move语义的引入可能会将其扩展到3以上。

最近,迈克尔·克莱斯(Michael Claisse)发表了涉及该主题的演讲:http ://channel9.msdn.com/events/CPP/C-PP-Con-2014/The-Canonical-Class


10

C ++中的三个规则是设计和开发三个要求的基本原则,即如果以下成员函数之一中有明确的定义,则程序员应一起定义其他两个成员函数。即以下三个成员函数是必不可少的:析构函数,复制构造函数,复制赋值运算符。

C ++中的Copy构造函数是一种特殊的构造函数。它用于构建新对象,该新对象等效于现有对象的副本。

复制赋值运算符是一种特殊的赋值运算符,通常用于将现有对象指定给其他相同类型的对象。

有简单的例子:

// default constructor
My_Class a;

// copy constructor
My_Class b(a);

// copy constructor
My_Class c = a;

// copy assignment operator
b = a;

7
嗨,您的答案没有添加任何新内容。其他的则更深入,更准确地涵盖了该主题-您的回答是近似的,实际上在某些地方是错误的(即,这里没有“必须”;“很可能”)。对于已经完全回答过的问题,发布这种答案确实不值得。除非您要添加新的东西。
2014年

1
此外,还有4层简单的例子,这些在某种程度上关系到2的的3三个法则是在谈论。太混乱了。
anatolyg 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.