dev/C++

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

dev_dev 2025. 6. 21. 08:42

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

Pimpl 은 Pointer to implementation idiom 이라고 불린다.

클래스의 자료 멤버를 구현 클래스(or 구조체)를 가리키는 포인터로 대체하는 방식이다.

이 때 기존의 자료 멤버는 구현 클래스로 옮기고 포인터로 접근한다.

 

클래스의 자료 멤버를 header에서 직접 구현하는 경우 아래와 같은 단점이 있다.

(*클라이언트는 header 파일을 사용하는 cpp 코드)

  1. 클라이언트의 컴파일 시간 증가
    • ex) 하나의 cpp 파일에서 여러개의 header 를 include 함
      • 이 때 include 한 header 에, 여러 개의 header 가 또 존재하는 경우 컴파일 시간 증가함
  2. 클라이언트가 header 내용에 의존하게 되어, 내용이 바뀌면 재컴파일 필요함
    • 클라이언트는 header 의 클래스 private 변수가 바뀌더라도, 메모리 할당 등 이유로 재컴파일 해야함

Pimpl 은 C++98 에서는 아래와 같이 사용했다.

class Widget {
	public:
		Widget();
		~Widget();
		
	private:
		struct Impl;
		Impl *pImpl;
}

"클래스 자료 멤버를 header에서 직접 구현함" 의  단점이 해소된 것을 확인할 수 있다.

  1. 자료 멤버 형식을 작성할 필요없으므로, 추가적 include 가 불필요함 (클라이언트 컴파일 빨라짐)
  2. header 내용 변경되어도 클라이언트 영향없음

위와 같은 선언만 하고 정의하지 않는 형식을 불완전 형식(incomplete type)이라고 부른다.

header 에서는 pimpl 방식으로 작성하고, 클래스(or 구조체)의 구현은 cpp 에서 하면 된다.

결국 의존성이 Widget.h 에서 widget.cpp (Widget 구현자) 로 옮겨진 것이다.

 

Impl 포인터가 가리키는 객체 또한 동적으로 할당, 해제 해야한다.

Widget::Widget():pImpl(new Impl) {}

~Widget::Widget() { delete pImpl; }

 

위 방식은 예전 방식 (raw pointer) 이므로 smart pointer인 std::unique_ptr 를 사용하자.

Widget::Widget():pImpl(std::make_unique<Impl>()) {}


앞선 예시와 달리 smart pointer 를 사용하는 경우 Widget 클래스에 소멸자는 불필요하다.

(포인터가 삭제될 때 가리키는 객체도 자동 삭제)

 

불완전 형식(incomplete type) 으로 인한 주의점

이제 앞서 언급한 불완전 형식 ( 선언만 있고 정의는 다른 구현 코드에서 처리 )로 인해 발생하는, 신경써야하는 부분을 알아보자.

참고로 이 부분은 std::unique_ptr 사용할 때만 주의하면 된다.

 

std::unique_ptr 와 std::shared_ptr 의 차이인, 커스텀 삭제자 형식의 지원/미지원에 의해 상이한 동작이다.

std::unique_ptr 는 더 최적화를 하기 때문에, 사용에 엄격한 부분이 발생한다.

따라서 컴파일러가 작성한 특수 멤버 함수 (소멸자, 이동 연산) 쓰이는 시점에서 내부 형식이 완전해야한다.

 

컴파일 에러 예시)

// main.h
#include <memory>

class Widget{
    private:
        struct Impl;
        std::unique_ptr<Impl> pImpl;
};

// main.cpp
#include "main.h"

struct Widget::Impl {
    int a;
};

int main() {
    Widget w;
}

 

 

컴파일러에 따라, 아래 로직에서 컴파일 에러가 발생할 수 있다. (필자의 apple clang 17.0.0에서는 에러 미발생)

  1. main 스코프를 벗어나며 w 가 파괴되는 시점에 w 삭제자 호출됨
  2. 소멸자 정의되어 있지 않으므로, 컴파일러가 삭제자 자동 작성
  3. 자동 작성된 삭제자에서는 Widget 멤버 포인터인 pImpl 소멸자를 호출함
  4. 삭제자가 delete 적용하기 전에 raw pointer 가 불완전한 형식 가리키지 않는지 static_assert 로 점검함
  5. 불완전한 형식이므로 컴파일 에러 발생

struct 미구현하여 임의로 불완전 형식으로 만들었을 때, 아래와 같은 에러 발생

/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/unique_ptr.h:79:19: error: invalid application of 'sizeof' to an incomplete type 'Widget::Impl' 79 | static_assert(sizeof(_Tp) >= 0, "cannot delete an incomplete type");

 

삭제 코드 만들어지는 지점에 Widget::Impl 를 완전한 형식으로 만들면 해결된다.

이를 위해 header 에서 명시적으로 삭제자를 선언하고, cpp 에서 삭제자를 정의하면 된다.

 

예시)

// main.h
#include <memory>

class Widget{
    public:
        ~Widget();	// 삭제자 명시적 선언
    private:
        struct Impl;
        std::unique_ptr<Impl> pImpl;
};

// main.cpp
#include "main.h"

struct Widget::Impl {
    int a;
};

Widget::~Widget() {}	// 삭제자 구현
// Widget::~Widget()=default;	// 동일

int main() {
    Widget w;
}

 

 

이동 연산의 경우도 삭제자와 동일하다.

이동 연산 중 예외가 발생해서 삭제자가 호출되었을때, 가리키는 대상이 완전한 형식인지 체크하는 과정이 존재한다.

이 때 불완전 형식이면 컴파일 에러가 발생한다.

따라서 header 에서 명시적으로 이동 연산을 선언하고, cpp 에서 이동 연산을 정의하면 된다.

 

예시)

// main.h
#include <memory>

class Widget{
    public:
        Widget();
        ~Widget();
        Widget(Widget &&rhs);
        Widget& operator=(Widget &&rhs);
    private:
        struct Impl;
        std::unique_ptr<Impl> pImpl;
};

// main.cpp
#include "main.h"

struct Widget::Impl {
    int a;
};

Widget::Widget():pImpl(std::make_unique<Impl>()) {}

Widget::~Widget() {}

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

int main() {
}

 

이제 복사 연산을 다루는데, 복사 연산은 직접 정의해주어야한다.

std::unique_ptr 같은 이동 전용 형식 있는 클래스는 컴파일러가 복사 연산을 자동으로 작성해주지 않으며, 작성하더라도 shallow copy 이기 때문이다. (포인터까지만 복사)

 포인터가 가리키는 객체까지 복사하는 deep copy 를 구현하기 위해서는 직접 구현해야한다.

 

복사 연산자 예시)

Widget::Widget(const Widget& rhs):pImpl(nullptr) {
	if (rhs.pImpl) {
		pImpl = std::make_unique<Impl>(*rhs.pImpl);
	}
}

마찬가지로 header 에서 명시적으로 복사 연산을 선언하고, cpp 에서 복사 연산을 정의한다.