三的规则是什么?

  • 复制对象是什么意思
  • 复制构造函数复制赋值操作符是什么?
  • 我什么时候需要自己申报?
  • 如何防止我的对象被复制?
352364 次浏览

导言

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.pdf12§1]

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

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

非联合类X的隐式定义的复制赋值操作符执行按成员的复制赋值它的子对象。[n3126.pdf12.8§30]

隐式定义

person的隐式定义的特殊成员函数如下所示:

// 1. copy constructorperson(const person& that) : name(that.name), age(that.age){}
// 2. copy assignment operatorperson& operator=(const person& that){name = that.name;age = that.age;return *this;}
// 3. destructor~person(){}

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

执行析构函数的主体并销毁主体中分配的任何自动对象后,类X的析构函数调用X的直接[…]成员的析构函数[n3126.pdf12.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 constructorperson(const person& that){name = new char[strlen(that.name) + 1];strcpy(name, that.name);age = that.age;}
// 2. copy assignment operatorperson& 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 operatorperson& 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);        // Ctorperson(const person &) = default;                // 1/5: Copy Ctorperson(person &&) noexcept = default;            // 4/5: Move Ctorperson& operator=(const person &) = default;     // 2/5: Copy Assignmentperson& operator=(person &&) noexcept = default; // 5/5: Move Assignment~person() noexcept = default;                    // 3/5: Dtor};

零的法则

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

咨询

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

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

如果你的班级需要

  • a复制构造函数
  • 一个赋值操作符
  • 析构函数

明确定义,那么很可能需要他们三个都是

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

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

(请注意,即将到来的C++标准的新版本(C++11)将移动语义学添加到C++中,这可能会改变三规则。然而,我对此知之甚少,无法编写C++11关于三规则的部分。)

三巨头的法则如上所述。

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

非默认析构函数

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

你可能会认为这是工作。

问题是,如果一个副本是由你的对象组成的,那么副本将指向与原始对象相同的内存。

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

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

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

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

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

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

复制对象是什么意思?有几种方法可以复制对象——让我们谈谈你最有可能指的两种——深度复制和浅层复制。

由于我们使用的是面向对象语言(或者至少假设是这样),假设你分配了一段内存。由于它是一种面向对象语言,我们可以轻松引用我们分配的内存块,因为它们通常是原始变量(int、chars、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 constructorcar2.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 greendelete car2;/*I get rid of my car which is also really your car...I told C++ to resolvethe address of where car2 exists and delete the memory...which is alsothe memory associated with your car.*/car1.changePaint("red");/*program will likely crash because this area isno longer allocated to the program.*/

因此,无论您使用哪种语言编写,在复制对象时都要非常小心,因为大多数时候您想要深度复制。

什么是复制构造函数和复制赋值操作符?我已经在上面使用过了。当你键入诸如Car car2 = car1;之类的代码时,复制构造函数会被调用。本质上,如果你声明一个变量并将其赋值在一行中,那就是调用复制构造函数的时候。赋值操作符是当你使用等号时发生的事情car2 = car1;。注意car2不是在同一个语句中声明的。你为这些操作编写的两块代码可能非常相似。事实上,典型的设计模式有另一个函数,一旦你满意初始复制/赋值是合法的,你就会调用它来设置所有内容——如果你看看我写的长篇代码,功能几乎相同。

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

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

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

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

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

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

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

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

有一些快速的例子:

// default constructorMy_Class a;
// copy constructorMy_Class b(a);
// copy constructorMy_Class c = a;
// copy assignment operatorb = a;

许多现有的答案已经触及了复制构造函数、赋值操作符和析构函数。然而,在C++11中,移动语义的引入可能会将其扩展到3之外。

最近Michael Claisse做了一个关于这个话题的演讲:http://channel9.msdn.com/events/CPP/C-PP-Con-2014/The-Canonical-Class

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

三规则规定,如果你宣布任何一个

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