본문 바로가기

Rule of Three, Five, Zero 실전

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

생성자와 소멸자에서 자원을 직접 소유하는 클래스는 소멸자만으로는 안전하지 않다고 했습니다. 객체를 복사하거나 옮길 때 그 자원을 어떻게 다룰지도 함께 정해야 하며, 그 지침이 Rule of Three, Five, Zero입니다. 이 글에서는 동적 배열을 소유하는 Buffer 클래스로 이 다섯 가지 특별 멤버 함수를 직접 구현해 보고, 그런 구현을 쓸 필요가 없게 만드는 Rule of 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로 선언합니다.

위 그림처럼 먼저 물어야 할 것은 클래스가 자원을 직접 소유하는지입니다. 직접 소유하지 않고 표준 라이브러리의 소유자에게 맡긴다면 Rule of Zero로 가고, 직접 소유한다면 복사와 이동을 각각 허용할지 정해 구현하거나 막습니다. 복사와 이동은 둘 중 하나를 고르는 것이 아니어서, 한 클래스가 둘 다 허용할 수도 있습니다.

얕은 복사의 문제

컴파일러가 만들어 주는 복사 생성자와 복사 대입은 멤버를 하나씩 복사합니다. 멤버가 원시 포인터라면 포인터의 값, 즉 주소만 복사되어 두 객체가 같은 배열을 가리키게 되는데, 이를 얕은 복사(shallow copy)라고 합니다.

shallow_copy.cpp
class Buffer {
private:
    int* data;
    int size;

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

// Buffer b2 = b1; 이라면 b1.data와 b2.data가 같은 배열을 가리킴

이 상태에서 두 객체가 사라지면 각자의 소멸자가 같은 배열에 delete[]를 실행해 이중 해제가 되고, 한쪽이 먼저 사라진 뒤 다른 쪽이 배열을 쓰면 해제된 메모리를 쓰게 됩니다. 문제는 주소가 같다는 것 자체가 아니라, 두 객체가 모두 그 배열을 해제할 권리를 가진다는 것입니다. 그래서 이 코드는 실행해 보지 않고 원인을 읽는 데만 씁니다.

Rule of Three와 Rule of Five

Rule of Three는 소멸자, 복사 생성자, 복사 대입 연산자를 함께 설계하라는 지침입니다. 복사를 허용한다면 원본과 별개의 새 배열을 할당해 값을 복사하는 깊은 복사(deep copy)를 구현합니다. C++11부터는 자원을 새로 할당하지 않고 소유자만 바꾸는 이동 생성자와 이동 대입 연산자까지 다섯 가지를 함께 다루며, 이를 Rule of Five라고 합니다.

다음은 다섯 가지를 모두 구현한 Buffer입니다.

buffer_rule_of_five.cpp
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <utility>

class Buffer {
private:
    int* data = nullptr;
    std::size_t size = 0;

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

    ~Buffer() { delete[] data; }

    Buffer(const Buffer& other)
        : data(other.size ? new int[other.size] : nullptr), size(other.size) {
        std::copy(other.data, other.data + other.size, data);
    }

    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            int* fresh = other.size ? new int[other.size] : nullptr;
            std::copy(other.data, other.data + other.size, fresh);
            delete[] data;
            data = fresh;
            size = other.size;
        }
        return *this;
    }

    Buffer(Buffer&& other) noexcept
        : data(std::exchange(other.data, nullptr)), size(std::exchange(other.size, 0)) {}

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = std::exchange(other.data, nullptr);
            size = std::exchange(other.size, 0);
        }
        return *this;
    }

    int& at(std::size_t i) {
        if (i >= size) {
            throw std::out_of_range("Buffer index");
        }
        return data[i];
    }

    std::size_t length() const { return size; }
};

int main() {
    Buffer a(3);
    a.at(0) = 7;

    Buffer b = a;
    b.at(0) = 9;
    std::cout << a.at(0) << ' ' << b.at(0) << '\n';

    Buffer c = std::move(a);
    std::cout << c.at(0) << ' ' << a.length() << ' ' << c.length() << '\n';

    b = c;
    c = std::move(b);
    std::cout << c.at(0) << ' ' << b.length() << '\n';
}
7 9
7 0 3
7 0

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.at(0)을 9로 바꿔도 a는 7을 유지합니다. 그림의 아래쪽처럼 이동 생성자는 새로 할당하지 않고 a의 배열 A를 c가 넘겨받으며, a는 nullptr과 0으로 비워집니다. std::exchange(x, v)는 C++14의 <utility> 함수로, x에 v를 넣고 원래 값을 돌려주어 이런 이전을 한 줄로 쓰게 해 줍니다.

복사 대입은 기존 배열을 먼저 지우지 않고, 새 배열을 할당해 값을 복사하는 일을 먼저 끝낸 뒤 기존 배열과 바꿉니다. 그래서 new가 std::bad_alloc을 던져도 대상 객체는 원래 상태를 유지합니다. this != &other는 b = b; 같은 자기 대입에서 쓸데없는 복사를 건너뛰는 검사입니다.

이동 생성자와 이동 대입 연산자의 매개변수 Buffer&&는 rvalue 참조(rvalue reference)라고 부르며, 곧 사라질 임시 객체나 std::move를 거친 식에 묶이는 참조입니다. 그래서 Buffer c = std::move(a);에서는 const Buffer&를 받는 복사 생성자보다 이 생성자가 더 잘 맞아 선택됩니다.

std::move(a)는 a를 옮기는 함수가 아니라, a를 이동해도 되는 식으로 바꾸어 이동 생성자가 선택되게 할 뿐입니다. 실제로 자원을 넘기는 일은 이동 생성자가 하며, 원본이 비워진다는 것도 이 Buffer가 정한 규칙입니다. 이동한 뒤의 원본은 다시 대입하거나 소멸시키는 것만 하는 것이 안전하며, 이 예제처럼 length()가 0이라고 기대할 수 있는 것은 그렇게 구현했기 때문입니다. rvalue 참조의 자세한 규칙은 move 의미론과 rvalue 참조에서 다룹니다.

이동 연산에 noexcept를 붙인 것도 중요합니다. std::vector는 원소를 더 큰 공간으로 옮길 때 이동 생성자에 noexcept가 있으면 이동을 쓰고, 없으면 복사할 수 있는 타입인 한 안전을 위해 복사를 씁니다. 복사할 수 없는 타입이라면 noexcept가 없어도 이동을 쓰지만, 그 이동이 예외를 던지면 원래 상태로 돌아간다는 보장이 사라집니다. 다만 noexcept는 정말 예외가 나가지 않는다는 약속이므로, 할당이 필요한 복사 연산처럼 실패할 수 있는 함수에는 붙이지 않습니다.

= default와 = delete로 정책 밝히기

특별 멤버 함수는 직접 구현하지 않고, 컴파일러가 만든 것을 쓰겠다는 = default나 쓰지 못하게 하는 = delete로 정책만 밝힐 수도 있습니다. = delete로 지운 함수는 선언으로 남아 있어서, 그것을 부르는 코드는 실행하기 전에 컴파일 오류가 됩니다. 또 복사 연산이나 소멸자를 직접 선언하면 컴파일러가 이동 연산을 자동으로 만들지 않으므로, 아래 클래스는 이동 두 가지를 = default로 따로 밝힙니다.

move_only.cpp
class FileHandle {
public:
    FileHandle() = default;
    ~FileHandle() = default;

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

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

이 클래스는 복사는 막고 이동만 허용합니다. 파일이나 잠금처럼 둘로 나눌 수 없는 자원을 맡는 클래스에 알맞은 정책이며, std::unique_ptr도 이렇게 동작합니다.

이런 선언이 필요한 이유 가운데 하나는 특별 멤버 함수끼리의 자동 생성 규칙이 서로 얽혀 있기 때문입니다. 소멸자나 복사 연산을 하나라도 직접 선언하면 컴파일러는 이동 연산을 만들어 주지 않고, 이동하려는 곳에서 복사가 대신 쓰입니다. 그래서 다섯 가지 가운데 하나를 직접 선언했다면, 나머지에도 = default나 = delete로 의도를 적어 두는 편이 안전합니다.

더 깊이: 표준에 남아 있는 Rule of Three의 흔적

Rule of Three는 지침이지만, 표준에도 같은 생각이 들어 있습니다. C++11부터 클래스에 사용자가 선언한 소멸자나 복사 대입 연산자가 있으면, 컴파일러가 복사 생성자를 암시적으로 만들어 주는 것은 권장되지 않는(deprecated) 기능입니다. 복사 대입 연산자도 마찬가지로, 사용자가 선언한 소멸자나 복사 생성자가 있을 때 암시적으로 만드는 것이 권장되지 않습니다. 기존 코드를 깨지 않으려고 지금도 만들어 주기는 하지만, 셋 가운데 하나를 직접 정의했다면 나머지도 따져 보라는 신호입니다.

반대로 이동 생성자나 이동 대입 연산자를 하나라도 선언하면, 암시적인 복사 생성자와 복사 대입 연산자는 삭제된 함수로 정의됩니다. 이동 연산만 직접 선언한 클래스가 저절로 이동 전용이 되는 것은 이 규칙 때문입니다.

GCC는 권장되지 않는 이 생성을 경고로 알려 줍니다. -Wextra에 포함된 -Wdeprecated-copy는 사용자가 만든 복사 생성자나 복사 대입 연산자가 있는데 나머지 하나가 암시적으로 만들어질 때 경고하고, -Wdeprecated-copy-dtor를 켜면 사용자가 만든 소멸자가 있는 경우까지 경고합니다. 두 옵션은 GCC의 C++ 옵션 문서에 설명되어 있습니다.

Rule of Zero

가장 권하는 방법은 자원을 직접 소유하지 않는 것입니다. 배열은 std::vector, 문자열은 std::string, 단독 소유는 std::unique_ptr에 맡기면, 클래스는 다섯 가지 특별 멤버 함수를 하나도 직접 쓰지 않아도 됩니다. 이것이 Rule of Zero입니다.

rule_of_zero.cpp
#include <memory>
#include <string>
#include <utility>
#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가 복사를 보장해 주는 것은 아닙니다.

표준 타입의 복사와 이동은 다음처럼 동작합니다.

standard_copy_move.cpp
#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
1 7

copied는 별도의 원소를 가지므로 9로 바꿔도 moved의 첫 값 1에는 영향이 없습니다. 표준은 이동한 뒤의 vector가 비어 있다고 보장하지 않으므로, original은 새 값을 대입한 뒤에 다시 씁니다. 반면 std::unique_ptr은 이동한 뒤 원본이 nullptr이 된다고 정해져 있어 source == nullptr이 1로 출력됩니다. 이처럼 이동한 뒤의 상태는 타입마다 정해진 대로만 기대합니다.

정리

  • 원시 포인터를 멤버로 가진 클래스의 기본 복사는 주소만 복사하는 얕은 복사여서 이중 해제를 일으킵니다.
  • 자원을 직접 소유하면 소멸자, 복사 생성자, 복사 대입을 함께 설계하고, C++11부터는 이동 생성자와 이동 대입까지 다섯 가지를 다룹니다.
  • 복사는 새 자원을 할당해 값을 복제하고, 이동은 자원의 소유자를 바꾸며 원본을 정해진 상태로 비웁니다.
  • 복사 대입은 새 자원을 먼저 준비한 뒤 바꾸어 실패해도 원래 상태를 지키고, 이동 연산에는 noexcept를 붙입니다.
  • 소멸자나 복사 연산을 직접 선언하면 이동 연산이 만들어지지 않으므로, 나머지에 = default나 = delete로 정책을 밝힙니다.
  • 자원을 std::vector, std::string, std::unique_ptr에 맡기는 Rule of Zero를 먼저 고려하며, 이때 복사 여부는 멤버를 따릅니다.

연습 문제

1. 값과 멤버를 바꾸어 보기

standard_copy_move.cpp에서 original을 {3, 5}로, copied[0]에 넣는 값을 8로 바꾸면 첫 줄은 무엇인가요? 따로, UserProfile a; UserProfile b = a;는 컴파일되나요? UserProfile b = std::move(a);는 어떤가요?

해설 보기

첫 줄은 8 3 4입니다. 원본의 첫 값 3은 moved로 넘어가고, copied만 8로 바뀝니다. UserProfile b = a;는 std::unique_ptr 멤버를 복사할 수 없어 컴파일 오류이고, std::move(a)로 이동하는 것은 모든 멤버를 이동할 수 있으므로 컴파일됩니다.

2. 이동 연산을 지우면

buffer_rule_of_five.cpp에서 이동 생성자와 이동 대입 연산자를 모두 지우면 둘째 출력 줄은 어떻게 되나요?

해설 보기

이동 생성자가 없으면 Buffer c = std::move(a);에서 복사 생성자가 대신 선택되어 c가 새 배열을 할당해 값을 복사합니다. a는 비워지지 않으므로 둘째 줄은 7 3 3이 됩니다. 프로그램은 여전히 올바르게 동작하지만, 옮기면 되는 곳에서도 매번 복사하게 됩니다.

3. 자기 대입 검사를 지우면

복사 대입 연산자에서 if (this != &other) 검사를 지우고 b = b;를 실행하면 문제가 생기나요? 기존 배열을 먼저 delete[]한 뒤 복사하도록 순서를 바꾸면 어떤가요?

해설 보기

현재 구현은 새 배열에 값을 먼저 복사한 뒤 기존 배열을 지우므로, 검사를 지워도 자기 자신의 값을 새 배열로 복사하고 옛 배열을 지울 뿐이어서 결과는 올바릅니다. 검사는 쓸데없는 복사를 줄이는 최적화입니다. 반대로 delete[] data;를 먼저 하면, 자기 대입에서 other.data도 방금 지운 배열이어서 해제된 메모리에서 복사하는 정의되지 않은 동작이 됩니다.

이것으로 10장의 객체 지향 프로그래밍 심화를 마칩니다. 다음 장의 함수 템플릿에서는 타입을 매개변수로 받아, 여러 타입에 대해 같은 코드를 한 번만 작성하는 템플릿을 다룹니다.