본문 바로가기

Rule of Three, Five, Zero 실전

동적 자원의 얕은 복사와 이중 해제를 확인하고 복사·이동 특별 멤버 및 Rule of Zero로 소유권을 안전하게 설계합니다.

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

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

C++ · RULE OF ZERO

자원 소유권부터 복사·이동 정책을 정한다

소멸자 개수를 세기 전에 누가 자원을 소유하고 복사·이동을 허용할지 결정한다.

직접 소유 여부와 연산별 자원 정책원본의5정책노드4관계를유지한다. 직접소유하지않는NO경로는RuleZero이며 직접소유시복사허용·이동허용·연산별금지를따로판단한다. COPY와MOVE는배타분기가아니어서RuleFive가함께허용할수있고DELETE는의미없는해당연산만금지한다. 아래정책노드끼리실행간선이없으며nullptr이모든타입의이동후상태라는보장도아니다.직접 자원 소유?NO: 표준 멤버 위임직접 소유: 연산별 판단Rule of Zero표준 RAII 멤버멤버의 복사·이동 계약깊은 복사 구현독립 자원 확보이동 구현소유권 이전noexcept 검토연산별 = delete의미 없는 복사·이동 금지NO복사 허용이동 허용해당 연산 금지복사와 이동은 함께 허용 가능서로 배타적인 네 선택지 아님
특별 멤버 정책은 직접 소유 여부에서 갈린다 표준 RAII 멤버는 Rule of Zero로 간다. 직접 소유하는 경우 복사·이동 허용 여부를 각각 판단하여 구현하거나 해당 연산을 삭제한다. COPY와 MOVE는 배타적 분기가 아니며 함께 허용할 수 있다. 자원을 직접 소유하는가?소유권 계약부터 확인Rule of Zero표준 RAII 멤버에 위임깊은 복사 구현독립 자원을 새로 확보이동 구현handle 이전noexcept 검토복사·이동 삭제의미 없는 연산을 금지NOCOPYMOVEDELETE
  1. 소유권 질문

    표준 타입이 자원을 소유하면 Rule of Zero를 택한다.

  2. 복사 허용

    독립 자원을 만드는 깊은 복사를 구현한다.

  3. 이동 허용

    원본 계약과 noexcept를 함께 설계한다.

  4. 허용하지 않음

    = delete로 정책을 명시한다.

직접 소유가 필요하지 않다면 검증된 RAII 타입에 위임하는 Rule of Zero를 우선 검토합니다. COPY와 MOVE는 함께 허용할 수 있으며, 금지할 연산만 개별적으로 = delete로 선언합니다.


왜 필요한가: 얕은 복사(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 포인터만 복사됨 (얕은 복사)

이 첫 코드 블록은 위험 원인을 읽는 발췌입니다. 두 Buffer를 실제로 얕게 복사해 소멸시키는 실행 예제로 쓰지 마세요. 같은 주소를 가진다고 같은 객체인 것은 아니며, 두 객체가 각각 delete[]를 실행할 권리를 갖게 되는 것이 문제입니다. 이어지는 Rule of Three 예제는 그 복사 정책을 새 자원 확보로 바꿉니다.


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 > 0 ? n : 0) {}

    ~Buffer() {
        delete[] data;
    }

    // 복사 생성자 (깊은 복사)
    Buffer(const Buffer& other) : data(other.size > 0 ? new int[other.size] : nullptr), size(other.size) {
        if (size > 0) 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;
        if (other.size > 0) std::copy(other.data, other.data + other.size, newData);

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

C++ · COPY & MOVE OWNERSHIP

깊은 복사는 새 자원, 이동은 기존 자원의 새 소유자

두 행은 같은 시작 상태에서 선택하는 별개 연산입니다. 깊은 복사 후에는 두 독립 버퍼가, 이동 후에는 기존 버퍼를 소유한 새 객체가 남습니다.

깊은 복사와 이동의 별도 자원 경로원 desktop의 두 독립 세 노드/두 간선 경로 총6노드4간선이다. 복사는 원본A유지→새B할당→대상B소유, 이동은 원본A→소유권 이전과 원본null/0→대상A소유다. 이동의null/0은 본문Buffer구현에 한정되며 모든 타입의 보장이 아니다. 구획 간 간선이 없으므로 복사 뒤 이동을 순서대로 수행한다는 그림이 아니다.깊은 복사 · 별도 연산원본 sourcebuffer A 소유값을 복제새 buffer B 할당복사 대상buffer B 소유원본은 A 유지이동 · 별도 연산원본 sourcebuffer A 소유소유권 이전원본 data = nullptr원본 size = 0이동 대상기존 buffer A 소유같은 출발 상태의 두 별도 경로두 구획 사이 실행 간선 없음
Buffer의 깊은 복사와 이동 비교깊은 복사는 원본 A를 유지하면서 새 B에 값을 복제한다. 본문의 이동 구현은 A를 이동 대상에 이전하고 원본 포인터를 null로 재설정한다.깊은 복사 · 자원 두 개원본 sourcebuffer A 소유값을 복제새 buffer B 할당복사 대상buffer B 소유이동 · 자원 한 개의 소유자 변경원본 sourcebuffer A 소유handle을 이전원본 data = nullptr이동 대상기존 buffer A 소유

깊은 복사 → A와 B 두 버퍼

원본은 A를 계속 소유합니다. 대상은 새 B를 할당하고 값을 복제하므로 두 버퍼를 독립적으로 변경할 수 있습니다.

이동 → 대상이 기존 A 소유

본문 Buffer 구현은 handle을 대상에 넘기고 원본을 data = nullptr, size = 0으로 둡니다.

이동 후 상태: 원본은 소멸·재대입 가능한 계약을 지킵니다. 이 그림의 null 상태는 본문 Buffer 구현의 선택이며 모든 타입이 반드시 비워진다는 뜻은 아닙니다.

복사 대입은 “대상 메모리를 먼저 지운다”가 아니라 새 메모리 확보와 복제를 먼저 마친 뒤 기존 자원을 교체합니다. b = b에서는 this와 other가 같은 객체이므로 곧바로 반환합니다. 크기 0은 nullptr와 0으로 표현하며, 양수일 때만 원본 요소를 복사하므로 빈 버퍼에서도 유효하지 않은 범위를 만들지 않습니다.


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 > 0 ? n : 0) {}
    ~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를 붙이면 표준 컨테이너가 재할당 시 이동을 더 적극적으로 선택할 수 있어 성능과 예외 안전성에 유리합니다.

위 Rule of Five 블록은 이동 부분을 보여 주는 발췌입니다. 복사 생성자·복사 대입은 선언만 있으므로 그대로 복사를 호출하면 정의를 찾지 못해 링크가 실패합니다. 완성하려면 같은 Buffer 클래스의 두 선언을 앞 Rule of Three의 해당 함수 정의로 교체하고 algorithm 헤더를 포함합니다. 두 Buffer 클래스 전체를 한 파일에 이어 붙이면 재정의 오류가 납니다.

std::move를 사용하는 호출 코드에는 utility 헤더도 필요합니다. std::move(source) 자체는 자원을 옮기는 함수가 아니라 이동 가능한 표현식으로 바꾸는 도구입니다. 여기서는 그 표현식으로 이동 생성자·대입을 호출할 때 data와 size를 넘기고 원본을 nullptr·0으로 설정합니다. 원본이 이후 비워지는 것은 이 Buffer 구현의 계약입니다. noexcept는 실제로 예외가 밖으로 나가지 않는다는 약속이므로 모든 이동 함수에 무조건 붙이지 않습니다. std::move의 계약과 복사·이동 생성 규칙도 이 구분을 설명합니다.


=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 <utility>
#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)) {}
};

이 경우 복사/이동/소멸 대부분을 수동으로 관리할 필요가 크게 줄어듭니다. badgeId가 std::unique_ptr이므로 UserProfile의 복사는 삭제되고 이동은 가능합니다. Rule of Zero가 복사 가능성을 보장하는 것은 아닙니다.

예시 다음 별도 프로그램은 표준 타입이 복사와 이동을 대신 관리하는 경우입니다. 앞의 Buffer나 UserProfile 정의를 붙이지 않고 이 코드만 실행합니다.

표준 타입으로 복사와 이동을 관찰하는 완전한 예제
#include <iostream>
#include <memory>
#include <utility>
#include <vector>

int main() {
    std::vector<int> original{1, 2};
    auto copied = original;
    copied[0] = 9;
    auto moved = std::move(original);
    original = {4}; // 이동 후 원본에 새 값을 대입해 재사용
    std::cout << copied[0] << " " << moved[0] << " " << original[0] << "\n";

    auto source = std::make_unique<int>(7);
    auto owner = std::move(source);
    std::cout << (source == nullptr) << " " << *owner << "\n";
}

첫 출력은 9 1 4입니다. copied는 별도 요소들을 가지므로 9로 바꿔도 이동 대상의 첫 값 1은 그대로입니다. 이동 직후 vector 원본이 반드시 어떤 길이라고 가정하지 않고 새 값 4를 대입한 뒤 사용합니다. 두 번째 출력은 1 7이며 unique_ptr의 이동 후 원본은 nullptr이고 새 owner가 7을 소유합니다. 이동 후 상태는 각 타입의 계약을 확인해야 합니다.

스스로 확인하기 첫 줄의 vector 값을 {3, 5}로, copied의 대입값을 8로 바꾸고 원본 재대입 {4}는 유지하면 첫 출력은 어떻게 달라질까요? 별도로 위 UserProfile 정의 뒤에서 UserProfile a; UserProfile b = a;를 쓰면 빌드가 성공할까요? UserProfile b = std::move(a);로 바꾸는 실험과 구분하세요.

독립 자원과 멤버별 생성 정책의 풀이

vector 출력은 8 3 4입니다. 원본의 3은 moved로 이어지고 복사본만 8이 됩니다. UserProfile의 보통 복사는 unique_ptr 멤버를 복사할 수 없어 컴파일에서 거부됩니다. std::move(a)로 이동 생성하는 별도 실험은 멤버들의 이동을 사용하므로 가능합니다. 기본 생성한 a의 badgeId는 비어 있으므로 그 포인터를 역참조하는 실험은 추가하지 않습니다.

앞의 소유 정책 그림은 직접 자원 소유 여부와 필요한 연산을 점검합니다. 복사와 이동은 서로 배타적인 선택이 아니며 하나의 클래스가 둘 다 허용할 수 있습니다. 금지 여부도 연산별로 판단합니다. Rule of Zero는 표준 멤버가 정한 정책을 따르고, 직접 소유하는 클래스는 허용한 연산의 자원 계약을 직접 구현해야 합니다.


연산자 오버로딩과의 연결

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

즉, 연산자 오버로딩을 다룰 때도 단순 문법이 아니라 자원 소유 정책과 함께 설계해야 합니다.

관련 내용은 ‘연산자 오버로딩 기초’와 함께 읽으면 좋습니다.


자원 소유 규칙 정리

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

마지막으로 직접 자원을 소유하는 클래스를 만들 때는 특별 멤버 함수와 선언 정책을 한 번에 점검하면 실수를 줄일 수 있습니다.

Rule of Three/Five/Zero는 외울 함수 목록이 아니라 자원 소유 여부와 복사·이동 정책을 한 묶음으로 정하는 기준입니다.

가능하면 직접 소유를 표준 RAII 타입에 맡겨 Rule of Zero로 되돌리는 것이 가장 읽기 쉽고 안전한 결론입니다.