본문 바로가기
컴퓨터/Modern Effective C++ 정리

Modern Effecitve C++ item 22 : Pimple Idiom

by 김짱쌤 2015. 4. 21.

Pimpl Idiom

Pimpl(Pointer to Implementation) Idiom이란 include가 필요한 유저 define 타입의 멤버변수들을 해당 멤버변수들을 포함한 구조체의 포인터로 대체하는 방법이다. 말로 쓰면 어려워 보이니 실제 C++ 98에서 자주 사용하던 예시를 들어서 설명하도록 하겠다.

//in header Widget.h
#include <string>
#include <vector>
#include <Gadget.h>
class Widget{
public:
   Widget();
   ...
private:
   std::string name;
   std::vector<double> data;
   Gadget g1, g2, g3;
}

Pimpl Idiom을 사용하지 않는다면, 자주 보는 형태의 헤더파일일 것이다. 이런 형식의 헤더파일의 문제점은 헤더파일 내에서 다른 헤더파일을 include하고 있다는 것이다. #include를 사용하면 전처리기는 해당 헤더의 내용을 그대로 복사하여 집어넣는다. (최적화 하지 않는다고 가정하면) 그러면 Widget.h을 include 하는 모든 cpp 파일에 Widget.h가 include하는 모든 헤더를 포함하게 되므로 코드량이 매우 커질 것이다. 따라서 헤더에 다른 헤더를 include할수록 컴파일 시간이 늘어난다.

또 다른 문제는 re-complie 이다. string이나 vector와 같은 표준 라이브러리 함수는 내용이 바뀔일이 별로 없지만, Gadget같은 유저 정의 클래스의 헤더는 자주 바뀔 가능성이 높다. Gadget의 내용이 바뀐다면, Widget.h를 include하는 다른 모든 파일도 같이 재컴파일 될 수 밖에 없다. 이런 문제때문에 헤더는 다른 헤더를 가능하면 include하지 않는 것이 좋다. 그래서 사용하는 것이 Pimpl Idiom이다.

C++ 98에서 Pimpl Idiom

//in header Widget.h
class Widget{
public:
   Widget();
   ~Widget();
   ...
private:
   struct Impl; //Impl구조체 선언
   Impl* pImpl; //다른 멤버들이 들어갈 구조체의 포인터
}

include가 필요한 멤버변수들을 모아 Impl 구조체에 저장하고 그 포인터를 멤버변수로 들고 있는다. Impl구조체의 정의는 구현파일(cpp)로 미룬다. 그러면 해당 멤버변수에 필요한 #include를 cpp에서 해주면 되기 때문에, 헤더파일에는 어떤 #include도 필요가 없다. cpp파일의 #include는 오직 해당 cpp파일에만 영향을 미치기에 컴파일 시간을 최적화 할 수 있다. Widget.cpp에서 어떻게 정의해야하는지 살펴보자.

//in implementation Widget.cpp
#include "Widget.h"
#include "Gadget.h"   // 여기에 필요한 헤더파일들 include
#include <string>
#include <vector>

Widget::Impl          //Impl구조체 정의
{ 
   std::string name;
   std::vector<double> data;
   Gadget g1, g2, g3;
};

Widget::Widget()      //pImpl을 초기화리스트에서 만들어줌
   :pImpl(new Impl())    
{};

Widget::~Widget()     //pImpl 소멸자에서 해제
{ delete pImpl; }         

C++ 11에서 PImpl idiom

pImpl이 raw pointer이기에 생성자와 소멸자에서 각각 할당/해제를 수행한다. c++ 98수준에서는 이런 방식을 사용했겠지만, c++ 11에서 이것은 너무나 raw하다. 이번 챕터의 취지에 맞게 우리는 스마트한 스마트 포인터를 사용해보자. 클래스 멤버변수들을 포함한 pImpl은 해당 클래스의 인스턴스가 가지고 있기만 하면 충분한 포인터이다. 따라서 적합한 스마트 포인터는 std::unique_ptr일 것이다. 이것을 그대로 적용해보자.

//in header Widget.h
class Widget{
public:
   Widget();
   ...

private:
   struct Impl;
   std::unique_ptr<Impl> pImpl //unique_ptr로 대체
};

//in implementation 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() //초기화 리스트에서 unique_ptr생성
   :pImpl(std::make_unique<Impl>()) 
{}

차이점이 눈에 들어오는가? 우선 할당 해제할 필요가 없어졌고, 소멸자가 사라졌다. 스마트 포인터는 자신이 그 쓸모를 다하면 알아서 사라져버리는 포인터이기 때문이다. 해당 인스턴스가 사라지면 인스턴스가 소유한 unique_ptr도 자동 해제될 것이다. 이것이 c++11의 스마트 포인터의 위엄이다.

#include "Widget.h"
Widget w; //에러

하지만 위의 코드대로 하면 에러가 발생한다. (visual studio 13버전은 에러안남 ;;) 컴파일러 마다 다르겠지만, 이 에러 메시지는 sizeof 또는 delete를 온전하지 않은(incomplete) 클래스(타입)에 사용했다는 느낌의 내용을 전달할 것이다. unique_ptr을 사용하면 자주보는 메시지인데, unique_ptr이 incomplete 타입에 대한 지원이 없기 때문이기도하고, pImpl에 자주 사용되기 때문이기도 하다. 이 pImpl에서 왜 이런 에러가 발생하는지에 대해서 잘 이해할 수만 있다면 자주 보이는 이 에러메시지를 피할 수 있다.

Pimpl + unique_ptr 에러 이해하기

일단 이 에러는 w가 해제되는 시점에 발생한다. w가 해제될때 호출되는 소멸자는 complier가 자동 생성한 소멸자일 것이다. 자동 생성 소멸자의 룰에따라 이 소멸자는 모든 멤버들의 소멸자를 호출할 것이다. unique_ptr 타입인 pImpl도 마찬가지로 소멸자가 호출될 것이다. custom deleter없이 선언된 unique_ptr은 default deleter를 호출하게 될 것이다. 이 dafault deleter에는 static_assert로 pointer타입이 incomplete이면 안된다는 조건이 걸려있다. 우리가 받는 에러메시지는 이 static_assert가 fail되면서 발생한다. 그런데 분명 우리는 Impl을 cpp에서 정의했는데 소멸시점에 왜 incomplete라고 뜨는 것인가? 라는 의문을 갖는 사람이 있을 것이다.(뜨끔)

자동으로 생성되는 special member function들은 inline으로 생성되는 것이 일반적이라고 item 17에서 언급한 적이 있다. 따라서 자동생성된 소멸자도 inline에서 즉 header에서 곧바로 실행되기 때문에, Impl이 정의되지 않은 incomplete한 타입으로 파악하게 되는 것이다. 이 문제를 피하는 방법은 간단하다. pImpl의 소멸자가 호출되는 시점에 Impl이 complete 타입이기만 하면된다. 그러면 Widget의 소멸자의 호출 시점을 cpp로 땡기면된다. 즉, 소멸자를 정의하면된다!

//in Widget.h
class Widget{
public:
   Widget();
   ~Widget(); //소멸자 선언하고
   ...
private:
   struct Impl;
   std::unique_ptr<Impl> pImpl;
}
//in Widget.cpp
...
Widget::~Widget(){}; //정의하기만 하면 에러 안녕
//or
Widget::~Widget() = default; //어차피 내용없다면 default로 선언해도 충분하다.
...

같은 이유에서 발생하는 다른 문제들 : move

모든 special member function들은 같은 이유에서 같은 에러메시지를 띄울 수 있다. move 생성/대입연산자 부터 알아보자. unique_ptr은 자체적으로 move 연산을 지원하므로 default move면 충분하다.

//in Widget.h
class Widget{
public:
   Widget();
   ~Widget();

   Widget(Widget&& rhs) = default;           
   Widget operator=(Widget&& rhs) = default;  //error 발생
   ...
}

소멸자와 완전히 같은이유에서 에러가 발생한다. move 대입연산을 할때 기존의 멤버를 삭제하는 작업을 진행하기 때문이다. move 생성자는 조금 다르게 exception처리에서 기존 멤버를 삭제하는 작업을 진행하기 때문에 에러가 발생한다. 어쨋든 원인은 동일하고 해결책도 같다.

//in Widget.h
class Widget{
public:
   Widget();
   ~Widget();

   Widget(Widget&& rhs)t;           //선언만
   Widget operator=(Widget&& rhs);  
   ...
}

//in Widget.cpp
...
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;  //정의 해주자.
...

같은 이유에서 발생하는 다른 문제들 : copy

copy는 좀 이야기가 다르다. 하지만 결과는 같다. unique_ptr pImpl을 사용한 클래스에서 copy special-member function들은 자동으로 생성되지 않는다. 이유는 unique_ptr이 move-only타입이기 때문이다. 만약 복사가 된다 쳐도 그냥 unique_ptr을 그대로 복사할 것이기 때문에, 직접 copy 연산자들을 정의해줘야한다. 새로 unique_ptr을 만들고 기존 pImpl의 내용을 복사하자.

//in Widget.h
class Widget{
public:
   ...
   Widget(Widget& rhs);           //선언하고
   Widget operator=(Widget& rhs); 
   ...
}

//in Widget.cpp
...
Widget::Widget(Widget& rhs)
   : pImpl(std::make_unique<Impl>(*rhs.pImpl))
{};
Widget& Widget::operator=(Widget&& rhs){
   *pImpl = *rhs.pImpl;
   return *this;
}
...

Impl 구조체가 일반 타입들로 구성되어있으므로 Impl의 default 복사 생성/대입 연산자를 사용하면 충분하다.(들어간 멤버변수들의 타입이 제대로 복사 연산을 지원하기만 한다면) new 대신 make_unique를 사용한 것도 잘 눈여겨 보자.

share_ptr로 pImpl을 만든다면?

효율을 위해 배타적 소유권을 가진 unique_ptr로 pImpl을 만들어 썻지만 shared_ptr로 pImpl을 사용하는 것도 가능은 하다. (불필요한 control box가 따로 생성되는 비효율의 문제는 분명하지만...) 주목할 점은 shared_ptr에서 우리가 이야기한 모든 문제들이 발생하지 않는다는 점이다.

//in Widget.h
class Widget{
public:
   Widget();
   ...
private:
   struct Impl;
   std::shared_ptr<Impl> pImpl;
};

//in Other.cpp
#include "Widget.h"
...
{
   Widget w1;
   auto w2(w1);       //copy 생성자 문제 없다.
   w1 = std::move(w2);//move 대입 연산 문제 없다.
}//소멸자 문제 없다.

이 모든 이유는 unique_ptr과는 달리 shared_ptr이 custom deleter를 다른 방식으로 관리하기 때문이다. unique_ptr의 경우 deleter를 해당 포인터의 타입으로 관리한다. 따라서 컴파일러는 deleter의 타입을 파악하는데 런타임 데이터를 최소화할 수 있다. 그래서 inline special member function이 호출할 때에 실행 파일을 고려하지 않고 타입 체크가 수행되는 것이다.

shared_ptr의 경우 custom deleter는 포인터 타입의 일부가 아니다. 따라서 deleter를 추론하는데 런타입 데이터를 필요로 한다. 이것은 코드를 좀 느리게 수행하는 대신, deleter를 컴파일이 완료된 시점에서 파악한다. 그래서 inline으로 호출된 함수에서도 실행파일을 고려하여 타입체크를 수행한다. 따라서 shared_ptr에서 해당 타입은 complete한 타입으로 인지할 수 있는 것이다. 물론 그렇다고 Pimple Idiom에서 shared_ptr을 고르는 것은 올바른 선택은 아니다. Widget과 Widget::Impl의 관계는 분명한 배타적 소유권이기에 unque_ptr이 더 적합한 툴이다. 하지만 이런 shared_ptr을 선택했을때 얻는 이점을 알고 있으면 필요한 상황에서 긴요하게 써먹을 수 있을 것이다.