본문 바로가기

컴퓨터과학

C++ (21-1) new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호할 것

c++ ptr

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

우선 std::make_unique와 std::make_shared의 기본부터 짚고 넘어갑시다. std::make_shared는 C++11의 일부이지만 아쉽게도 std::make_unique는 아닙니다. std::make_shared는 C++14에서 표준 라이브러리에 포함되었습니다. 그러나 C++을 사용하는 독자라도 걱정할 필요는 없습니다. std::make_unique의 기본적인 버전을 독자가 직접 작성하는 것이 어렵지 않기 때문입니다. 

다음이 바로 기본적인 구현입니다.

<의사 코드>
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
 return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

이 코드에서 보듯이, make_unique는 그냥 자신의 매개변수들을 생성할 객체의 생성자로 완벽 전달하고, new가 돌려준 생 포인터로 std::unique_ptr를 생성해서 돌려줄 뿐입니다. 이런 형태의 구현은 배열을 지원하지 않지만, 필요하다면 make_unique를 독자가 직접 만드는 것이 그리 어렵지 않다는 점은 잘 보여줍니다. 단, 독자가 직접 만든 구현을 std 이름 공간에 집어넣지는 말아야 합니다. 그러면 나중에 C++14 표준 라이브러리 구현으로 업그레이드했을 때 표준 라이브러리 제작사가 제공한 버전과 충돌할 것이기 때문입니다.
(* 완전한 기능을 갖춘 make_unique를 최대한 쉽게 만들어 내는 한 가지 방법은 make_unique의 표준화에 쓰인 제안 문서를 찾아서 거기에 나온 구현을 복사해 쓰는 것입니다. 그 문서는 스테픈 T. 라와웨이드가 작성한 N3656(2013년 4월 18일 자)입니다.)

 std::make_unique와 std::make_shared는 임의의 개수와 형식의 인수들을 받아서 그것들을 생성자로 완벽 전달해서 객체를 동적으로 생성하고, 그 객체를 가리키는 똑똑한 포인터를 돌려주는 세 가지 make 함수 중 둘입니다. 나머지 하나는 std::allocate_shared 입니다. 이 함수는 std::make_shared처럼 작동하되, 첫 인수가 동적 메모리 할당에 쓰일 할당자 객체라는 점이 다릅니다.
똑똑한 포인터를 make 함수들을 이용해서 생성하는 것과 그냥 생성하는 것을 단순하게 비교해 보기만 해도 make 함수들을 선호할 첫 번째 이유가 명백해집니다. 다음 예를 생각해 봅시다.

auto upw1(std::make_unique<Widget>()); // make 함수를 사용
std::unique_ptr<Widget> upw2(new Widget); // 사용하지 않음

auto spw1(std::make_shared<Widget>()); // make 함수를 사용
std::shared_ptr<Widget> spw2(new Widget); // 사용하지 않음

강조된 부분들이 두 방식의 본질적인 차이점입니다. new를 사용하는 버저에서는 생성할 객체의 형식이 되풀이해서 나오지만, make 함수 버전은 그렇지 않습니다. 형식을 여러 번 되풀이하는 것은 소프트웨어 공학의 핵심 교의 중 하나인 "코드 중복을 피하라"와 충돌합니다. 소스코드에 중복(duplication)이 있으면 컴파일 시간이 늘어나고, 목적 코드의 덩치가 커지고, 일반적으로 코드 기반(code base)을 다루기가 좀 더 어려워집니다. 중복된 코드는 일관성이 없는 코드로 진화하기 일쑤이고, 코드 기반의 비일관성은 버그로 이어지는 경우가 많습니다. 그런 점들은 차치하더라도, 뭔가를 두 번 타자하는 것이 한 번만 타자하는 것보다 수고롭다는 점은 명백합니다. 타자량이 줄어드는 것을 싫어할 사람이 있겠습니까??
make 함수들을 선호할 둘째 이유는 예외 안전성과 관련이 있습니다. 어떤 Widget 객체를 그 객체의 우선순위(priority)에 따라 적절히 처리하는 함수가 있다고 합시다.

void rocessWidget(std::shared_ptr<Widget> spw, int priority);

std::shared_ptr를 값으로 전달하는 것이 좀 꺼림칙할 수 있겠지만, Chapter (41)에서 설명하듯이 만일 processWidget이 항상 std::shared_ptr의 복사본을 만든다면 (이를테면 그것을 이미 처리된 Widget들을 관리하는 자료구조에 저장해서), 이러한 값 전달 방식은 합당한 설계상의 결정입니다.
다음으로, 우선순위를 다음과 같은 함수로 계산한다고 합시다.

int computerPriority();

그리고 std::make_shared 대신 new를 사용한 processWidget 호출에서 이 함수를 사용한다고 합시다.

processWidget(std::shared_ptr<Widget>(new Widget), computerPriority()); // 자원 누수 위험이 있음!

주석에서 언급했듯이, new로 생성한 Widget에 대한 누수가 발생할 수 있습니다. 왜 그럴까요?? 호출하는 코드와 호출된 함수 모두 std::shared_ptr를 사용하며, std::shared_ptr는 자원 누수를 방지하도록 설계되어 있습니다. 어떤 객체를 가리키는 마지막 std::shared_ptr가 그 객체를 더 이상 가리키지 않게 되면 그 객체는 자동으로 파괴됩니다. 다른 모든 곳에서도 std::shared_ptr가 쓰인다고 하면, 이 코드에서 누수가 발생할 여지는 없어야 하지 않을까요??
그래도 누수가 발생할 수 있는 이유는 컴파일러가 소스 코드(원시 코드)를 목적 코드(object code)로 번역하는 방식과 관련이 있습니다. 실행 시점에서 함수가 호출될 때, 함수의 코드가 샐 행 되기 전에 함수의 인수들이 먼저 평가됩니다. processWidget 호출의 경우 processWidget 자체가 실행되기 전에 다음과 같은 일들이 일어납니다. 
- 표현식 "new Widget"이 평가됩니다. 즉, Widget이 힙에 생성됩니다.
- new가 산출한 포인터를 관리하는 std::sharaed_ptr<Widget>의 생성자가 실행됩니다.
- computerPriority가 실행됩니다.

그런데 컴파일러가 이 세 가지 일을 딱 이 순서대로 실행하는 코드를 생성해야 하는 것은 아닙니다. std::shared_ptr 생성자가 호출되려면 그 인수가 먼저 평가되어야 하므로 "new Widget"이 std::shared_ptr 생성자보다 먼저 평가되는 것은 확실합니다. 그러나 computerPriority는 그 호출들 사이에서 실행될 수도 있고, 그다음에 실행될 수도 있고, 더욱 중요하게는 호출들 사이에서 실행될 수도 있습니다. 즉, 컴파일러가 이 연산들을 다음과 같은 순서로 실행하는 목적 코드를 산출할 수도 있습니다.
1. "new Widget"을 실행합니다.
2. computerPiority를 실행합니다.
3. std::shared_ptr 생성자를 실행합니다. 
그런 코드가 만들어졌다면, 그리고 실행 시점에서 computerPriority가 예외를 던졌다면, 단계 1에서 동적으로 할당된 Widget 객체가 새게 됩니다. 그 객체를 관리할 std::shared_ptr는 단계 3에서야 생성되며, 따라서 예외가 던져진 시점에서 Widget 객체는 그냥 생 포인터가 가리키는 동적 할당 객체일 뿐입니다.