[Modern C++] 항목 22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 4. 스마트 포인터
👉🏻 항목 22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라
🔍 Pimpl 관용구 개요
- Pimpl 관용구(pointer to implementation idiom): 클래스의 자료 멤버들을 구현 클래스(또는 구조체)를 가리키는 포인터로 대체하고, 원래 쓰이던 자료 멤버들을 그 구현 클래스로 옮긴 뒤, 포인터를 통해 간접적으로 접근하는 기법
- 빌드 시간을 줄이는 데 유용하다.
class Widget { // "widget.h" 헤더 파일 안에서
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget은 사용자 정의 타입
};
Widget의 자료 멤버가std::string,std::vector,Gadget타입이므로, 클라이언트는<string>,<vector>,gadget.h를 반드시 포함해야 한다.- 이 헤더들 때문에 클라이언트의 컴파일 시간이 증가하고, 헤더 내용이 바뀌면 클라이언트도 다시 컴파일해야 한다.
🏷️ C++98 방식: raw 포인터 사용
class Widget { // 여전히 "widget.h" 헤더 안
public:
Widget();
~Widget(); // 소멸자가 필요함 (본문 설명 참고)
...
private:
struct Impl; // 구현용 구조체와
Impl *pImpl; // 그것을 가리키는 포인터를 선언
};
- 선언만 하고 정의는 하지 않은 타입을 불완전 타입(incomplete type)이라고 한다.
Widget::Impl이 바로 그런 타입. - 불완전 타입을 가리키는 포인터를 선언하는 것은 불완전 타입으로 할 수 있는 몇 안 되는 일 중 하나이며, Pimpl 관용구는 이 능력을 활용한다.
// "widget.cpp" 구현 파일 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // 전에 Widget에 있던 자료 멤버들을
std::string name; // 담은 Widget::Impl의 정의
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() // 이 Widget 객체를 위한
: pImpl(new Impl) // 자료 멤버들을 할당
{}
Widget::~Widget() // 이 객체를 위한 자료 멤버들을
{ delete pImpl; } // 파괴
- 이제
widget.h는std::string,std::vector,Gadget을 언급하지 않으므로 클라이언트는 관련 헤더를 포함할 필요가 없다 → 컴파일 속도 향상, 헤더 변경에 대한 영향 차단 - 다만 이 코드는 raw 포인터와
new/delete를 직접 사용한다는 점에서 원시적이다.
✅ std::unique_ptr로 대체하기
class Widget { // 헤더 "widget.h" 안에서
public:
Widget();
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl; // raw 포인터 대신 스마트 포인터를 사용한다
};
// "widget.cpp" 파일 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // 이전과 동일
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() // 항목 21의 조언을 따라,
: pImpl(std::make_unique<Impl>()) // std::make_unique를 이용해서
{} // std::unique_ptr를 만든다
std::unique_ptr가 파괴될 때 자신이 가리키는 객체를 자동으로 삭제하므로, 소멸자를 따로 선언할 필요가 없다.- 그러나 이 코드는 다음과 같은 클라이언트 코드에서 컴파일 오류가 발생한다.
#include "widget.h"
Widget w; // 오류!
⚠️ 오류의 원인: 불완전 타입과 컴파일러 생성 함수
w가 파괴되는 지점(범위를 벗어나는 지점)에서w의 소멸자가 호출되는데,Widget에는 소멸자가 선언되어 있지 않으므로 컴파일러가 대신 소멸자를 작성한다.- 컴파일러가 작성한 소멸자 안에는
pImpl의 소멸자를 호출하는 코드가 들어간다.pImpl은 기본 삭제자를 사용하는std::unique_ptr<Widget::Impl>이며, 그 삭제자는delete를 적용하기 전에 C++11의static_assert로 대상이 불완전 타입이 아닌지 점검한다. Widget객체w의 파괴 코드가 생성되는 시점에는Widget::Impl이 아직 불완전 타입이므로static_assert가 실패하고 오류 메시지가 발생한다.- 컴파일러가 자동 작성하는 특수 멤버 함수는 암묵적으로
inline이기 때문에, 오류 메시지는 보통w가 생성되는 행을 가리킨다.
- 컴파일러가 자동 작성하는 특수 멤버 함수는 암묵적으로
🛠️ 해결책: 소멸자를 헤더에서 선언만, 구현 파일에서 정의
class Widget { // 이전처럼 "widget.h" 안에서
public:
Widget();
~Widget(); // 선언만 해둔다
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// 이전처럼 "widget.cpp" 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // Widget::Impl의 정의 (이전과 동일)
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() // 이전과 동일
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() // ~Widget의 정의
{}
- 소멸자의 정의가
Widget::Impl이 완전한 타입이 된 이후(구현 파일 안)에 만들어지므로static_assert가 통과한다. - 컴파일러가 자동 작성한 소멸자 자체에는 문제가 없었으며, 단지 소멸자가 정의되는 위치가 문제였다는 점이 핵심이다.
- 굳이 소멸자 본문에 넣을 코드가 없다면 다음과 같이
= default로 정의해도 동일한 효과를 낸다.
Widget::~Widget() = default; // 앞에서와 같은 효과
🚚 이동 연산 지원하기
- Pimpl 관용구를 사용하는 클래스는 바탕
std::unique_ptr에 대한 이동만 수행하면 되므로 이동 연산을 지원하기에 자연스러운 후보다. - 그러나 소멸자를 선언하면 컴파일러는 이동 연산들을 작성해 주지 않으므로, 이동을 지원하려면 직접 선언해야 한다.
class Widget { // 여전히 "widget.h"
public:
Widget();
~Widget();
Widget(Widget&& rhs); // 선언만 하고
Widget& operator=(Widget&& rhs); // 정의는 하지 않는다
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
Widget::Widget() // 이전과 동일
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() = default; // 이전과 동일
Widget::Widget(Widget&& rhs) = default; // 여기서
Widget& Widget::operator=(Widget&& rhs) = default; // 정의
클래스 정의 바깥에서
= default로 정의된 이동 연산들은 암묵적으로noexcept로 선언된다.std::unique_ptr처럼Widget도 예외를 방출하지 않게 만들고 싶다면, 이동 연산들을 선언과 정의 모두에서 명시적으로noexcept로 선언하면 된다.
- 이동 대입 연산자가 재대입 전에
pImpl이 가리키는 객체를 파괴해야 하고, 이동 생성자에서도 예외 발생 시pImpl을 파괴하는 코드가 만들어지는데, 두 경우 모두Impl이 완전한 타입이어야 한다. 소멸자와 마찬가지로 정의를 구현 파일로 옮기면 해결된다.
🧬 복사 연산 지원하기
- Pimpl 관용구를 사용해도 클래스가 나타내는 개념 자체가 바뀌는 것은 아니다. 원래
Widget이 복사 가능한 타입들(std::string,std::vector, 복사 가능한Gadget)로 이루어졌다면,Widget역시 복사를 지원하는 것이 합당하다. - 복사 연산은 직접 정의해야 한다. 이유는 다음 두 가지다.
std::unique_ptr같은 이동 전용 타입이 있는 클래스에 대해서는 컴파일러가 복사 연산을 작성해 주지 않는다.- 설령 작성한다 해도, 그 함수들은
std::unique_ptr자체만 복사하는 얕은 복사(shallow copy)를 수행한다. 우리가 원하는 것은 포인터가 가리키는 대상까지 복사하는 깊은 복사(deep copy)다.
class Widget { // 여전히 "widget.h" 안에서
public:
... // 다른 함수들은 이전과 동일
Widget(const Widget& rhs); // 이 함수들은
Widget& operator=(const Widget& rhs); // 선언만 해둔다
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// "widget.cpp" 안에서
#include "widget.h"
...
struct Widget::Impl { ... }; // 이전과 동일
Widget::~Widget() = default; // 다른 함수들은 이전과 동일
Widget::Widget(const Widget& rhs) // 복사 생성자
: pImpl(nullptr)
{ if (rhs.pImpl) pImpl = std::make_unique<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;
}
- 매개변수
rhs(복사 대입 연산자에서는*this)가 이미 이동되어서pImpl이 널일 수 있는 경우를 처리해 주어야 한다는 점만 빼면 구현은 간단하다. - 두 함수 모두 컴파일러가
Impl에 대해 작성해 준 복사 연산을 그대로 활용해서 각 필드를 자동으로 복사한다. 또한 두 함수 모두new를 직접 사용하는 대신std::make_unique를 사용하라는 항목 21의 조언을 따랐다.
🔀 std::unique_ptr 대신 std::shared_ptr를 쓴다면?
Widget안의pImpl포인터가Widget::Impl객체를 독점적으로 소유하므로, 여기에 적합한 스마트 포인터는std::unique_ptr다.- 흥미롭게도,
std::shared_ptr를 사용해서 여러Widget이Impl구조체를 공유하게 만든다면 이 항목의 조언이 더 이상 적용되지 않는다.- 이 경우
Widget에서 소멸자를 선언할 필요가 없고, 사용자 선언 소멸자가 없으므로 컴파일러는 기꺼이 이동 연산들을 작성하며, 그 이동 연산들이 우리가 원하는 방식대로 작동한다.
- 이 경우
class Widget { // "widget.h" 안에서
public:
Widget();
... // 소멸자나 이동 연산의 선언이 전혀 없음
private:
struct Impl;
std::shared_ptr<Impl> pImpl; // std::unique_ptr 대신 std::shared_ptr를 사용
};
Widget w1;
auto w2(std::move(w1)); // w2를 이동 생성
w1 = std::move(w2); // w1을 이동 대입
- 모든 것이 문제없이 컴파일되고 기대한 대로 실행된다.
- 이러한 차이는 두 스마트 포인터가 커스텀 삭제자를 지원하는 방식의 차이에서 비롯된다.
std::unique_ptr: 삭제자의 타입이 포인터 타입 자체의 일부 → 더 작은 자료구조와 더 빠른 코드를 낸다. 대신 컴파일러 작성 특수 멤버 함수가 쓰이는 시점에 가리키는 타입이 반드시 완전한 타입이어야 한다.std::shared_ptr: 삭제자의 타입이 포인터 타입의 일부가 아님 → 자료구조는 더 크고 코드는 다소 느리지만, 완전한 타입 요구조건이 사라진다.
- Pimpl 관용구의 경우
Widget과Widget::Impl사이의 관계는 독점적 소유 관계이므로std::unique_ptr가 적합한 수단임이 확실하다. 다만 소유권이 공유되는 다른 상황(std::shared_ptr가 더 적합한 경우)에서는 번거로운 멤버 함수 정의 과정을 겪지 않아도 된다는 점을 알아둘 만하다.
🧐 정리
- Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.
std::unique_ptr타입의pImpl포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 정의해야 한다. 컴파일러가 기본으로 작성하는 함수 구현이 사용하기에 적합한 경우에도 그렇게 해야 한다.- 위의 조언은
std::unique_ptr에 적용될 뿐,std::shared_ptr에는 적용되지 않는다.
댓글남기기