3의 법칙/5의 법칙/0의 법칙(Rule of three/five/zero)
3의 법칙/5의 법칙/0의 법칙(Rule of three/five/zero)
클래스가 동적 메모리 같은 자원(resource)을 직접 다루게 되면, 복사·이동·소멸을 담당하는 특수 멤버 함수들을 함께 신경 써야 해요. C++ 커뮤니티에서는 이걸 '3의 법칙', '5의 법칙', '0의 법칙'이라는 이름으로 정리해서 쓰고 있어요.
출처: cppreference
본문
3의 법칙(Rule of three)
클래스가 사용자 정의 소멸자(destructor), 사용자 정의 복사 생성자(copy constructor), 또는 사용자 정의 복사 대입 연산자(copy assignment operator)를 요구한다면, 그것은 거의 확실히 세 개 모두를 요구해요.
C++는 값 전달/반환, 컨테이너 조작 등 다양한 상황에서 사용자 정의 타입의 객체를 복사하고 복사 대입해요. 이때 이 특수 멤버 함수들이, 접근할 수 있으면 호출되며, 사용자 정의가 아니라면 컴파일러가 암시적으로 정의해요.
클래스가 그 핸들이 비클래스 타입 객체인 자원(원시 포인터, POSIX 파일 디스크립터 등)을 관리하는데, 그 자원을 관리하는 소멸자가 아무것도 하지 않고 복사 생성자/대입 연산자가 '얕은 복사'(shallow copy)를 수행한다면(내부 자원을 복제하지 않고 핸들의 값만 복사), 암시적으로 정의된 특수 멤버 함수들은 사용하면 안 돼요.
#include <cstddef>
#include <cstring>
#include <iostream>
#include <utility>
class rule_of_three
{
char* cstring; // 동적으로 할당된 메모리 블록의 핸들로 쓰는 원시 포인터
public:
explicit rule_of_three(const char* s = "") : cstring(nullptr)
{
if (s)
{
cstring = new char[std::strlen(s) + 1]; // 할당
std::strcpy(cstring, s); // 채워 넣음
}
}
~rule_of_three() // I. 소멸자
{
delete[] cstring; // 해제
}
rule_of_three(const rule_of_three& other) // II. 복사 생성자
: rule_of_three(other.cstring) {}
rule_of_three& operator=(const rule_of_three& other) // III. 복사 대입
{
// 간결함을 위해 copy-and-swap으로 구현
// (이는 저장 공간 재사용을 막는다는 점에 주의)
rule_of_three temp(other);
std::swap(cstring, temp.cstring);
return *this;
}
const char* c_str() const // 접근자
{
return cstring;
}
};
int main()
{
rule_of_three o1{"abc"};
std::cout << o1.c_str() << ' ';
auto o2{o1}; // II. 복사 생성자 사용
std::cout << o2.c_str() << ' ';
rule_of_three o3("def");
std::cout << o3.c_str() << ' ';
o3 = o2; // III. 복사 대입 사용
std::cout << o3.c_str() << '\n';
} // I. 모든 소멸자가 여기서 호출됨
출력:
abc abc def abc
복사 가능한 핸들을 통해 복사 불가능한 자원을 관리하는 클래스는, 복사 대입과 복사 생성자를 private로 선언하고 정의를 제공하지 않거나(C++11까지), 복사 대입과 복사 생성자를 = delete로 정의해야 해요(C++11부터). 이것도 3의 법칙의 또 다른 적용이에요. 둘 중 하나만 지우고 나머지는 암시적으로 정의되게 두는 것은 보통 틀린 일이에요.
5의 법칙(Rule of five)
사용자 정의(선언만 된 = default나 = delete 포함) 소멸자, 복사 생성자, 또는 복사 대입 연산자가 존재하면, 이동 생성자(move constructor)와 이동 대입 연산자(move assignment operator)의 암시적 정의가 막혀요. 따라서 이동 의미론이 바람직한 클래스는 다섯 개의 특수 멤버 함수를 모두 선언해야 해요.
class rule_of_five
{
char* cstring; // 동적으로 할당된 메모리 블록의 핸들로 쓰는 원시 포인터
public:
explicit rule_of_five(const char* s = "") : cstring(nullptr)
{
if (s)
{
cstring = new char[std::strlen(s) + 1]; // 할당
std::strcpy(cstring, s); // 채워 넣음
}
}
~rule_of_five() // 소멸자
{
delete[] cstring; // 해제
}
rule_of_five(const rule_of_five& other) // 복사 생성자
: rule_of_five(other.cstring) {}
rule_of_five(rule_of_five&& other) noexcept // 이동 생성자
: cstring(std::exchange(other.cstring, nullptr)) {}
rule_of_five& operator=(const rule_of_five& other) // 복사 대입
{
// 간결함을 위해 임시 복사본에서의 이동 대입으로 구현
// (이는 저장 공간 재사용을 막는다는 점에 주의)
return *this = rule_of_five(other);
}
rule_of_five& operator=(rule_of_five&& other) noexcept // 이동 대입
{
std::swap(cstring, other.cstring);
return *this;
}
// // 또는, 두 대입 연산자를 copy-and-swap 구현으로 대체할 수도 있다.
// // 이 구현 역시 복사 대입에서 저장 공간을 재사용하지 못한다.
// rule_of_five& operator=(rule_of_five other) noexcept
// {
// std::swap(cstring, other.cstring);
// return *this;
// }
};
3의 법칙과 달리, 이동 생성자와 이동 대입을 제공하지 않는 것은 보통 오류가 아니라 놓친 최적화 기회예요.
0의 법칙(Rule of zero)
사용자 정의 소멸자, 복사/이동 생성자 또는 복사/이동 대입 연산자를 가진 클래스는 오직 소유권(ownership)만을 다뤄야 해요(이는 단일 책임 원칙에서 따라 나와요). 그 외의 클래스는 사용자 정의 소멸자, 복사/이동 생성자, 복사/이동 대입 연산자를 가져서는 안 돼요.
이 규칙은 C++ 코어 가이드라인의 C.20: 기본 연산 정의를 피할 수 있다면 피하라에도 나타나요.
class rule_of_zero
{
std::string cppstring;
public:
rule_of_zero(const std::string& arg) : cppstring(arg) {}
};
기본 클래스가 다형적(polymorphic)으로 쓰이도록 의도되었다면, 그 소멸자는 public이고 virtual로 선언해야 할 수 있어요. 이렇게 하면 암시적 이동이 막히고(그리고 암시적 복사는 더 이상 쓰지 말라는 뜻이 되고) 특수 멤버 함수들을 = default로 정의해야 해요.
class base_of_five_defaults
{
public:
base_of_five_defaults(const base_of_five_defaults&) = default;
base_of_five_defaults(base_of_five_defaults&&) = default;
base_of_five_defaults& operator=(const base_of_five_defaults&) = default;
base_of_five_defaults& operator=(base_of_five_defaults&&) = default;
virtual ~base_of_five_defaults() = default;
};
다만 이렇게 하면 클래스가 슬라이싱(slicing)에 취약해져요. 그래서 다형적 클래스는 복사를 = delete로 정의하는 경우가 많아요(코어 가이드라인 C.67: 다형적 클래스는 공개 복사/이동을 억제해야 한다). 이는 5의 법칙에 대한 다음과 같은 일반적인 표현으로 이어져요.
더 알아보기
- 특수 멤버 함수: 소멸자, 복사/이동 생성자, 복사/이동 대입 연산자의 암시적 정의 규칙.
- 복사 생성자·이동 생성자·이동 대입 연산자: 각 특수 멤버 함수의 자세한 동작.
- RAII: 소멸자에서 자원을 해제하는 패턴.