C++/C++

C++ - Rule of Zero/Three/ Five

오의현 2024. 10. 10. 18:57

C++에서 안정적인 설계를 위해 권고하는 몇가지의 규칙들이 있다.

컴파일러에 의해 강제되는 규칙은 아니지만, 이를 준수하였을 때 안정성을 보장할 수 있다고 알려진 규칙들이다.

 

예를 들면, RAII 같은 것들 말이다. (RAII는 쉽게 말하면, 동적할당은 생성자에서하고 해제는 소멸자에서 하여 객체의 생명주기와 자원의 생명주기를 결합하라는 규칙이다.)

 

Rule of Zero, Rule of Three, Rule of Five도 이런 규칙이다. 준수하였을 때, 프로그램의 안정성을 높일 수 있기 때문에 권고되는 규칙인 셈이다.

 

그렇다면, 이게 무슨 규칙인지 먼저 Rule of Three 부터 알아보자.

Rule Of Three

C++에서 클래스를 만들면, 해당 클래스에는 몇가지 디폴트 함수가 생성된다.

(복사 생성자, 복사 대입자 등등...)

 

얕은 복사와 깊은 복사에 대한 이해가 있다면 알고 있겠지만, 포인터를 보유한 객체에게 있어 디폴트 복사 연산자는 위험요소가 존재한다. (메모리 누수, 댕글링 포인터 등)

 

그렇기 때문에 디폴트 연산자로 인한 얕은 복사를 방지하기 위해 복사생성자, 복사 대입자를 오버로딩하여 깊은 복사가 발생하도록 해주어야 메모리를 안전하게 관리할 수 있게 된다.

 

Rule Of Three는 이를 확고히 하기 위한 규칙이다. 포인터 변수를 보유한 객체라면 복사 생성자, 복사 대입자, 소멸자 3가지의 연산자를 반드시 오버로딩하여 올바르게 구현하라는 규칙이 Rule Of Three이다.

 

연산자를 적절히 구현했다면, 복사로 인해 메모리 누수나 댕글링 포인터가 발생하지 않을 것이니 말이다.

 

그런데, 복사 생성자와 복사 대입자는 얕은 복사의 위험성을 회피하기 위한 목적이라 하더라도 소멸자는 왜 구현하라는 것일까?

 

메모리가 해제가 안되어있으면 객체가 사라지기 전에 해제하라는 의미이다. 소멸자는 객체가 사라지기 직전에 호출되는 함수이므로 소멸자에서 자원 해제를 명시하면 할당받은 자원은 객체와 함께 소멸할테니 말이다.

 

하지만, 이 말이 기계적으로 연산자를 오버로딩하라는 의미는 아니다.

 

예를 들어, 연산자를 오버로딩 했음에도 불구하고 구현부가 엉성해서 메모리가 안전하게 관리되지 않을 수도 있고 포인터 변수가 메모리 누수의 위험성이 없는 변수일 수도 있다. (동적할당된 것이 아니라 스택 메모리를 가리키는 포인터 변수일 수도 있으니 말이다.) 

 

Rule Of Three 의 핵심은 연산자를 '적절히' 관리하고 신경을 쓰라는 것이다. 3개의 연산자중 하나라도 오버로딩을 했다면 이 객체는 복사가 발생할 가능성이 있는 객체라고 판단을 했거나, 동적할당된 메모리를 가리키는 포인터 변수가 포함되어 있어 복사 과정에서 메모리 문제를 유발할 가능성이 있다고 판단했기 때문에 오버로딩을 했을 것이다. 그렇다면, 이 때 Rule Of Three를 떠올리고 나머지 연산자를 적절하게 구현하기 위해 고민을 해보자는 것이 Rule Of Three의 중요한 것이다.

 

하지만, 객체를 설계할 때 항상 이 규칙을 떠올리는 것이 쉽지는 않을 것이다. 프로그램의 설계만으로도 머리가 복잡할테니 말이다. 그래서 이 규칙을 강제적으로 떠올리게 하기 위해서 연산자의 삭제가 권고된다. 객체를 생성하면 일단 복사생성자, 복사 대입자를 delete하는 것이다. (소멸자는 delete할 수 없으니 복사 생성/대입자만)

 

복사 연산자가 delete되면, 해당 객체에서 복사가 발생할 경우 컴파일 에러가 발생할 것이다. 그렇다면 이 때, 고민을 해보는 것이다. 먼저, 복사가 정말 필요한 상황인지를 고미하는 것이다. 그 상황에서 복사가 필요하지 않다면 다른 방법으로 처리를 해보는 것이다. 하지만, 다른 방법으로 해결할 수 없거나 복사하는 것이 더 효율적이라고 판단된다면 그 때 복사 연산자를 정의하는 것이다. 물론 이 과정에서 복사를 어떻게 처리해야 하는가 고민을 해야 한다. 동적할당된 메모리를 가리키는 포인터 변수가 포함되어 있다면, 깊은 복사가 발생하도록 해야 할 것이며 옮길 필요가 없는 데이터는 옮기지 않도록 해야 할 것이니 말이다.

 

Rule Of Five

Rule of Five는 위에서 언급한 3개의 연산자에 이동 연산자, 이동 대입자를 추가한 것이다. Rule Of Three는 move semantics가 나오기 전에 존재했던 규칙이라 이동 연산자가 포함이 안되어 있었다고 한다. 그래서 두 연산자를 추가한 것이다.

Rule Of Zero

Rule Of Zero 는 Rule Of Three/Five와는 다소 다르다. Rule Of Three/Five는 프로그래머가 직접 메모리를 관리할 때, 어떻게 하는 것이 좋은가에 대한 방향을 제시하는 규칙이라면 Rule Of Zero는 직접 관리하는 것이 없도록 하라는 규칙이다.

 

무슨 말인가 싶겠지만, 쉽게 말하면 직접 메모리를 관리하려고 하지 말고 스마트 포인터처럼 알아서 메모리를 관리해주는 기능을 활용하라는 뜻이다. 애초에 동적할당된 메모리를 스마트 포인터로 관리하고 있다면 메모리 관련 문제가 거의 발생하지 않으니 말이다. (shared_ptr의 순환참조 문제 예외..)

 

스마트 포인터 말고도 다른 서드파티 라이브러리를 활용하는 등 가능하다면 다른 전문가의 손에 맡기자는 것이 Rule Of Zero 이다. 

 

 

본인은 visual studio를 사용할 때, 아래와 같이 항목 템플릿을 만들어두고 객체를 만들 때쓰고 있다.

#pragma once

class ClassName
{

public:

	ClassName();
	~ClassName();

	ClassName(const ClassName& _Other) = delete;
	ClassName(ClassName&& _Other) noexcept = delete;
	ClassName& operator=(const ClassName& _Other) = delete;
	ClassName& operator=(ClassName&& _Other) noexcept = delete;

protected:

private:

};

객체를 만들면 기본적으로 ClassName이 파일이름으로 바뀌며 코드가 생성된다. (항목 템플릿에 대해 잘 모른다면, 나중에 게시글을 올려보도록 할테니 참고하도록 하자.. 간단히 설명하자면 소스파일, 헤더파일을 만들면 기본 코드가 작성되어 있는 그런 기능이다.)

 

복사, 이동 연산자가 모두 delete되어있다. 이를 통해 복사, 이동이 발생할 때 조금 더 세심하게 신경쓰고 처리할 수 있다!