[Effective C++] Cp 5. 컴파일러가 만든 함수도 다시보자
이 항목의 핵심은 간단하다.
클래스의 생성/복사/대입/소멸 동작을 내가 명시하지 않으면 컴파일러가 "합리적이라고 판단한 기본 동작"을 넣어 준다.
문제는 이 기본 동작이 "컴파일은 되지만 의도는 틀린 코드"를 만들 수 있다는 점이다. 특히 리소스 소유권(메모리, 파일, 소켓, 뮤텍스)을 다루는 클래스에서 위험하다.
1. 컴파일러가 자동으로 만들 수 있는 함수
C++ 클래스에서 특별히 선언하지 않으면 컴파일러가 다음 멤버 함수를 자동으로 선언할 수 있다.
- 기본 생성자(default constructor)
- 소멸자(destructor)
- 복사 생성자(copy constructor)
- 복사 대입 연산자(copy assignment operator)
C++11 이후에는 이동 의미(move semantics)가 추가되어 아래도 상황에 따라 자동 생성된다.
- 이동 생성자(move constructor)
- 이동 대입 연산자(move assignment operator)
주의할 점은 "항상 자동 생성"이 아니라 "조건이 맞을 때 자동 생성"이라는 점이다. 일부 함수를 직접 선언하면 다른 함수의 자동 생성이 억제되거나 삭제(= delete)될 수 있다.
2. 자동 생성 동작이 왜 문제를 만들까
자동 생성된 복사/대입은 기본적으로 "멤버별 복사(memberwise copy)"다.
멤버가 값 타입(int, std::string, std::vector)이면 보통 문제 없다.하지만 "소유권 있는 포인터"가 멤버면 얕은 복사가 일어나기 쉽다.
class FileHandle
{
private:
FILE* fp;
public:
explicit FileHandle(FILE* f)
: fp(f)
{ }
~FileHandle()
{
if (fp != nullptr)
{
fclose(fp);
}
}
};
위 코드에서 복사 생성자/복사 대입을 직접 정의하지 않으면 fp 주소값만 복사된 객체가 생길 수 있다. 그 결과 다음 문제가 생긴다.
- 두 객체가 같은 파일 핸들을 닫으면서 이중 해제 발생
- 한 객체가 먼저 닫아 버린 뒤 다른 객체가 잘못된 핸들 접근
- 소멸 순서에 따라 간헐적 버그(재현 어려움)
3. "자동 생성/삭제"가 갈리는 대표 조건
이하는 개발 중 자주 걸리는 규칙들이다.
- 참조 멤버(
T&)가 있으면 복사 대입이 자연스럽게 어렵다. const멤버가 있으면 복사 대입 시 재할당이 불가능해 제약이 생긴다.- 사용자 정의 소멸자/복사 연산자 선언은 이동 연산 자동 생성을 막을 수 있다.
- 멤버/기반 클래스가 복사 불가면 해당 클래스 복사도 불가가 된다.
즉, 나는 하나만 건드렸는데 다른 연산이 갑자기 delete되는 현상이 실제로 발생하므로, 클래스 인터페이스만 보고도 복사/이동 가능 여부를 명시적으로 확인해야 한다.
4. Rule of Three / Five 관점에서 정리
고전적으로는 Rule of Three가 중요하다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
이 셋 중 하나를 직접 작성해야 하는 클래스라면, 대개 나머지 둘도 함께 설계해야 한다는 뜻이다.
C++11 이후에는 이동 연산까지 포함해 Rule of Five로 확장한다.
- 소멸자
- 복사 생성자
- 복사 대입 연산자
- 이동 생성자
- 이동 대입 연산자
5. 권장 대응 방식
가장 안전한 우선순위는 다음과 같다.
- 리소스를 직접 다루지 말고 RAII 표준 타입을 먼저 사용
- 복사가 불필요한 타입은 명시적으로 복사 금지
- 복사가 필요하면 깊은 복사 정책을 코드로 분명히 구현
- 이동만 허용할지, 복사+이동 모두 허용할지 의도 명시
복사 금지 예시는 아래처럼 표현한다.
class Uncopyable
{
private:
Uncopyable(const Uncopyable&) = delete;
Uncopyable& operator=(const Uncopyable&) = delete;
public:
Uncopyable() = default;
~Uncopyable() = default;
};
6. 깊은 복사가 필요한 경우의 형태
소유 포인터를 반드시 써야 한다면 복사 생성자/복사 대입에서 "새 자원 생성 + 내용 복사"를 직접 정의해야 한다.
class Buffer
{
private:
std::size_t size;
int* data;
public:
explicit Buffer(std::size_t n)
: size(n), data(new int[n]())
{ }
~Buffer()
{
delete[] data;
}
Buffer(const Buffer& rhs)
: size(rhs.size), data(new int[rhs.size])
{
std::copy(rhs.data, rhs.data + size, data);
}
Buffer& operator=(const Buffer& rhs)
{
if (this == &rhs)
{
return *this;
}
int* newData = new int[rhs.size];
std::copy(rhs.data, rhs.data + rhs.size, newData);
delete[] data;
data = newData;
size = rhs.size;
return *this;
}
};
이런 코드가 반복된다면 원시 포인터 대신 std::vector<int>를 쓰는 편이 훨씬 안전하다.
7. 정리
컴파일러의 자동 생성 함수는 편의 기능이지 설계 의도를 대신해 주는 기능이 아니라는 점을 잊지말자.
클래스가 무엇을 소유하고, 복사/이동/소멸을 어떻게 해야 하는지 직접 결정하고, 그 결정을 = default/= delete/사용자 정의 함수로 명시하는 습관이 필요하다.