小编典典

什么是三法则?

c++

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

阅读 347

收藏
2022-02-08

共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)部分感到困惑,这称为成员初始化器列表

特殊成员函数

复制对象是什么意思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->name包含that.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;
}

这也可以在没有明确检查的情况下处理自分配。这个问题的一个更强大的解决方案是copy-and-swap idiom,但我不会在这里详细介绍异常安全性。我只提到例外是为了说明以下几点:编写管理资源的类很困难。

不可复制的资源

某些资源不能或不应该被复制,例如文件句柄或互斥体。在这种情况下,只需将复制构造函数和复制赋值运算符声明为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 个额外的特殊成员函数:移动构造函数和移动赋值。实现这些功能的五国规则也是如此。

带有签名的示例:

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成员的简单代码与使用 a 的复杂且容易出错的替代代码进行比较char*,您就会被说服。只要您远离原始指针成员,三法则就不太可能涉及您自己的代码。

2022-02-08