协慌网

登录 贡献 社区

什么是三法则?

  • 复制对象意味着什么?
  • 什么是复制构造函数复制赋值运算符
  • 我什么时候需要自己申报?
  • 如何防止复制对象?

答案

介绍

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.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 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 = 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);

或者,您可以从boost::noncopyable继承或将它们声明为已删除(C ++ 0x):

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

三个规则

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

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

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

忠告

大多数情况下,您不需要自己管理资源,因为现有的类(如std::string已经为您完成了。只需将使用std::string成员的简单代码与使用char*的复杂且容易出错的替代方法进行比较,您应该确信。只要你远离原始指针成员,三个规则就不太可能涉及你自己的代码。

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

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

  • 复制构造函数
  • 赋值运算符
  • 或者是一个析构函数

明确地定义,那么它可能需要所有这三个

原因是它们中的所有三个通常用于管理资源,如果您的类管理资源,它通常需要管理复制和释放。

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

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

三巨头的法则如上所述。

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

非默认析构函数

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

你可能认为这是完成的工作。

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

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

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

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

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

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

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