본문 바로가기
C++/Effective Modern C++

Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정리하라

by COCO1337 2020. 7. 16.

Pimpl 관용구는 클래스의 자료 멤버들을 구현 클래스 또는 구조체를 가리키는 포인터로 대체하고, 일차 클래스에 쓰이는 자료 멤버들을 그 구현 클래스로 옮기고, 포인터를 통해 그 자료 멤버들에 간접적으로 접근하는 방법이다.

일반적으로 헤더의 의존도를 떨어뜨리고, 컴파일 시간을 단축해 준다. 기존 C++98에서의 구현 방법 예시이다.

// "widget.h"
class Widget {
public:
  Widget();
  ...
private:
  std::string name;
  std::vector<double> data;	// 헤더 내에 #include <string>, #include <vector> 필요
  Gadget g1, g2, g3;
};

// Pimpl 관용구 적용
class Widget {
public:
  Widget();
  ~Widget();	// 소멸자 필요
  ...
private:
  struct Impl;	
  Impl *pImpl;	// 구현용 구조체와, 그것을 가리키는 포인터
};

이처럼 선언만 하고 정의를 하지 않은 형식을 불완전 형식이라고 부르기도 한다.

그리고 원래의 클래스에서 사용하던 자료멤버들을 담는 객체를 동적으로 할당 및 해제 하는 코드를 추가한다.

// "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() : pImpl(new Impl) {}		// 자료 멤버 할당
Widget::~Widget() { delete pImpl; }		// 자료 멤버 파괴

예제코드에서 Impl 객체를 동적으로 할당하고 해제하기 때문에 Widget에 반드시 소멸자가 필요하다.


raw pointer가 아닌 std::unique_ptr를 사용해서 바꿔보도록 하자

// "widget.h"
class Widget {
public:
  Widget();
  ...
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

// "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()
: pImpl(std::make_unique<Impl>())
{}

코드 컴파일을 문제 없지만 클라이언트 쪽에서 Widget w; 등과 같은 용법은 컴파일 되지 않는다.

그 이유는 std::unique_ptr가 불완전한 형식을 지원한다고 되어있는 점이다. 이 문제는 w가 파괴되는 지점에 있다. 소멸자를 명시적으로 작성하지 않았기 때문에 컴파일러가 자동으로 생성해 주게 된다. 자동 생성된 소멸자 코드는 std::unique_ptr 안에 있는 raw pointer에 대해 delete를 적용하는 함수이다. 그런데 대부분의 표준 라이브러리 구현들에서 그 삭제자 함수는 delete를 적용하기 전에 C++11의 static_assert를 이용해 점검한다. 컴파일러가 자동으로 작성하는 다른 모든 특수 함수처럼 Widget의 소멸자는 inline이기 때문에 static_assert가 참이 아닌 것으로 판명되게 된다.

이 문제에 대한 해결 방법은 완전한 형식이 되게 만들어 주는 것이다. 컴파일러는 형식의 정의를 보게 되면 그 형식을 완전한 형식으로 간주한다. 이 예제에서는 헤더 파일에 소멸자를 선언만 해두고 구현 파일에서 정의를 하는 것으로 해결 할 수 있다.


소멸자를 명시적으로 선언 했다면 컴파일러는 이동 연산을 자동으로 작성하지 않는다. 이동을 지원하기 위해 이동 연산들을 직접 선언해야 한다. 이동 배정 연산자 같은 경우 재배정 하기 전에 기존에 가리키던 개체를 파괴할 때 앞서 얘기했던 문제(불완전한 형식을 지원)와 동일한 문제가 발생한다. 이동 연산의 경우 컴파일러는 이동 연산자 안에서 예외가 발생했을 때 pImpl을 파괴하기 위한 코드를 작성하는데 pImpl을 파괴하려면 Impl이 완전한 형식이어야 한다. 즉, 이전과 동일한 문제가 발생하기 때문에 이동연산 또한 선언만 해두고 구현 파일에서 정의 하는 것으로 해결할 수 있다.

// "widget.h"
class Widget{
public:
  Widget();
  ~Widget();			// 선언만 해주고
  Widget(Widget&& rhs);
  Widget& operator=(Widget&& rhs);
  
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

// "Widget.cpp"
#include ....
struct Widget::Impl { ... };
Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() = default;		// 정의
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&&rhs) = default;

만약 위 예제에서 Gadget에 복사 가능하다면 복사 연산을 지원하는 것이 합당하다. 하지만 std::unique_ptr 같은 이동 전용 형식이 있는 클래스에 대해서는 컴파일러가 복사 연산들을 작성해주지 않으며, 작성해준다 해도 얕은 복사를 수행하기 때문에 사용자가 직접 정의를 해야 한다. 앞의 예제와 마찬가지로 헤더 파일에서 선언만 해주고 구현 파일에서 구현하는 형식으로 깊은 복사를 지원하도록 만들어보자

// "widget.h"
class Widget {
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가 이미 이동 되어서 해당 pImpl 포인터가 널인 경우를 처리해주는 것만 제외하면 간단하다. Widget의 복사 연산들은 컴파일러가 작성한 Widget::Imple의 복사 연산들을 호출한다. 두 함수 모두 new가 아닌 make함수를 사용했다.


하지만 std::shared_ptr에서는 앞의 예제들이 적용되지 않는다. 그 이유는 서로 커스텀 삭제자를 지원하는 방식이 다르기 때문이다. std::unique_ptr에서 삭제자의 형식은 smart pointer의 형식이지만 std::shared_ptr는 그렇지 않다.

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

// "widget.cpp"
Widget w1;
auto w2(std::move(w1));		 // 이동 생성
w1 = std::move(w2);		// 이동 배정

위의 예제는 정상 작동하게 된다.


Conclusion

- Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.

- std::unique_ptr 형식의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 구현한다. 컴파일러가 기본으로 작성하는 함수 구현들이 사용하기에 적합해도 그렇게 해야 한다.

- 위 조언들은 std::unique_ptr에만 적용되고 std::shared_ptr에는 적용되지 않는다.


Reference

Effective Modern C++ 항목 22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라

반응형

댓글