KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 09:57

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

1. Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.
2. std::unique_ptr 형식의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 구현해야 한다. 컴파일러가 기본으로 작성하는 함수 구현들이 사용하기에 적합한 경우에도 그렇게 해야 한다.
3. 위의 조언은 std::unique_ptr 에 적용될 뿐, std::shared_ptr 에는 적용되지 않는다.

 

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

class Widget {
public:
	Widget();
	~Widget();
	...

private:
	struct Impl;
	Impl *pImpl;
};

위 방식의 장점은 Widget 클라이언트가 WIdget 에 들어가야 했던 자료형의 헤더들을 #include 로 포함시킬 필요가 없다는 것이다. 따라서 컴파일이 빨라지고, 헤더 파일이 변경되고 Widget 의 클라이언트에는 영향이 가지 않는다.

선언만 하고 정의는 하지 않은 형식을 불완전 형식(imcomplete type) 이라고 부르기도 한다(Widget::Impl 이 그런 형식). Widget 클래스를 사용하는 헤더 파일(.h)과 구현 파일(.cpp)을 다음과 같이 정의했다고 하자.

/* widget.h 파일 */
class Widget {
public:
	Widget();
	// pImpl 이 std::unique_ptr 이므로 소멸자 불필요
	...

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;"

그 이유는 w 가 파괴되는 지점에서 w 의 소멸자가 호출되는데, std::unique_ptr 를 이용하는 Widget 클래스에 따로 소멸자를 선언하지 않았으므로 컴파일러는 소멸자 안에 Widget 의 자료 멤버 pImpl 의 소멸자를 호출하는 코드를 삽입한다. pImpl 은 pImpl 은 std::unique_ptr 이므로, std::unique_ptr 안에 있는 생 포인터에 대해 delete 를 적용한다. 그런데 대부분의 표준 라이브러리 구현들에서 그 삭제자 함수는 delete 를 적용하기 전에, 생 포인터가 불완전한 형식을 가리키는지를 C++11 static_assert 를 이용해 점검한다. 컴파일러는 w 의 파괴를 위한 코드를 산출하는 과정에서 static_assert 가 참이 아니라고 판정하므로, 컴파일 오류가 발생하는 것이다.

std::unique_ptr<Widget::Impl> 을 파괴하는 코드가 만들어지는 지점에서 Widget::Impl 이 완전한 형식이 되게 하면 문제가 해결된다. Widget::Impl 의 정의는 widget.cpp 에 있으므로, Widget::Impl 의 정의 이후에 컴파일러가 그 소스 파일에만 있는 Widget 의 소멸자의 본문을 보게 한다면 컴파일이 잘 된다. 즉, widget.h 에서 Widget 의 소멸자를 선언만 해 두고, widget.cpp 에서 정의를 해 주면 된다(비워 놓아도 되고, "= default" 를 붙여도 된다). 이동 연산들도 마찬가지로 처리해 주면 된다.

복사 연산의 경우, 우리가 원하는 깊은 복사(deep copy)가 가능하려면 직접 구현해 주어야 한다(헤더 파일에는 정의만).

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

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;
}

 

그런데 pImpl 을 std::unique_ptr 가 아니라 std::shared_ptr 로 만들면, 이 항목의 조언이 적용되지 않는다. 컴파일러가 자동으로 만든 이동 연산들이 정상적이고 바라는 대로 작동할 것이기 때문이다!

Comments