什么是三分法则?

What is The Rule of Three?

提问人:fredoverflow 提问时间:11/13/2010 最后编辑:Rann Lifshitzfredoverflow 更新时间:11/28/2022 访问量:374281

问:

  • 复制对象是什么意思?
  • 什么是复制构造函数复制赋值运算符
  • 我什么时候需要自己申报?
  • 如何防止我的对象被复制?
C++ 复制构造函数 值运算符 C++-FAQ 三法则

评论

65赞 sbi 11/13/2010
请在投票关闭之前阅读整个线程c++-faq 标签 wiki
17赞 sbi 11/16/2010
@Binary:在投票之前,至少要花时间阅读评论讨论。文本曾经要简单得多,但弗雷德被要求对其进行扩展。此外,虽然这在语法上是四个问题,但它实际上只是一个包含多个方面的问题。(如果您不同意这一点,请通过单独回答每个问题来证明您的 POV,让我们对结果进行投票。
1赞 sbi 1/25/2011
弗雷德,这是对你关于 C++1x 的回答的一个有趣的补充:stackoverflow.com/questions/4782757/......我们该如何处理?
7赞 Nemanja Trifunovic 6/28/2011
相关新闻: 两大法
3赞 rubenvb 9/25/2015
确切地说@paxdiablo零法则

答:

2108赞 fredoverflow 11/13/2010 #1

介绍

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)

特殊成员函数

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

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

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

管理资源

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

让我们回到前标准的 C++。 没有 ,程序员爱上了指针。 该类可能如下所示:std::stringperson

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. 可以通过 观察到 via 的变化。ab
  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;
}

请注意初始化和赋值之间的区别: 在将旧状态分配给它之前,我们必须将其拆除,以防止内存泄漏。 此外,我们必须防止 表单的自我赋值 . 如果没有该检查,将删除包含字符串的数组, 因为当你写的时候,和 都包含同一个指针。namex = xdelete[] namex = xthis->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);

或者,您可以继承或声明它们已删除(在 C++ 11 及更高版本中):boost::noncopyable

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

三法则

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

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

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

五法则

从 C++11 开始,对象具有 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 法则。规则的零部分指出,在创建类时,不允许编写任何特殊成员函数。

建议

大多数时候,你不需要自己管理资源, 因为现有的类,比如已经为你做了。 只需使用成员比较简单代码即可 对于使用 a 的复杂且容易出错的替代方案,您应该被说服。 只要远离原始指针成员,三法则就不太可能涉及你自己的代码。std::stringstd::stringchar*

评论

4赞 sbi 11/13/2010
弗雷德,如果 (A) 您不会在可复制代码中拼写出实施不当的赋值并添加注释说这是错误的,并在细则中查看其他地方,我会对我的赞成票感觉更好;要么在代码中使用 C&s,要么跳过实现所有这些成员 (B),你会缩短前半部分,这与 RoT 关系不大;(C) 您将讨论移动语义的引入以及这对 RoT 意味着什么。
7赞 Johannes Schaub - litb 11/13/2010
但我认为,该帖子应该成为 C/W。我喜欢你保持术语基本准确(即你说“复制分配运算符”,并且你没有利用分配不能意味着复制的常见陷阱)。
5赞 sbi 11/13/2010
@Prasoon:我不认为删掉一半的答案会被视为对非CW答案的“公平编辑”。
77赞 Alexander Malakhov 9/13/2012
如果您为 C++11 更新您的帖子(即移动构造函数/赋值),那就太好了
7赞 fredoverflow 11/18/2015
@solalito 使用后必须释放的任何内容:并发锁、文件句柄、数据库连接、网络套接字、堆内存......
556赞 sbi 11/13/2010 #2

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

如果你的班级需要任何一项

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

明确定义,那么它可能需要这三个。

这样做的原因是,这三者通常都用于管理资源,如果您的类管理资源,它通常需要管理复制和释放。

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

(请注意,即将推出的 C++ 标准的新版本(即 C++11)为 C++ 添加了移动语义,这可能会改变三法则。但是,我对此知之甚少,无法写一篇关于三法则的 C++11 部分。

评论

3赞 Matthieu M. 11/14/2010
防止复制的另一种解决方案是从无法复制的类(如)继承(私下)。它也可以更清晰。我认为 C++0x 和“删除”函数的可能性在这里可能会有所帮助,但忘记了语法:/boost::noncopyable
2赞 sbi 11/14/2010
@Matthieu:是的,这也行得通。但除非是 std 库的一部分,否则我认为它并没有太大的改进。(哦,如果你忘记了删除语法,你就忘记了我所知道的。noncopyable:))
4赞 sbi 6/5/2014
@Daan:请看这个答案。但是,我建议坚持马蒂尼奥零法则。对我来说,这是过去十年中创造的 C++ 最重要的经验法则之一。
4赞 Nathan Kidd 6/28/2018
Martinho 的 Rule of Zero 现在更好(没有明显的广告软件接管)位于 archive.org
173赞 Stefan 5/14/2012 #3

三巨头的定律如上所述。

一个简单的例子,用简单的英语来说,它解决了什么样的问题:

非默认析构函数

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

你可能会认为这已经完成了工作。

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

一旦其中一个删除了其析构函数中的内存,另一个将有一个指向无效内存的指针(这称为悬空指针),当它尝试使用它时,事情就会变得毛茸茸的。

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

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

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

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

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

评论

4赞 Unbreakable 1/4/2015
因此,如果我们使用复制构造函数,则进行复制,但完全位于不同的内存位置,如果我们不使用复制构造函数,则进行复制,但它指向相同的内存位置。这就是你想说的吗?因此,没有复制构造函数的复制意味着将存在一个新指针,但指向相同的内存位置,但是,如果我们有用户显式定义的复制构造函数,那么我们将有一个单独的指针指向不同的内存位置,但有数据。
4赞 Stefan 7/27/2016
对不起,我很久以前就回复了这个问题,但我的回复似乎还不在这里:-(基本上,是的 - 你明白了:-)
36赞 user1701047 10/18/2012 #4

复制对象是什么意思? 有几种方法可以复制对象 - 让我们谈谈你最有可能指的是的 2 种类型 - 深拷贝和浅拷贝。

由于我们使用的是面向对象的语言(或者至少是假设的),假设您分配了一段内存。由于它是一种 OO 语言,我们可以很容易地引用我们分配的内存块,因为它们通常是原始变量(ints、chars、bytes)或我们定义的类,它们由我们自己的类型和基元组成。因此,假设我们有一类汽车,如下所示:

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

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

如何防止我的对象被复制? 使用私有函数覆盖允许为对象分配内存的所有方式是一个合理的开始。如果你真的不希望人们复制它们,你可以将其公开,并通过抛出异常而不是复制对象来提醒程序员。

评论

7赞 sehe 6/12/2014
这个问题被标记为 C++。这种伪代码的阐述充其量只能澄清定义明确的“三法则”,最坏的情况是散布混乱。
48赞 fatma.ekici 1/1/2013 #5

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

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

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

12赞 Marcus Thornton 8/12/2014 #6

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 8/16/2014
嗨,你的答案没有增加任何新内容。其他人则更深入、更准确地涵盖了这个主题——你的答案是近似的,实际上在某些地方是错误的(即这里没有“必须”;它是“很可能应该”)。在发布这种已经彻底回答的问题的答案时,真的不值得你。除非你有新的东西要添加。
1赞 anatolyg 11/3/2014
此外,还有四个快速的例子,它们在某种程度上与三法则所谈论的三个中的两个有关。太多的混乱。
19赞 xyz 1/7/2015 #7

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

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

29赞 Ajay yadav 1/12/2016 #8

我什么时候需要自己申报?

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

  1. Copy 构造函数
  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);
}