본문 바로가기

컴퓨터과학

C++ (21-3) new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호할 것 / (22-1) Pimpl 관용구를 사용할 떄에는 특수 멤버 함수들을 구현 파일에서 정의할 것

c++ new

[new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호할 것]

 

한 예로, 이전에 본 예외에 안전하지 않은 processWidget 함수 호출 예제를 조금 고쳐서 살펴보겠습니다. 이번에는 커스텀 삭제자를 사용합니다.

void processWidget(std::shared_ptr<Widget> spw, int priority); // 이전과 동일

void cusDel(Widget *ptr); // 커스텀 삭제자

다음은 예외에 안전하지 않은 호출입니다.

processWidget(std::shared_ptr<Widget>(new Widget, cusDel), computePriority()); // 이전처럼 자원 누수 위험이 있음!

이전에도 설명했듯이, 만일 computePriority가 "new Widget" 이후에, 그러나 std::shared_ptr 생성자 이전에 호출된다면, 그리고 computePriority가 예외를 방출한다면, 동적으로 할당된 Widget이 새게 됩니다.
이번 예제에서는 커스텀 삭제자 때문에 std::make_shared를 사용할 수 없습니다. 따라서 예외 안전성 문제를 피하는 방법은 Widget의 할당과 std::shared_ptr의 생성을 개별적인 문장으로 두고, 그 문장에서 생성한 std::shared_ptr로 processWidget을 호출하는 것입니다. 다음은 이러한 기법의 기본을 보여주는 예인데, 잠시 후에 살펴보겠지만 성능을 개선할 여지가 남아 있습니다.

std:: shared_ptr<Widget> spw(new Widget, cusDel);

processWidget(spw, computePriority()); // 정확하지만, 최적은 아님; 본문 참고

이 코드가 예외에 안전한 이유는, 비록 생성자에서 예외가 발생한다고 해도, std::shared_ptr는 생성자로 전달된 생 포인터의 소유권을 확보하기 때문입니다. 이 예에서 만일 spw의 생성자가 예외를 던진다고 해도(이를테면 제어 블록을 위한 메모리의 동적 할당에 실패해서), "new Widget"으로 만들어진 포인터에 대해 cusDel이 확실히 호출되므로 메모리 누수는 발생하지 않습니다.
그럼 성능상의 작은 비효율성 문제를 살펴봅시다. 예외에 안전하지 않은 호출에서는 processWidget에 오른 값을 넘겨줍니다.

processWidget(
 std::shareed_ptr<Widget>(new Widget, cusDel), computePriority() // 인수가 오른 값
);

그러나 예외에 안전한 호출에서는 왼값을 넘겨줍니다.

processWidget(spw, computePriority()); // 인수가 왼 값

processWidget의 std::shared_ptr 매개변수는 값 전달 방식이므로, 오른 값을 넘겨줄 때에는 std::shared_ptr 객체가 이동 생성에 의해 만들어집니다. 그러나 왼 값을 넘겨주면 복사 생성이 일어납니다. std::shared_ptr의 경우 이동과 복사의 차이가 클 수 있습니다. std::shared_ptr를 복사하려면 참조 횟수를 원자적으로 증가해야 하지만, std::shared_ptr를 이동할 때에는 참조 횟수를 조작할 필요가 없기 때문입니다. 예외에 안전한 코드의 성능을 예외에 안전하지 않은 코드의 수준으로 끌어올리려면, std::move를 적용해서(Chapter (23)) spw를 오른 값으로 변환해야 합니다.

processWidget(std::move(spw), computePriority()); // 예외 안전성과 효율성을 모두 갖춘 방식

이것이 재미있고 유익한 지식이긴 하지만, 실제로 써먹는 상황은 그리 많지 않을 것입니다. make 함수를 사용할 수 없는 상황을 그리 자주 만나지는 않기 때문입니다. 사용하지 않을 특별히 강력한 이유가 없는 한, make 함수를 사용하는 것이 옳은 선택입니다.


*** Key Point ***
- new의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_shared와 std::allocate_shared의 경우 더 작고 빠른 코드가 산출됩니다.
- make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기치를 전달해야 하는 경우가 있습니다.
- std::shared_ptr에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우와 (2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr들이 해당 std::shared_ptr들보다 더 오래 살아남는 경우입니다.

 


[Chapter 22 : Pimpl 관용구를 사용할 떄에는 특수 멤버 함수들을 구현 파일에서 정의할 것] 

너무 긴 빌드 시간을 주이느라 고생한 경험이 있는 독자라면 Pimpl 관용구("pointer to implementation" idiom)를 알 것입니다. 

(* Pimpl 관용구를 널리 알린 허브 서터의 글 "GotW #100: Compilation Firewalls"에 따르면 Pimpl이라는 이름은 허브 서터의 동료 제프 섬너(Jeft Sunner)가 고안한 것으로, 구현을 뜻하는 impl에 포인터를 뜻하는 접두사 p(헝가리식 표기법에 흔히 쓰이는)를 붙여서 만든 변수 이름 pimpl에서 비롯된 것이라고 합니다. 그러나 자신처럼 제프도 "종종 조악한 말장난(horrid puns)을 즐기는" 친구라고 언급한 것으로 볼 때, 같은 발음의 영어 단어인 pimpl(여드름)과 완전히 무관하다고는 할 수 없습니다.)

이 관용구는 클래스의 자료 멤버들을 구현 클래스(또는 구조체)를 가리키는 포인터로 대체하고, 일차 클래스에 쓰이는 자료 멤버들을 그 구현 클래스로 옮기고, 포인터를 통해서 그 자료 멤버들에 간접적으로 접근하는 기법입니다. 예를 들어 다음과 같은 모습의 Widget 클래스가 있다고 합시다.

class Widget { // "widget.h" 헤더 파일 안에서
public: 
 Widget();
 ...
private:
 std::string name;
 std::vector<double> data;
 Gadget g1, g2, g3; // Gadget은 어떤 사용자 정의 형식
};

Widget의 자료 멤버들이 std::string, std::vector, Gadget 형식이므로, Widget을 컴파일하려면 그 형식들의 헤더들이 있어야 합니다. 즉, Widget의 클라이언트는 반드시 #include를 이용해서 <string>과 <vector>, gadget.h를 포함해야 합니다. 이 헤더들 때문에 Widget 클라이언트의 컴파일 시간이 증가하며, 또한 클라이언트가 그 헤더들이 내용에 의존하게 됩니다. 한 헤더의 내용이 변하면 Widget 클라이언트도 반드시 다시 컴파일해야 합니다. 표준 헤더인 <string>과 <vector>는 자주 바뀌지 않지만, gadget.h는 자주 바뀔 수 있습니다.
C++98에서 Pimpl 관용구를 Widget에 적용했다면, Widget의 자료 멤버들을 다음과 같이 선언만 하고 정의는 하지 않은 구조체(struct)를 가리키는 포인터로 대체했을 것입니다.
class Widget { // 여전히 "widget.h" 헤더 안
public:
 Widget();
 ~Widget(); // 소멸자가 필요함
 ...
private:
 struct Impl; // 구현용 구조체와 그것을 가리키는 포인터를 선언
 Impl *pImpl;
};