빌드 시간을 줄이기 위해 클래스에 pimpl 관용구를 적용시키는 경우 전방 선언한 클래스나 구조체의 포인터를 사용하게 된다.
그리고 이를 동적할당하여 사용 후 소멸자에서 해제를 시키는 것이 보통일 것이다.
// Widget.h
class Widget {
public:
Widget();
~Widget();
private:
struct Impl; // 전방 선언
Impl* pImpl;
};
// Widget.cpp
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(new Impl) {}
Widget::~Widget() { delete pImpl; }
하지만 이제는 원시 포인터와 new, delete를 직접 사용하는 대신 스마트 포인터를 사용하는 것이 좋다는 것을 안다.
// Widget.h
class Widget {
public:
... // 소멸자에서 delete를 해줄 필요가 없다
private:
struct Impl; // 전방 선언
std::unique_ptr<Impl> pImpl;
};
// Widget.cpp
...
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
스마트 포인터이기 때문에 소멸자에서 delete를 해줄 필요가 없다.
위의 코드들 자체는 컴파일이 잘 되지만 클라이언트에서 Widget 객체를 선언하여 사용하려고 할 때에는 컴파일 에러가 발생하게 된다.
#include "Widget.h"
Widget w;
w가 파괴되는 시점에 소멸자가 호출되는데, 스마트 포인터로 변경하면서 소멸자를 작성하지 않았기 때문에 컴파일러가 소멸자를 자동으로 작성하게 된다. 이 때 pImpl의 소멸자인 unique_ptr의 기본 삭제자를 호출한다.
unique_ptr의 기본 삭제자는 소유중인 원시 포인터에 delete를 적용하기 전에 혹시라도 원시 포인터가 불완전한 타입을 가리키고 있지는 않은지를 static_assert를 이용해서 점검하게 된다. 그런데 여기서 불완전한 타입을 가리키고 있다고 판단하게 되어 에러가 발생하는 것이다.
컴파일러가 자동으로 작성하는 특수 멤버 함수는 암묵적으로 inline이고 Widget.h 안에는 Impl에 대한 정의가 되어있지 않아서 자동으로 작성된 소멸자가 호출되는 순간에는 불완전한 타입이 되는 것이다.
이 문제는 unique_ptr<Widget::Impl>을 파괴하는 코드가 만들어지는 지점에서 완전한 타입이 되면 바로 해결된다.
Widget::Impl의 정의는 cpp에 되어있기 때문에 소멸자의 호출이 헤더가 아닌 cpp에서 이뤄지면 될 것이다.
그럼 이제 어떻게 하면 되는가.
소멸자의 선언과 정의를 분리시켜서 명시적으로 작성해주면 된다.
// Widget.h
class Widget {
public:
Widget();
~Widget();
...
private:
...
};
// Widget.cpp
Widget::~Widget() {} // Widget::~Widget() = default;
불완전한 타입에 대한 문제는 해결되었다.
Pimpl 관용구를 사용하는 클래스는 이동 연산들을 지원하기에 아주 좋은 클래스이다.
심지어 별도로 작성하지 않아도 컴파일러가 자동으로 작성해주는 이동 연산들이 요구에 딱 맞는 이동을 수행한다.
하지만 불완전한 타입에 대한 문제를 해결하느라 소멸자를 명시적으로 만들어주는 바람에 이동 연산은 자동으로 생성되지 않는다. 하지만 이것 역시 명시적으로 작성해주면 된다.
// Widget.h
class Widget {
public:
Widget(Widget&& rhs); // 여기에 default를 적으면 안됨
Widget& operator=(Widget&& rhs);
...
};
// Widget.cpp
Widget::Widget(Widget&& rhs) = default;
Widget& operator=(Widget&& rhs) = default;
이동연산에 대한 문제도 해결하였다.
하지만 만약 Gadget이 string, vector와 같이 복사가 가능한 타입이라면 복사 연산을 지원해주는것이 합당하다.
그런데 복사 연산은 이동 연산과 달리 직접 구현해주어야 한다. 왜냐면 unique_ptr같은 이동 전용 타입이 있는 클래스에서는 컴파일러가 복사 연산들을 작성해주지 않을뿐더러 설령 작성된다 하더라도 얕은 복사를 수행하기 때문이다.
// Widget.h
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
...
};
// Widget.cpp
Widget::Widget(const Widget& rhs) : pImpl(nulltpr) {
if (rhs.pImpl) pImpl = std::make_unqiue<Impl>(*rhs.pImpl);
}
Widget& Widget::operator=(const Widget& rhs) {
if (!rhs.pImpl) pImpl.reset();
else if (!pImpl) pImpl = std::make_unique<Impl>(*rhs.pImpl);
else *pImpl = *rhs.pImpl;
return *this;
}
복사 연산을 직접 구현했을뿐 기존처럼 선언과 정의를 분리시켰다.
재미있게도 unique_ptr와 shared_ptr 둘 중 어느것을 사용하느냐에 따라 여지껏 언급한 문제해결 방법이 유효할수도, 필요없을수도 있다는 것이다.
shared_ptr의 삭제자는 타입의 일부가 아니라 제어 블록에서 같은 shared_ptr과 공유되기 때문에 런타임에 크기가 더 커지고 속도도 약간 느려지지만 삭제자가 호출될 때 원시 포인터의 타입이 완전한 타입이어야 한다는 제약 조건이 사라진다.
Pimpl 관용구를 사용함에 있어서 unique_ptr이나 shared_ptr사이에서 어떤 절충 관계가 존재하는 것은 아니다.
Widget과 Widget::Impl 사이의 관계는 독점적 소유 관계이기 때문에 unique_ptr이 더 잘 맞아떨어질 뿐이다.
상황에 따라 shared_ptr를 선택하는 경우에는 불완전한 타입에 대한 문제를 신경쓰지 않아도 된다는 점만 알면 된다.
◾ Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.
◾ std::unique_ptr 타입의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들은 클래스 헤더에 선언하고 구현 파일에서 구현해야 한다. 컴파일러가 기본으로 작성하는 함수 구현들이 사용하기에 적합한 경우에도 그렇게 해야 한다.
◾ 위의 조언은 std::unique_ptr에 적용될 뿐, std::shared_ptr에는 적용되지 않는다.
'도서 > Effective Modern C++' 카테고리의 다른 글
[5장] 항목 24 : 보편 참조와 오른값 참조를 구별하라 (0) | 2023.01.24 |
---|---|
[5장] 항목 23 : std::move와 std::forward를 숙지하라 (0) | 2023.01.24 |
[4장] 항목 21 : new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호하라 (0) | 2023.01.20 |
[4장] 항목 20 : std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라 (0) | 2023.01.19 |
[4장] 항목 18 : 소유권 독점 자원의 관리에는 std::unique_ptr를 사용하라 (0) | 2023.01.19 |