25. unique_ptr + 불완전 타입(Pimpl)에서 소멸자를 헤더에 기본으로 두면 안 되는 이유
난이도 상내 리뷰 · C++
해설 · C++
해설 — unique_ptr + 불완전 타입(Pimpl)에서 소멸자를 헤더에 기본으로 두면 안 되는 이유
난이도: 상
요약
std::unique_ptr<T>의 기본 삭제자 default_delete<T>는 delete 표현식을 실행하기 위해 T가 **완전한 타입(complete type)**이어야 한다(sizeof(T)를 필요로 함). Pimpl 관용구에서 T(=Impl)는 헤더에서 전방 선언만 되어 있으므로, unique_ptr<Impl>을 멤버로 갖는 클래스의 소멸자가 "그 자리에서" 인스턴스화되면 컴파일 에러가 난다. 문제의 problem.cpp처럼 같은 번역 단위 안에서 Impl이 나중에 완전히 정의되고 그 뒤에 소멸자를 정의하면 문제없지만, 소멸자를 헤더의 클래스 정의부에서 즉시 = default로 작성하면, 그 헤더만 include하고 Impl의 정의를 모르는 다른 번역 단위에서 컴파일 에러가 발생한다.
핵심 개념
- 불완전 타입(incomplete type): 전방 선언(
struct Impl;)만 되어 있고 정의(멤버, 크기)를 모르는 타입. std::default_delete<T>::operator(): 내부에서static_assert(sizeof(T) > 0, ...)로 완전한 타입인지 검사한 뒤delete ptr;을 실행한다.- 암묵적으로 정의되는 특수 멤버 함수의 인스턴스화 시점: 클래스 정의부에서
= default로 "정의"된 소멸자(또는 컴파일러가 자동 생성하는 소멸자)는, 그 정의가 필요해지는 시점(주로 그 클래스의 소멸자가 처음 호출되거나 참조되는 지점, 종종 클래스 정의 직후)에 즉시 몸체가 생성되며, 그 안에서 멤버인unique_ptr<Impl>의 소멸자도 함께 인스턴스화를 시도한다. - Pimpl 관용구의 정석: 헤더에서는 소멸자를 "선언만"(
~PlayerSession();) 하고, .cpp에서 Impl이 완전히 정의된 뒤PlayerSession::~PlayerSession() = default;로 정의를 지연시킨다. 이동 생성자/대입 연산자도 내부적으로 멤버를 소멸시킬 수 있으므로 동일하게 .cpp에서 정의해야 한다.
단계별 내부 동작
- 헤더에
class PlayerSession { ... struct Impl; std::unique_ptr<Impl> impl_; ~PlayerSession() = default; ... };형태로 소멸자를 그 자리에서 정의하면, 컴파일러는 클래스 정의가 끝나는 시점(또는 그 직후)에 이 소멸자의 몸체를 실제로 생성한다. - 소멸자 몸체는 암묵적으로 멤버
impl_(즉unique_ptr<Impl>)의 소멸자를 호출하는 코드를 포함한다. 이 시점에Impl이 헤더 안에서 아직 전방 선언 상태(불완전 타입)라면,default_delete<Impl>::operator()안의static_assert(sizeof(Impl) > 0, ...)가 실패하며 "invalid application of 'sizeof' to incomplete type 'Impl'" 에러가 발생한다. - 실제로 검증한 결과, 이 문제가 "같은 .cpp 파일 안"에서 발생하는지 여부는 미묘하다: 소멸자를 클래스 정의부 안에서 즉시
= default로 써도, 그 아래에PlayerSession::Impl의 완전한 정의가 같은 번역 단위 안에 존재하기만 하면(그리고 컴파일러 구현에 따라 인스턴스화가 실제 사용 시점까지 지연되면) 컴파일러가 에러를 내지 않을 수 있다. 하지만 **session.h만 include하고 Impl 정의를 전혀 모르는 다른 .cpp(예:main.cpp)**에서PlayerSession객체를 만들고 스코프를 벗어나 소멸시키면, 그 지점에서 암묵적 소멸자 호출이 인스턴스화를 강제하고unique_ptr<Impl>::~unique_ptr()내부에서 정확히 위와 같은 컴파일 에러가 발생한다. 이것이 Pimpl에서 "소멸자를 헤더에 기본으로 두면 안 되는" 이유의 핵심이다 — 문제는 헤더를 사용하는 다른 번역 단위에서 재현된다. PlayerSession::~PlayerSession() = default;를 .cpp(Impl이 완전히 정의된 뒤)에 두면, 그 정의가 위치한 번역 단위 안에서만unique_ptr<Impl>의 소멸자 인스턴스화가 일어나고, 이때는 Impl이 완전한 타입이므로 문제없이 컴파일된다. 헤더만 보는 다른 번역 단위는 소멸자의 "선언"만 보므로 인스턴스화를 시도하지 않는다.shared_ptr<Impl>을 쓰면 이 문제가 사라진다.shared_ptr의 삭제자는 타입 소거(type erasure)되어 제어 블록(control block) 생성 시점(즉make_shared/shared_ptr생성자가 호출되는 지점, 보통 .cpp의 생성자 안)에 이미 구체적인 삭제 함수 포인터가 저장되기 때문에, 소멸자 자체는 그 삭제자 포인터를 통해 간접 호출만 하면 되고Impl의 완전성을 요구하지 않는다. 반면unique_ptr의 기본 삭제자는 템플릿 파라미터T에 직접 의존하므로 소멸자가 인스턴스화되는 모든 지점에서T가 완전해야 한다.
흔한 오해·함정
- "같은 파일 안에서 컴파일했더니 문제없더라"는 이유로 헤더에
= default소멸자를 남겨두는 실수를 한다. 실제로는 그 헤더를 include하는 다른 번역 단위(다른 .cpp)에서 터진다는 게 핵심 함정이다. - 소멸자만 옮기고 이동 생성자/이동 대입 연산자는 헤더에
= default로 남겨두는 실수도 흔하다 — 이동 연산 역시 내부적으로 기존 리소스를 해제(소멸)할 수 있으므로 동일한 문제가 생긴다. unique_ptr의 문제이지 "포인터 멤버라서 생기는 문제"가 아니다. 커스텀 삭제자를 명시적으로 지정하고 그 삭제자가 불완전 타입을 요구하지 않는다면(예:void(*)(void*)같은 타입 소거된 삭제자) 문제가 발생하지 않을 수도 있다.
면접 포인트
- "왜 Pimpl에서 unique_ptr 소멸자를 .cpp에서 정의해야 하는가"에 대해
sizeof/완전한 타입 요구사항까지 근거를 들어 설명할 수 있는가. - 문제가 재현되는 지점이 "같은 파일"이 아니라 "헤더만 아는 다른 번역 단위"라는 것을 정확히 짚을 수 있는가.
unique_ptrvsshared_ptr의 삭제자 저장 방식(컴파일 타임 타입 파라미터 vs 런타임 타입 소거)의 차이를 설명할 수 있는가.