icon

안동민 개발노트

10장 : 객체 지향 프로그래밍 심화

Rule of Three, Five, Zero 실전


9장에서 생성자/소멸자와 함께 Rule of Three/Five/Zero를 소개했습니다.

이번 절에서는 “왜 필요한지”를 실제 코드 문제로 확인하고, 구현 패턴을 단계적으로 정리하겠습니다.


왜 필요한가: 얕은 복사(Shallow Copy) 문제

클래스가 동적 자원을 직접 소유할 때, 컴파일러 기본 복사는 포인터 값만 복사하는 얕은 복사가 될 수 있습니다.

이 경우 두 객체가 같은 메모리를 가리키게 되어 이중 해제(double delete) 같은 치명적 오류가 발생합니다.

얕은 복사로 인한 위험한 코드
class Buffer {
private:
    int* data;
    int size;

public:
    Buffer(int n) : data(new int[n]), size(n) {}
    ~Buffer() { delete[] data; }
};

// 복사 생성자/복사 대입을 직접 정의하지 않으면
// Buffer b2 = b1; 에서 data 포인터만 복사됨 (얕은 복사)

Rule of Three: 소멸자, 복사 생성자, 복사 대입

자원을 직접 소유하면 보통 아래 3개를 같이 설계해야 합니다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자
Rule of Three 구현 예시
#include <algorithm>
#include <iostream>

class Buffer {
private:
    int* data;
    int size;

public:
    Buffer(int n = 0) : data(n > 0 ? new int[n] : nullptr), size(n) {}

    ~Buffer() {
        delete[] data;
    }

    // 복사 생성자 (깊은 복사)
    Buffer(const Buffer& other) : data(other.size > 0 ? new int[other.size] : nullptr), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }

    // 복사 대입 연산자 (깊은 복사 + 자기 대입 방어)
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;

        int* newData = other.size > 0 ? new int[other.size] : nullptr;
        std::copy(other.data, other.data + other.size, newData);

        delete[] data;
        data = newData;
        size = other.size;
        return *this;
    }
};

Rule of Five: 이동 생성자, 이동 대입 추가

C++11 이후에는 이동 시맨틱까지 포함해 5개를 함께 고려합니다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자
  • 이동 생성자
  • 이동 대입 연산자
Rule of Five 구현 핵심
class Buffer {
private:
    int* data;
    int size;

public:
    Buffer(int n = 0) : data(n > 0 ? new int[n] : nullptr), size(n) {}
    ~Buffer() { delete[] data; }

    Buffer(const Buffer& other);              // copy ctor
    Buffer& operator=(const Buffer& other);   // copy assign

    // move ctor
    Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // move assign
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
        return *this;
    }
};

이동 연산에 noexcept를 붙이면 표준 컨테이너가 재할당 시 이동을 더 적극적으로 선택할 수 있어 성능과 예외 안전성에 유리합니다.


=default, =delete로 의도 명확히 하기

특별 멤버 함수를 “자동 생성 허용/금지”로 명확히 표현할 수 있습니다.

특별 멤버 함수 제어
class NonCopyableFile {
public:
    NonCopyableFile() = default;
    ~NonCopyableFile() = default;

    NonCopyableFile(const NonCopyableFile&) = delete;
    NonCopyableFile& operator=(const NonCopyableFile&) = delete;

    NonCopyableFile(NonCopyableFile&&) noexcept = default;
    NonCopyableFile& operator=(NonCopyableFile&&) noexcept = default;
};

이 방식은 “복사는 금지하고 이동만 허용” 같은 정책을 코드 수준에서 강하게 보장합니다.


Rule of Zero: 가장 권장되는 현대적 접근

핵심 아이디어는 단순합니다.

  • 직접 new/delete를 다루지 않고
  • 자원 관리를 표준 라이브러리 타입에 맡긴다

예: std::string, std::vector, std::unique_ptr

Rule of Zero 스타일
#include <memory>
#include <string>
#include <vector>

class UserProfile {
private:
    std::string name;
    std::vector<int> scores;
    std::unique_ptr<int> badgeId;

public:
    UserProfile() = default;
    UserProfile(std::string n, std::vector<int> s, int id)
        : name(std::move(n)), scores(std::move(s)), badgeId(std::make_unique<int>(id)) {}
};

이 경우 복사/이동/소멸 대부분을 수동으로 관리할 필요가 크게 줄어듭니다.


연산자 오버로딩과의 연결

대입 연산자 오버로딩(operator=)은 Rule of Three/Five의 일부입니다.

즉, 연산자 오버로딩을 다룰 때도 단순 문법이 아니라 자원 소유 정책과 함께 설계해야 합니다. 관련 내용은 ‘연산자 오버로딩 기초’와 함께 읽으면 좋습니다.


자원 소유 규칙 정리

  • 자원을 직접 소유하면 Rule of Three/Five를 반드시 고려해야 한다.
  • 이동 연산자는 가능하면 noexcept를 붙인다.
  • =default, =delete로 복사/이동 정책을 명시한다.
  • 현대 C++에서는 Rule of Zero(표준 타입 활용)가 가장 안전하고 유지보수에 유리하다.

목차