본문 바로가기

컴퓨터과학

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

c++ new

std::make_shared를 사용하면 이런 문제가 생기지 않습니다. 이 경우 호출 코드는 다음과 같은 모습입니다.

processWidget(std::make_shared<Widget>(), computePriority()); // 자원 누수의 위험이 없음

실행시점에서 std::make_shared가 먼저 호출될 수도 있고 computerPriority가 먼저일 수도 있습니다. 만일 std::make_shared가 먼저라면, 동적으로 할당된 Widget을 가리키는 생 포인터는 computerPriority가 호출되기 전에 반환된 std::shared_ptr 안에 안전하게 저장됩니다. 그런 다음 computerPriority가 예외를 방출한다면, std::shared_ptr의 소멸자가 피지칭 Widget 객체를 파괴합니다. 만일 computerPriority가 먼저라면, 그리고 예외를 방출한다면, std::make_shared는 아예 호출되지 않으므로 Widget이 동적으로 할당되지 않으며, 따라서 누수를 걱정할 자원이 아예 없는 것입니다.
std::shared_ptr와 std::make_shared를 std::unique_ptr와 std::make_unique로 대체해도 정확히 동일한 추론이 적용됩니다. 따라서, new 대신 std::make_unique를 사용하는 것은 std::make_shared를 이용해서 예외에 안전한 코드를 작성하는 것 만큼이나 중요합니다.
std::make_shared의 특징(new를 직접 사용하는 것에 비한) 하나는 향상된 효율성입니다. std::make_shared를 사용하면 컴파일러가 좀 더 간결한 자료구조를 사용하는 더 작고 빠른 코드를 산출할 수 있게 됩니다. 다음처럼 new를 직접 사용한다고 합시다.

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

이 코드가 한 번의 메모리 할당을 실행한다는 점은 자명합니다. 그러나 실제로는 두 번의 할당이 일어납니다. Chapter (19)에서 설명하듯이, 모든 std::shared_ptr에는 피지칭 객체의 참조 횟수를 비롯한 여러 가지 관리 자료를 담는 제어 블록을 가리키는 포인터가 있습니다. std::shared_ptr 생성자는 이 제어 블록을 위한 메모리를 할당합니다. 즉, new를 직접 사용해서 std::shared_ptr를 생성하면 Widget 객체를 위한 메모리 할당과 제어 블록을 위한 또 다른 메모리 할당이 일어납니다. 

대신 std::make_shared를 사용하면,
auto spw = std::make_shared<Widget>();

한 번의 할당으로 충분합니다. 이는 std::make_shared가 Widget 객체와 제어 블록 모두를 담을 수 있는 크기의 메모리 조각을 한 번에 할당하기 때문입니다. 이런 최적화를 적용하면 메모리 할당 호출 코드가 한 번만 있으면 되므로 프로그램의 정적 크기가 줄어듭니다. 또한, 실행시점에서 메모리를 한 번만 할당하므로 실행코드의 속도도 빨라집니다. 더 나아가서, std::make_shared를 사용하면 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요가 없어져서 프로그램의 전체적인 메모리 사용량이 줄어들 여지가 생깁니다.
std::make_shared의 효율성 분석은 std::allocate_shared에도 거의 그대로 적용되므로, std::make_shared의 성능상의 이점들은 그 함수로도 확장됩니다.
이처럼, new를 직접 사용하는 것보다 make 함수들을 선호하라는 조언에는 강력한 근거들이 존재합니다. 그러나, 비록 make 함수들이 소프트웨어 공학과 예외 안전성, 효율성 면에서 유리하긴 하지만, 이 항목의 조언은 이 함수들을 항상 사용하라는 것이 아니라 선호하라는 것임을 주의하기 바랍니다. 이는 이들을 사용할 수 없거나 사용하지 않아야 하는 상황이 존재하기 때문입니다.
예를 들어, make 함수들 중에는 커스텀 삭제자(Chapter (18), (19) 참고)를 지정할 수 있는 것이 없습니다. 그러나 std::unique_ptr와 std::shared_ptr는 커스텀 삭제자를 받는 생성자들을 제공합니다. Widget을 위한 다음과 같은 커스텀 삭제자가 있다고 할 때,

auto widgetDeleter = [](Widget* pw) { ... };

이 삭제자를 사용하는 똑똑한 포인터를 new를 이용해서 생성하는 것은 다음과 같이 아주 간단합니다.

std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);

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

그러나 make 함수로는 이런 일을 할 수 없습니다.
make 함수들의 두 번째 한계는 그 구현들의 구문적 세부사항에서 비롯된 것입니다. Chapter (7)에서 설명하듯이, std::initializer_list를 받는 생성자와 받지 않는 생성자를 모두 가진 형식의 객체를 생성할 때, 새성자 인수들을 중괄호로 감싸면 중복적재 해소 과정에서 std::initializer_list를 받는 버전이 선택되고, 괄호로 감싸면 std::initializer_list를 받지 않는 버전이 선택됩니다. make 함수들은 자신의 매개변수들을 객체의 생성자에 완벽하게 전달합니다. 그런데 이때 중괄호를 사용할지, 아니면 괄호를 사용할지 일부 형식에서는 이 질무느이 답에 따라 커다란 차이가 생길 수 있습니다. 예를 들어 다음과 같은 호출들에서,

auto upv = std::make_unique<std::vector<int>>(10, 20);

auto spv = std::make_shared<std::vector<int>>(10, 20);

똑똑한 포인터들은 값이 20인 요소 열 개를 담은 std::vector를 가리킬까, 아니면 값이 각각 10과 20인 두 요소를 담은 std::vector를 가리킬까? 또는, 둘 중 하나로 딱 정해지지는 않는 문제일까요??
다행히 그런 비결정론적인 상황은 아닙니다. 두 호출 모두, 모든 요소의 값이 20인 요소 얼 개짜리 std::vector를 생성합니다. 이는 make 함수들이 내부적으로 매개변수들을 완벽 전달할 때 중괄호가 아니라 괄호를 사용함을 뜻합니다. 그러나 불행한 일은, 피지칭 객체를 중괄호 초기치로 생성하려면 반드시 new를 직접 사용해야 한다는 것입니다. make 함수 중 하나로 그런 일을 하려면 중괄호 초기치를 완벽하게 전달할 수 있어야 하는데, Chapter (30)에서 설명하듯이 중괄호 초기치의 완벽 전달은 불가능합니다. 그러나 Chapter (30)에 우회책이 하나 나옵니다. auto 형식 연역을 이용해서 중괄호 초기치로부터 std::initializer_list 객체를 생성하고(Chapter (2) 참고), 그것을 make 함수에 넘겨주면 됩니다.

// std::initializer_list 객체를 생성
auto initList = { 10, 20 };

// 그 std::initializer_list 객체를 이용해서 std::vector를 생성
auto spv = std::make_shared<std::vector<int>>(initList);

std::unique_ptr의 경우에는 이상 두 시나리오가 make 함수들이 문제가 되는 상황의 전부입니다. std::shared_ptr와 해당 make 함수들의 경우에는 문제가 되는 시나리오가 두 개 더 있습니다. 둘 다 극단적인 경우이지만, 극단이 일상인 개발자들도 있으며, 독자 역시 그런 개발자일 수도 있습니다.
클래스 중에는 자신만의 operator new와 operator delete를 정의하는 것들이 있습니다. 어떤 형식에 이 두 함수가 존재한다는 것은, 전역 메모리 할당 루틴과 해제 루틴이 그 형식의 객체에 적합하지 않음을 뜻합니다. 이런 클래스 고유(class-specific) 메모리 관리 루틴들은 단지 클래스의 객체와 정확히 같은 크기의 메모리 저각들만 할당, 해제하는 경우가 많습니다. 예를 들어 Widget 클래스를 위한 operator new와 operator delete라면 크기가 정확히 sizeof(Widget)인 메모리 조각들의 할당과 해제를 처리하는 데 특화된 경우가 많습니다. 그런 루틴들은 컷그텀 std::shared_ptr의 커스텀 할당(std::allocate_shared를 통한)과 커스텀 해제(커스텀 삭제자를 통한)에는 잘 맞지 않습니다. 왜냐하면, std::allocate_shared가 요구하는 메모리 조각의 크기는 동적으로 할당되는 객체의 크기가 아니라 그 크기에 제어 블록의 크기를 더한 것이기 때문입니다. 결과적으로, 클래스 고유 operator new와 operator delete가 있는 형식의 객체를 make 함수로 생성하는 것은 대체로 바람직하지 않은 선택입니다.