C++의 3법칙

Jay Shaw 2023년10월12일
  1. C++의 3대 법칙 이해하기
  2. C++에서 생성자의 암시적 정의
  3. C++에서 생성자의 명시적 정의
  4. 결론
C++의 3법칙

빅 3의 법칙은 전 세계적으로 가장 인기 있는 코딩 관용구 중 하나입니다. 법에 따르면 C++에는 필요한 경우에도 함께 선언해야 하는 몇 가지 특수 기능이 있습니다.

이러한 함수는 복사 생성자, 복사 할당 연산자 및 소멸자입니다.

법에 따르면 세 가지 특수 기능 중 하나가 프로그램에서 선언되면 다른 두 개도 따라야 합니다. 그렇지 않으면 프로그램에서 심각한 메모리 누수가 발생합니다.

이 기사에서는 빅 3의 법칙과 이를 해결하는 방법에 대해 자세히 설명합니다.

C++의 3대 법칙 이해하기

동적으로 할당된 자원이 클래스에 추가될 때 어떤 일이 발생하는지를 고려하여 Big 3에 대해 알아보아야 합니다.

아래 예제에서 포인터에 메모리 공간을 할당하는 생성자 클래스가 생성됩니다.

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

댕글링 포인터와 같은 문제를 피하기 위해 프로그래머는 필요한 모든 생성자를 명시적으로 선언해야 합니다. 이것이 3대 규칙에 대한 것입니다.

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++에서 명시적으로 정의된 생성자에서 소멸자의 역할

함수 개체에 할당된 메모리를 지우려면 소멸자를 만들어야 합니다. 그렇지 않으면 메모리 누수가 발생할 위험이 있습니다.

암시적 생성자에서는 소멸자를 선언한 후에도 문제가 지속됩니다. 메모리에 할당된 개체가 복사되면 복사된 개체가 원본 개체와 동일한 메모리를 가리키기 때문에 문제가 발생합니다.

하나가 소멸자에서 메모리를 삭제하면 다른 하나는 유효하지 않은 메모리에 대한 포인터를 갖게 되며 이를 사용하려고 할 때 상황이 복잡해집니다.

결과적으로 명시적으로 정의된 복사 생성자를 생성하여 새 객체에 제거할 메모리 조각을 제공해야 합니다.

아래 프로그램은 3대 원칙을 따르는 데모 클래스를 보여줍니다.

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 클래스가 있습니다. 기본 생성자는 입력이 제공되지 않으면 null 값을 반환하는 반면 매개 변수화된 생성자는 값을 초기화하고 복사합니다.

여기에는 m_Name 변수가 리소스를 할당할 수 없을 때 예외가 발생하는 예외 처리 방법(try-catch)이 포함됩니다.

소멸자 다음에 원본 개체의 복사본을 만드는 복사 생성자가 만들어집니다.

#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 . . .

결론

이 기사는 빅 3의 법칙과 그것이 프로그래밍에 미치는 영향에 대한 포괄적이고 상세한 설명을 제공합니다. 독자들은 빅 3의 법칙의 필요성과 중요성을 배우게 됩니다.

이와 함께 여러 구현이 있는 심층 복사 및 얕은 복사와 같은 몇 가지 새로운 개념이 설명됩니다.