C++ 中的三大法则

Jay Shaw 2023年10月12日
  1. 了解 C++ 中的三大法则
  2. C++ 中构造函数的隐式定义
  3. C++ 中构造函数的显式定义
  4. 结论
C++ 中的三大法则

三大法则是全球最流行的编码习惯之一。法律规定 C++ 有一些必须一起声明的特殊功能,即使是必需的。

这些函数是复制构造函数、复制赋值运算符和析构函数。

法律规定,如果在程序中声明了三个特殊功能之一,则其他两个也必须遵循。如果没有,程序就会遇到严重的内存泄漏。

本文将详细解释三大法则以及如何绕过它。

了解 C++ 中的三大法则

必须考虑到当动态分配的资源被添加到一个类中时,会发生什么情况,以了解大三大法则。

在下面的示例中,创建了一个构造函数类,它为指针分配一些内存空间。

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
};

如上所示,内存被分配给指针。现在,它也必须使用析构函数释放。

~Big3() {
  std::cout << "Resource is released \n";
  delete x;
}

普遍的看法是,工作到此为止。但现实还很遥远。

在此类中启动复制构造函数时会发生一些严重的内存泄漏。

这个问题背后的原因是构造函数的隐式定义。当一个对象在没有定义构造函数的情况下被复制时,编译器会隐式创建一个复制构造函数,该构造函数不会创建克隆,而只是同一对象的影子。

默认情况下,复制构造函数对对象运行浅拷贝。程序使用这种复制方法运行良好,直到某些资源被动态分配给一个对象。

例如,在下面的代码中,创建了一个对象 p1。另一个对象 p2p1 的复制对象。

int main() {
  Big3 p1;
  Big3 p2(p1);
}

在这段代码中,当析构函数销毁对象 p1 时,p2 成为一个悬空指针。这是因为对象 p2 指向对象 p1 的引用。

为了更好地理解,下面给出了完整的代码。

#include <iostream>

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
  ~Big3() {
    std::cout << "Resource is released \n";
    delete x;
  }
};

int main() {
  Big3 p1;
  Big3 p2(p1);
}

为了避免像悬空指针这样的问题,程序员需要显式声明所有必需的构造函数,这就是三大规则的内容。

C++ 中构造函数的隐式定义

有两种方法可以制作对象的副本。

  1. 浅拷贝——使用构造函数拷贝一个对象的地址,并将其存储在新的对象中。
  2. 深度复制 - 使用类似的构造函数,将存储在该地址内的值复制到新地址中。

通常,当为对象分配一些内存时,复制构造函数的隐式版本会复制指针 x 的引用,而不是创建具有自己的内存分配集的新对象。

下面是如何隐式定义特殊成员函数的表示。

book(const book& that) : name(that.name), slno(that.slno) {}

book& operator=(const book& that) {
  name = that.name;
  slno = that.slno;
  return *this;
}

~book() {}

在这个例子中,定义了三个特殊的成员函数。第一个是复制构造函数,它通过成员初始化器列表分配成员变量:name(that.name), slno(that.slno)

第二个构造函数是创建类对象副本的复制赋值构造函数。在这里,运算符重载用于创建对象的副本。

最后,析构函数保持为空,因为没有分配任何资源。该代码不会引发错误,因为对象不需要任何内存分配。

为什么隐式定义在资源管理中失败

假设类的成员指针接收内存分配。当使用默认赋值运算符和复制函数构造函数复制此类的对象时,此成员指针的引用将被复制到新对象。

结果,对一个对象所做的任何更改也会影响另一个对象,因为新对象和旧对象都将指向相同的内存位置。第二个对象将继续尝试使用它。

class person {
  char* name;
  int age;

 public:
  // constructor acquires a resource
  // 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;
  }

  // destructor must release this resource via delete
  ~person() { delete[] name; }
};

上面的例子复制了 name,它复制了指针,将值存储在里面。

声明析构函数时,它只是删除原始对象的实例。但是复制的对象一直指向同一个引用,现在该引用已被破坏。

这是内存泄漏的主要原因。它们出现在析构函数删除原始对象及其引用时,而复制构造函数创建的对象继续悬空,也称为悬空指针。

类似地,如果悬空指针未选中,那么该内存引用将在未来造成多个内存泄漏。

这个问题的唯一解决方案是显式声明构造函数,或者简单地说,创建自己的复制构造函数和赋值运算符模型来解决这个问题。

自定义构造函数复制初始指针指向的值而不是其地址,为新对象分配单独的内存。

C++ 中构造函数的显式定义

据观察,隐式定义的构造函数导致对象的浅拷贝。为了解决这个问题,程序员明确定义了一个复制构造函数,它有助于在 C++ 中深度复制对象。

深度复制是一种分配新内存块来存储指针值的方法,而不仅仅是保存内存引用。

此方法需要显式定义所有三个特殊成员方法,以便编译器在复制对象时分配新内存。

析构函数在 C++ 中显式定义的构造函数中的作用

必须创建析构函数来擦除分配给函数对象的内存。如果你不这样做,这可能会导致内存泄漏。

在隐式构造函数中,即使声明了析构函数,问题仍然存在。出现问题是因为如果复制分配的对象内存,则复制的对象将指向与原始对象相同的内存。

当一个删除其析构函数中的内存时,另一个将有一个指向无效内存的指针,当它尝试使用它时事情会变得复杂。

因此,必须创建显式定义的复制构造函数,为新对象提供要清除的内存碎片。

下面的程序显示了一个遵守三大法则的演示类。

class name {
 private:
  int* variable;  // pointer variable
 public:
  // Constructor
  name() { variable = new int; }

  void input(int var1)  // Parameterized method to take input
  {
    *variable = var1;
  }

  // Copy Constructor
  name(name& sample) {
    variable = new int;
    *variable = *(sample.variable);

    // destructor
    ~name() { delete variable; }
  };

在 C++ 中实现复制构造函数

该程序有一个类 Book,它带有一个默认的参数化构造函数和一个析构函数。当没有提供输入时,默认构造函数返回空值,而参数化构造函数初始化这些值并复制它们。

这里包含了一个异常处理方法(try-catch),当变量 m_Name 无法分配资源时,它会抛出异常。

在析构函数之后,会创建一个复制构造函数,用于复制原始对象。

#include <cstring>
#include <exception>
#include <iostream>

using namespace std;

class Book {
  int m_Slno;
  char* m_Name;

 public:
  // Default Constructor
  Book() : m_Slno(0), m_Name(nullptr) {}

  // Parametarized Constructor
  Book(int slNumber, char* name) {
    m_Slno = slNumber;
    unsigned int len = strlen(name) + 1;
    try {
      m_Name = new char[len];
    } catch (std::bad_alloc e) {
      cout << "Exception received: " << e.what() << endl;
      return;
    }
    memset(m_Name, 0, len);
    strcpy(m_Name, name);
  }

  // Destructor
  ~Book() {
    if (m_Name) {
      delete[] m_Name;
      m_Name = nullptr;
    }
  }

  friend ostream& operator<<(ostream& os, const Book& s);
};

ostream& operator<<(ostream& os, const Book& s) {
  os << s.m_Slno << ", " << s.m_Name << endl;
  return os;
}

int main() {
  Book s1(124546, "Digital Marketing 101");
  Book s2(134645, "Fault in our stars");

  s2 = s1;

  cout << s1;
  cout << s2;

  s1.~Book();
  cout << s2;

  return 0;
}

在主函数中,当对象 s1 被销毁时,s2 不会丢失它的动态对象,即对象 s 的字符串变量。

下面的另一个示例演示了如何使用复制构造函数深度复制对象。在下面的代码中,创建了一个构造函数类 design

该类具有三个私有变量——静态 lh 中的两个,以及一个动态对象 w

#include <iostream>
using namespace std;

// A class design
class design {
 private:
  int l;
  int* w;
  int h;

 public:
  // Constructor
  design() { w = new int; }

  // Method to take input
  void set_dimension(int len, int brea, int heig) {
    l = len;
    *w = brea;
    h = heig;
  }

  // Display Function
  void show_data() {
    cout << "The Length is = " << l << "\n"
         << "Breadth  of the design = " << *w << "\n"
         << "Height = " << h << "\n"
         << endl;
  }

  // Deep copy is initialized here
  design(design& sample) {
    l = sample.l;
    w = new int;
    *w = *(sample.w);
    h = sample.h;
  }

  // Destructor
  ~design() { delete w; }
};

// Driver Code
int main() {
  // Object of class first
  design first;

  // Passing Parameters
  first.set_dimension(13, 19, 26);

  // Calling display method
  first.show_data();

  // Copying the data of 'first' object to 'second'
  design second = first;

  // Calling display method again to show the 'second' object
  second.show_data();

  return 0;
}

输出:

The Length is = 13
Breadth  of the design = 19
Height = 26

The Length is = 13
Breadth  of the design = 19
Height = 26
--------------------------------
Process exited after 0.2031 seconds with return value 0
Press any key to continue . . .

结论

本文全面而详细地解释了 big 3 规则以及它如何影响编程。读者可以了解三大法则的必要性和重要性。

除此之外,还解释了一些新概念,例如深拷贝和浅拷贝,它们有几种实现方式。