什么是三个原则?

2516
  • 复制对象是什么意思?
  • 什么是拷贝构造函数和拷贝赋值操作符?
  • 什么情况下需要手动声明它们?
  • 如何防止对象被复制?

65
请在投票关闭之前,仔细阅读此帖子的全部内容c++-faq标签wiki页面 - sbi
17
“@Binary:至少在投票之前花时间阅读评论讨论内容。原来的文本更简洁,但弗雷德被要求扩展它。此外,虽然从语法上讲这是四个问题,但实际上只是一个包含几个方面的问题。(如果您不同意,请通过分别回答每个问题并让我们投票结果来证明您的观点。)” - sbi
7
相关链接: The Law of The Big Two - Nemanja Trifunovic
6
请记住,在C++11中,我认为这已经升级为五法则,或类似的东西。 - paxdiablo
3
@paxdiablo 零值规则 - rubenvb
显示剩余2条评论
8个回答

2107

介绍

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()
{
}

在这种情况下,成员逐个复制是我们想要的:nameage被复制,因此我们得到了一个独立的、自包含的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. 一旦销毁ba.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 开始,一个对象具有两个额外的特殊成员函数: 移动构造函数和移动赋值运算符。五大法则建议同时实现这些函数。

以下是一个函数签名示例:

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规则。其中的零部分规定,创建类时允许不编写任何特殊成员函数。

建议

通常情况下,您不需要自己管理资源, 因为像std::string这样的现有类已经为您做了这些。 只需比较使用std::string成员的简单代码, 与使用char*的复杂且容易出错的替代方案即可。 只要避开原始指针成员,三个规则很少涉及您自己的代码。


4
Fred,如果你不在可复制的代码中拼错“badly implemented assignment”,并添加一条注释说这是错误的并在细节中寻找其他部分,我会对我的点赞感到更加满意;要么在代码中使用c&s,要么跳过实现所有这些成员。(A) 如果你缩短第一部分,与RoT关系较小的部分,我会感觉更好;(B) 如果你讨论移动语义的介绍以及它对RoT的意义,我也会感到更满意。 - sbi
7
但是我认为帖子应该标注C/W。我喜欢你保持术语的准确性(例如,你说“复制赋值运算符”),并且你没有陷入常见的误区,即赋值不能暗示复制。 - Johannes Schaub - litb
5
我认为剪掉答案的一半不会被视为对非社区维基的回答进行公正编辑。 - sbi
77
请更新您的C++11文章(即移动构造函数/赋值运算符),这将非常好。 - Alexander Malakhov
7
使用完毕后必须释放的内容包括:并发锁、文件句柄、数据库连接、网络套接字、堆内存等。 - fredoverflow
显示剩余27条评论

556

三法则是 C++ 中的一个经验法则,基本上就是说:

如果你的类需要任何一个以下操作:

  • 复制构造函数
  • 赋值运算符
  • 或析构函数

明确定义,那么它很可能需要全部三者

原因在于这三个通常用来管理资源,如果你的类管理资源,通常需要管理复制和释放。

如果你的类管理的资源没有好的语义来复制,则可以通过将复制构造函数和赋值运算符声明为private来禁止复制(不是 定义)。

(请注意,即将发布的新版本 C++ 标准(即 C++11)将向 C++ 添加移动语义,这可能会改变三法则。但是我对此了解太少,无法编写关于三法则的 C++11 版本)。


3
为了防止复制,另一个解决方案是从一个无法被复制的类(例如 boost::noncopyable)继承(私有继承)。这种方法可能更加清晰。我认为 C++0x 和“删除”函数的可能性可以在这里提供帮助,但是我忘记了具体语法 :/ - Matthieu M.
2
@Matthieu:没错,那也可以。但是除非noncopyable是std lib的一部分,否则我不认为它有多大的改进。(哦,如果你忘记了删除语法,那你忘记的比我知道的还要多。 :) - sbi
4
@Daan:请参考这个答案。不过,我建议遵循Martinho零成本原则。对我来说,这是最近十年中C++最重要的经验法则之一。 - sbi
4
马丁尼奥的“零规则”现在可以更好地找到(没有明显的广告软件接管),位于archive.org上。 - Nathan Kidd

173

“大三定律”就如上所述。

一个简单易懂的例子,是解决以下问题的:

非默认析构函数

在构造函数中分配了内存,因此需要编写析构函数来释放它。否则会导致内存泄漏。

您可能认为这个问题已经解决了。

问题在于,如果复制对象,则副本将指向与原始对象相同的内存。

当其中一个对象在其析构函数中删除内存时,另一个对象将指向无效内存(这称为悬空指针),当尝试使用它时,情况将变得混乱。

因此,您需要编写一个复制构造函数,以使新对象拥有自己的内存块进行管理。

赋值运算符和复制构造函数

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

这意味着新对象和旧对象将指向同一片内存空间,因此在更改其中一个对象时,另一个对象也会被更改。如果其中一个对象删除此内存,则另一个对象将继续尝试使用它 - 哎呀。

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


4
如果使用复制构造函数,则会创建副本,但位于完全不同的内存位置。如果不使用复制构造函数,则副本将指向相同的内存位置。这是您试图表达的内容吗?因此,不使用复制构造函数进行复制意味着将有一个新指针存在,但指向相同的内存位置。然而,如果用户明确定义了复制构造函数,则将拥有一个单独的指针,指向不同的内存位置但包含相同的数据。 - Unbreakable
4
抱歉,我很久之前已经回复了,但我的回复似乎不在这里 :-( 基本上,是的 - 你明白 :-) 翻译:抱歉,我很久之前已经回复了,但是我的回复似乎不在这里了。基本上说,没错 - 你理解得对 :-) - Stefan

48

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

    MyClass x(a, b);
    MyClass y(c, d);
    x = y; // This is a shallow copy if assignment operator is not provided
如果MyClass只有一些基本类型的成员,那么默认的赋值运算符就可以工作,但如果它有一些指针成员和没有赋值运算符的对象,结果将是不可预测的。因此,我们可以说如果在类的析构函数中需要删除一些内容,我们可能需要一个深度复制运算符,这意味着我们应该提供一个复制构造函数和赋值运算符。

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


36
复制对象是什么意思?有几种方法可以复制对象——让我们谈论一下你最有可能涉及到的两种——深复制和浅复制。
由于我们使用的是面向对象语言(或者至少是假设如此),所以假设您已经分配了一块内存。由于它是一种面向对象语言,我们可以轻松地引用我们分配的内存块,因为它们通常是原始变量(int、char、byte)或我们定义的类,这些类由我们自己的类型和基元组成。所以假设我们有一个名为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;
}

}

深拷贝是指我们声明一个对象,然后创建一个完全独立的对象副本...最终我们得到了两个对象在两组完全不同的内存中。

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未在同一语句中声明。您编写这些操作的两个代码块可能非常相似。事实上,典型的设计模式还有另一个函数,您可以在满意初始复制/赋值后调用该函数来设置所有内容-如果您查看我编写的长手代码,则函数几乎相同。
何时需要自己声明它们? 如果您不编写要共享或以某种方式用于生产的代码,则只需要在需要时声明它们。如果您选择“意外”使用它并且没有制作一个,那么您需要知道程序语言会做什么-也就是说,您会得到编译器默认值。例如,我很少使用复制构造函数,但赋值运算符重载非常常见。您知道还可以重写加法、减法等的含义吗?
如何防止我的对象被复制? 通过使用私有函数覆盖允许为对象分配内存的所有方式是一个合理的开始。如果您真的不希望别人复制它们,您可以将其设置为公共,并通过抛出异常并且不复制对象来向程序员发出警报。

7
问题被标记为C++。这个伪代码表述最多只能在某种程度上澄清“三法则”(Rule Of Three)的确切含义,但最坏的情况下只会带来更多的困惑。 - sehe

29

当我需要自己声明它们时?



“三大法则”指的是,如果您声明了以下任何一个:

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

19

12

C++中的三大法则是设计和开发的基本原则,如果以下三个成员函数中有一个有明确的定义,则程序员应该一起定义另外两个成员函数。换句话说,以下三个成员函数是不可或缺的:析构函数、复制构造函数和复制赋值运算符。

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

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

以下是快速示例:

// default constructor
My_Class a;

// copy constructor
My_Class b(a);

// copy constructor
My_Class c = a;

// copy assignment operator
b = a;

7
你的回答没有提供新信息。其他人更深入地涵盖了该主题,并且更准确 - 你的回答是近似的,实际上在某些地方是错误的(即这里没有“必须”,而是“很有可能应该”)。如果问题已经得到了彻底的回答,那么发表这种类型的答案确实不值得你费心,除非你有新的东西要补充。 - Mat
1
此外,还有四个快速示例,它们与三大法则中的两个某种程度上相关。太令人困惑了。 - anatolyg

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接