Rule of Three, Five, Zero 실전
동적 자원의 얕은 복사와 이중 해제를 확인하고 복사·이동 특별 멤버 및 Rule of Zero로 소유권을 안전하게 설계합니다.
9장에서 생성자/소멸자와 함께 Rule of Three/Five/Zero를 소개했습니다.
이번 절에서는 “왜 필요한지”를 실제 코드 문제로 확인하고, 구현 패턴을 단계적으로 정리하겠습니다.
C++ · RULE OF ZERO
자원 소유권부터 복사·이동 정책을 정한다
소멸자 개수를 세기 전에 누가 자원을 소유하고 복사·이동을 허용할지 결정한다.
- 소유권 질문
표준 타입이 자원을 소유하면 Rule of Zero를 택한다.
- 복사 허용
독립 자원을 만드는 깊은 복사를 구현한다.
- 이동 허용
원본 계약과 noexcept를 함께 설계한다.
- 허용하지 않음
= 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개를 같이 설계해야 합니다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
#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
깊은 복사는 새 자원, 이동은 기존 자원의 새 소유자
두 행은 같은 시작 상태에서 선택하는 별개 연산입니다. 깊은 복사 후에는 두 독립 버퍼가, 이동 후에는 기존 버퍼를 소유한 새 객체가 남습니다.
깊은 복사 → 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개를 함께 고려합니다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
- 이동 생성자
- 이동 대입 연산자
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
#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로 되돌리는 것이 가장 읽기 쉽고 안전한 결론입니다.