Computer >> 컴퓨터 >  >> 프로그래밍 >> C++

C++ 3의 법칙(Rule of Three) 완벽 정리: 개념부터 실전 예제까지


3의 법칙(Rule of Three)은 C++에서 예외 안전(exception-safe)한 코드를 작성하기 위한 대표적인 경험 법칙(rule of thumb) 중 하나입니다. 이 법칙은 클래스의 특수 멤버 함수들을 어떻게 정의하고 활용해야 하는지를 규정합니다.

3의 법칙은 빅 쓰리(Big Three) 또는 Law of Big Three라고도 불리며, 클래스가 아래 세 가지 멤버 함수 중 하나라도 명시적으로 정의한다면 나머지 두 가지도 함께 정의해야 한다고 규정합니다.

  • 소멸자(destructor)
  • 복사 생성자(copy constructor)
  • 복사 대입 연산자(copy assignment operator)

왜 세 가지를 함께 정의해야 할까?

위 세 가지는 모두 클래스의 특수 멤버 함수(special member functions)에 해당합니다. 프로그래머가 하나도 정의하지 않으면 컴파일러가 암시적(implicit) 버전을 자동으로 생성해 줍니다. 그러나 셋 중 하나라도 직접 정의했다면, 그 클래스는 동적으로 할당한 자원을 관리하고 있을 가능성이 높습니다. 이 경우 나머지 두 함수의 암시적 버전은 부적절하므로 반드시 재정의해야 합니다.

핵심 원인은 컴파일러가 암시적으로 생성하는 복사 생성자와 복사 대입 연산자가 멤버 변수를 얕은 복사(shallow copy), 즉 포인터 값을 그대로 복사하기 때문입니다. 클래스가 동적으로 할당된 자원을 가리키는 포인터를 멤버로 갖고 있다면, 복사본과 원본이 같은 메모리를 공유하게 되므로 새 메모리를 할당해 내용을 복사하는 깊은 복사(deep copy)가 필요합니다.

기본 소멸자는 사용이 끝난 객체를 해제하는 역할을 합니다. 복사 생성자가 정의되어 있지 않으면 소멸자는 두 번 실행됩니다. 한 번은 복사본을 담고 있는 객체에 대해, 다른 한 번은 멤버가 복사된 원본 객체에 대해 호출되는 것이죠. 결국 동일한 포인터가 두 번 해제되며 프로그램이 비정상 종료됩니다. 이 문제를 피하려면 세 가지 함수를 명시적으로 정의해야 합니다.

문제 상황 예제: 복사 생성자 없이 소멸자만 정의한 경우

아래 예제에는 복사 생성자와 복사 대입 연산자가 없고 소멸자만 존재합니다.

#include <stdio.h>

class Numbers {
private:
    int num;
    int* ptr;
public:
    // 일반 생성자: 동적 메모리 할당
    Numbers(int n, int* p) {
        num = n;
        ptr = new int[num];
    }
    // 소멸자: 메모리 해제
    ~Numbers() {
        delete ptr;
        ptr = NULL;
    }
};

int main() {
    int arr[4] = { 11, 22, 33, 44 };
    Numbers Num1(4, arr);
    // 암시적 복사 생성자 사용 → 문제 발생!
    Numbers Num2(Num1);
    return 0;
}

실행 결과

*** Error in `./a.out': double free or corruption (fasttop): 0x0000000001f46c20 ***
Aborted
  • main()의 스코프가 끝나면서 Num2와 Num1의 소멸자가 차례로 호출됩니다(C++ 객체는 생성의 역순으로 파괴됩니다).

  • 컴파일러가 제공한 기본 복사 생성자는 포인터 ptr의 값(메모리 주소)만 복사할 뿐 새로운 메모리를 할당하지 않습니다. 따라서 Num1과 Num2의 ptr은 같은 메모리를 가리키게 됩니다.

  • 결국 첫 번째 객체가 소멸하며 ptr을 해제하고, 두 번째 객체가 소멸할 때 이미 해제된 같은 메모리를 다시 해제(double free)하려다 프로그램이 크래시됩니다.

참고로 배열 형태로 동적 할당한 메모리는 delete가 아니라 delete[]로 해제하는 것이 올바른 방법입니다.

3의 법칙을 적용한 올바른 구현

세 가지 특수 멤버 함수를 모두 정의하고, 복사 시 깊은 복사를 수행하도록 수정하면 문제가 사라집니다.

#include <iostream>

class Numbers {
private:
    int num;
    int* ptr;
public:
    // 일반 생성자
    Numbers(int n, const int* p) : num(n), ptr(new int[n]) {
        for (int i = 0; i < num; ++i)
            ptr[i] = p[i];
    }

    // 1. 소멸자
    ~Numbers() {
        delete[] ptr;
    }

    // 2. 복사 생성자 (깊은 복사)
    Numbers(const Numbers& other) : num(other.num), ptr(new int[other.num]) {
        for (int i = 0; i < num; ++i)
            ptr[i] = other.ptr[i];
    }

    // 3. 복사 대입 연산자 (깊은 복사 + 자기 대입 검사)
    Numbers& operator=(const Numbers& other) {
        if (this != &other) {
            delete[] ptr;
            num = other.num;
            ptr = new int[num];
            for (int i = 0; i < num; ++i)
                ptr[i] = other.ptr[i];
        }
        return *this;
    }
};

이렇게 구현하면 복사되는 객체마다 독립적인 메모리를 가지므로 이중 해제(double free) 문제가 발생하지 않습니다.

마치며: 현대 C++에서는 어떻게 될까?

C++11에서 이동 생성자(move constructor)와 이동 대입 연산자(move assignment operator)가 추가되면서, 3의 법칙은 5의 법칙(Rule of Five)으로 확장되었습니다. 또한 std::vector, std::string, std::unique_ptr 같은 RAII 래퍼를 활용하면 컴파일러가 생성한 기본 함수만으로도 안전하게 동작하는데, 이를 0의 법칙(Rule of Zero)이라고 부릅니다. 다만 raw 포인터로 자원을 직접 관리해야 하는 상황이라면 여전히 3의 법칙(5의 법칙)을 반드시 지켜야 합니다.