C++/디자인(Design)

C++ 디자인: PImpl

kim선달 2020. 11. 11. 23:11
Select Language

 

C++ 디자인(Design): PImpl (Pointer to Implement)


 

1. 설명

Pimple 디자인은 헤더 파일에서는 실제 구현을 담당하는 클래스를 불완전한 타입으로 선언하고 이에 대한 포인터만 원래 클래스에 남겨두고, 소스 파일에서 구현 클래스를 정의하는 방법입니다.

그래서 Pointer to Implementation (구현(클래스)에 대한 포인터)라는 이름이 붙게 되었습니다.

 

이로 인해서 얻는 장점은 크게 2가지가 있습니다.

  1. 컴파일 시간 단축 / ABI 호환성 보장

    내부 구현이 바뀌어도, 헤더만 참조하는 파일은 재 컴파일이 필요하지 않음

  2. 소스 코드 은닉

    소스 파일을 빌드해서 배포할 시, 소스 코드를 은닉할 수 있습니다.

 

 

다만, 구현 클래스가 템플릿 특수화된 클래스거나, 팩토리 패턴(Factory Pattern)등이 사용 될 경우, 가상 함수 테이블 포인터(vptr) 가 사용되기 때문에(표준이 없고, 컴파일러 마다 다름) PImple 의 혜택들을 받지 못합니다.

 

단점도 있습니다

  1. 호출 오버헤드

    포인터를 통해 실제 구현 함수를 호출하게 되므로, 오버헤드가 발생할 수 있습니다.

    단, 링크 과정에서 최적화되어 없어질 수 있습니다.

  2. 자원 관리 오버헤드

    자원이 힙에 할당되므로(기존 방식은 스택), 객체 생성시와 소멸 시에 오버헤드가 발생 할 수 있습니다.

 


2. 예제

 

PImple 기법을 사용하지 않고, 일반적으로 짠 경우입니다.

#include <iostream>

// Widget.h (헤더 파일)
class Widget {
 public:
  Widget(int a, int b);
  void draw();

 private:
  int data1; // 실제로는 더 복잡한 객체
  int data2;
};

// Widget.cpp (소스 파일)
Widget::Widget(int a, int b) : data1(a), data2(b) {}
void Widget::draw() {
  std::cout << data1 << ", " << data2 << std::endl;
  // do something with data
}

// main
int main(){
  Widget w(3);
  w.draw();
  return 0;
}

(예시에서는 Widget 의 private 멤버들이 int 타입 2개 밖에 없지만, 실제로는 더 복잡하고 많은 멤버들이 있다고 가정합니다)

 

만약, data1이 수정되어야 한다면, 소스 파일 및 헤더파일과, 이를 포함(include) 하는 main 까지도 모두 재 컴파일이 필요하게 됩니다.

그래서 PImple 기법에서는 모든 멤버들을 가진 하나의 Impl 클래스와 이에 대한 포인터만 남깁니다.

 

Widget.hpp

#include <memory>

class Widget {
 public:
  Widget(int a, int b);
  void draw();

  ~Widget();
  Widget(Widget&&);
  Widget(const Widget&) = delete;
  Widget& operator = (Widget&&);
  Widget& operator = (const Widget&) = delete;

 private:
  class Impl;
  std::unique_ptr<Impl> pImpl;
};

 

Widget.cpp

#include "Widget.hpp"
#include <iostream>

class Widget::Impl {
 public:
  Impl(int a, int b) : data1(a), data2(b) {}
  void draw() {
    std::cout << data1 << ", " << data2 << std::endl;
  }

 private:
  int data1;
  int data2;
};

Widget::Widget(int a, int b) : pImpl(std::make_unique<Impl>(a, b)) {}
void Widget::draw() { pImpl->draw(); }

Widget::~Widget() = default;
Widget::Widget(Widget&&) = default;
Widget& Widget::operator=(Widget&&) = default;

 

main.cpp

#include "Widget.hpp"

int main(){
  Widget w(1, 2);
  w.draw();
  return 0;
}

 

이제는 소스 파일 내용이 수정되어도, API 부분(헤더 파일)과 이를 참조하는 main 부분은 재 컴파일 될 필요가 없습니다.

 

 

!! 예시에서는 포인터를 std::unique_ptr 을 사용하였는데, unique_ptr 특성상 소멸자가 객체화(쉽게 말해 실제 unique_ptr 이 생성)되는 시점에 가리키는 타입이 완전한 타입이어야 하기 때문에, 특수 멤버 함수들을 (삭제 하더라도)예시에서 처럼 선언과 구현해 주어야 합니다 !!

 

정말 완벽한 ABI 호환성을 위한다면, unique_ptr 말고 raw pointer 을 사용하는 것이 좋습니다(이 경우에는 물론 특수 멤버 함수및 소멸자를 직접 작성해 주어야 합니다)

아니면 C API 를 제공할 수 도 있습니다.

 


같이 보면 좋은 글

완전한 타입

특수 멤버 함수

 

 

C/C++ 개념 카테고리 바로 가기