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개를 같이 설계해야 합니다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
#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개를 함께 고려합니다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
- 이동 생성자
- 이동 대입 연산자
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
#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(표준 타입 활용)가 가장 안전하고 유지보수에 유리하다.