Effective C++/Effective Modern C++
[Effective Modern C++] 22. unique_ptr를 사용한 pImpl 관용구의 특수 멤버 함수
김디트
2025. 3. 7. 11:19
728x90
항목 22. pImpl 관용구를 사용할 때에는 특수 멤버 함수들을 cpp 파일에서 정의하라
pImpl 관용구
- 구현부를 클래스 포인터로 대체하고, 그 포인터를 통해 자료 멤버들에 간접적으로 접근하는 기법
- 각종 타입들을 헤더에서 포함시켜야 하므로 컴파일이 느려진다.
- 또한 타입이 추가될 때마다 재컴파일이 필요하다.
- 이를 해결하기 위해서 pImpl 관용구를 사용한다.
pImpl 구현
// 기존 구현
class Widget
{
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
- C++98에서는 아래와 같이 할 수 있었다.
// 헤더 파일
class Widget
{
public:
Widget();
~Widget();
...
private:
strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
Impl *pImpl; // raw 포인터 사용
};
// cpp 파일
struct Widget::Impl // 구현부 구조체
{
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(new Impl) {}
Widget::~Widget() { delete pImpl; } // 해제를 위해 반드시 소멸자가 필요하다.
- 모던 C++에서는 스마트 포인터를 사용할 수 있다.
// 헤더 파일
class Widget
{
public:
Widget();
...
private:
strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
std::unique_ptr<Impl> pImpl; // unique_ptr 사용
};
// cpp 파일
struct Widget::Impl // 구현부 구조체
{
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
// 소멸자는 필요 없다.
- 헌데 위 코드는 막상 Widget을 사용하려 하면 문제가 발생한다.
-
#include "widget.h" Widget w; // 컴파일 에러!!!
- 컴파일러는 불완전 형식에 sizeof나 delete를 사용한다는 불평을 내뱉는다.
- 이 문제는, w가 파괴될 때 컴파일러가 작성하는 코드에서 기인한다.
-
pImpl 파괴 시 코드 문제 상황
- w의 소멸자를 호출한다.
- std::unique_ptr을 이용하는 Widget 클래스에는 소멸자가 따로 선언되어 있지 않다.
- 소멸자에서 할 일이 없으므로.
- 컴파일러가 생성하는 특수 멤버 함수 규칙으로 인해 컴파일러가 대신 소멸자를 생성한다. (항목 17 참조)
- 컴파일러는 그 소멸자 안에 Widget의 자료 멤버 pImpl의 소멸자를 호출하는 코드를 삽입한다.
- pImpl은 std::unique_ptr<Widget::Impl> 이므로, unique_ptr의 기본 삭제자를 사용한다.
- 그 기본 삭제자는 std::unique_ptr 안의 raw 포인터에 delete를 적용한다.
- 근데 대부분의 표준 라이브러리 구현들에서 그 삭제자 함수는 delete를 적용하기 전에 적용할 대상이 불완전 타입인지를 static_assert를 이용해서 점검한다.
- 헌데 불완전 타입이므로 컴파일 에러가 발생한다.
- w가 파괴되는 지점 쪽을 지정하며 에러가 발생하는데, 특수 멤버 함수는 암묵적으로 inline이기 때문이다.
- 즉, 파괴 시점에 Widget::Impl이 완전한 타입이 되게 하면 문제는 해결된다.
해결 방법
// 헤더 파일
class Widget
{
public:
Widget();
~Widget(); // 선언만 한다.
...
private:
strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
std::unique_ptr<Impl> pImpl; // unique_ptr 사용
};
// cpp 파일
struct Widget::Impl // 구현부 구조체
{
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() {} // ~Widget의 정의
- 이렇게 Widget의 소멸자를 헤더에 선언, cpp에 정의하면 된다.
- 물론 컴파일러가 자동으로 만든 소멸자가 문제 없다는 걸 명시하고 싶다면 아래처럼 해도 된다.
Widget::~Widget() = default;
pImpl과 이동 연산
- 이동 연산자들은 소멸자가 선언되어 있으면 자동으로 작성되지 않는다.(항목 17 참조)
- 컴파일러가 작성하는 이동 연산들이 적합하다고 가정하면, 기본 구현을 지정하면 되지 않을까?
// 헤더 파일
class Widget
{
public:
Widget();
~Widget();
// 이 역시 이동 연산을 처리할 때 컴파일 에러가 발생하는 코드!
Widget(Widget&& rhs) = default;
Widget& operator=(Widget&& rhs) = default;
...
private:
strcut Impl;
std::unique_ptr<Impl> pImpl;
};
- 이동 할당 연산 시 pImpl을 재배정하기 전에 pImpl이 가리키는 객체를 파괴해야 하는데 그 시점에 불완전 타입이다!
- 이동 생성자는 예외 발생 시 pImpl을 파괴하기 위한 코드를 작성하는데 파괴하려면 완전 타입이어야 한다.
- 해결책은 동일하다.
// 헤더 파일
class Widget
{
public:
Widget();
~Widget();
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
...
private:
strcut Impl;
std::unique_ptr<Impl> pImpl;
};
// cpp 파일
struct Widget::Impl // 구현부 구조체
{
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
// 구현부에 정의
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
- 만약 커스텀 깊은 복사를 만들고 싶다면, 역시 같은 룰을 적용하여 구현부를 cpp에 구현해주면 된다.
shared_ptr을 사용한다면?
- 이 항목의 조언이 더 이상 적용되지 않는다.
- 소멸자를 선언할 필요가 없다.
- 그러므로 이동 연산들은 컴파일러가 알아서 만들어준다.
// 헤더 파일
class Widget
{
public:
Widget();
...
private:
struct Impl;
std::shared_ptr<Impl> pImpl;
};
// cpp 파일
// 아래는 모두 성공한다.
Widget w1;
auto w2(std::move(w1));
w1 = std::move(w2);
- unique_ptr
- 커스텀 삭제자가 타입에 반영된다.
- 컴파일러는 더 작은 런타임 자료구조와 더 빠른 런타임 코드를 만들어낼 수 있다.
- 하지만 특수 멤버 함수가 쓰이는 시점에서 대상 타입들이 완전한 타입이어야 한다는 제약이 있다.
- shared_ptr
- 커스텀 삭제자가 타입에 반영되지 않는다.
- unique_ptr보다 다소 느리고 무겁다.
- 하지만 대상 타입들이 완전한 타입이어야 한다는 제약이 사라진다.
- pImpl의 경우 독점적 관계이므로 unique_ptr을 쓰는 게 가장 적합한 것은 확실하지만
- 위 차이를 알아두는 건 의미가 있을 것이다.
728x90