Item 21. new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호해라


make 함수 (std::make_unique, std::make_shared)

make 함수(std::make_unique, std::make_shared)는 임의의 타입 인자들을 받아서 그것들을 생성자로 perfect forwarding 하여 객체를 동적으로 생성하고 그 객체를 가리키는 스마트 포인터를 돌려주는 함수이다.

쉽게 말해서 std::unique_ptr, std::shared_ptr를 만들어주는 함수이다.

그 외 make 함수로 std::allocate_shared도 있는데 std::make_shared처럼 작동하되 첫 인자가 동적 메모리 할당에 쓰일 할당자 객체라는 점이 다르다.

C++11 표준 라이브러리에는 std::make_shared만 포함되어 있고 C++14 표준 라이브러리부터 std::make_unique를 지원한다.

만약 C++11에서 std::make_unique를 사용하고 싶으면 다음과 같이 직접 구현할 수 있다.

template <typename T, typename... Ts>
std::unique_ptr<T> make_uniuqe(Ts&&... params) {
  return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}


make 함수들의 장점

make 함수는 다음과 같은 장점이 있다.

코드 중복을 피할 수 있음

make 함수를 사용하면 코드 중복을 피할 수 있다.

동적으로 객체를 할당하여 스마트 포인터를 정의하는 상황에서 new를 직접 사용하면 생성할 객체의 타입을 두 번이나 써줘야 한다.

하지만 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); // 사용 안함

예외 안정성이 높음

어떤 Widget 객체를 우선순위(priority)에 따라 처리해야 하는 함수가 있다고 가정해보자.

// Widget 객체를 우선순위에 따라 처리하는 함수
void processWidget(std::shared_ptr<Widget> spw, int priority);

// 우선 순위를 결정하는 함수
int computePriority();

processWidget(std::shared_ptr<Widget>(new Widget), computePriority());

이때 new를 사용해 std::shared_ptr를 생성하여 processWidget 함수를 호출하면 new로 생성한 Widget 객체에 대한 자원 누수가 발생할 위험이 있다.

이 문제를 이해하기 위해서는 컴파일러가 코드를 어떤 순서로 실행하는지 알아야 한다.

위 예시에서 processWidget 함수가 호출되기 전에 다음과 같은 3가지 작업이 발생한다.

  • new Widget으로 객체를 동적 할당한다.
  • std::shared_ptr<Widget>의 생성자를 호출한다.
  • computePriority 함수를 호출한다.

하지만 우리는 이 3가지 작업의 순서를 정확하게 알 수 없다.

new Widget으로 객체를 동적 할당하고 이를 인자로 std::shared_ptr<Widget>의 생성자를 호출하기 때문에 둘의 순서는 보장할 수 있지만 computePriority 함수의 순서는 컴파일러가 결정하기 때문이다.

만약 다음과 같은 순서로 작업이 진행되면 문제가 발생할 수 있다.

  1. new Widget으로 객체를 동적 할당한다.
  2. computePriority 함수를 호출한다.
  3. std::shared_ptr<Widget>의 생성자를 호출한다.

2번째 작업이 실행하는 도중 예외가 발생하면 3번째 작업인 std::shared_ptr<Widget>의 생성자 호출이 발생하지 않으면서 1번째 작업에서 동적으로 할당한 객체를 해제할 방법이 없어진다.

즉, 자원 누수가 발생할 수 있다.

이 문제도 make 함수를 사용하면 해결할 수 있다.

processWidget(std::make_shared<Widget>(), computePriority());

std::make_shared를 사용하면 Widget 객체를 동적 할당하고 std::shared_ptr<Widget>를 생성하는 과정이 하나의 과정으로 묶여서 computePriority 함수에서 예외가 발생하더라도 자원 누수 문제가 발생하지 않는다.

효율성이 향상됨

std::make_shared 함수를 써서 std::shared_ptr를 만들면 컴파일러가 더 효율적으로 코드를 최적화할 수 있다.

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

new를 사용해 std::shared_ptr를 생성하면 두 번의 메모리 할당이 발생한다.

  1. new를 통한 객체의 메모리 할당
  2. std::shared_ptr를 관리할 control block의 메모리 할당

만약 std::make_shared를 사용하면 메모리 할당이 한 번만 발생한다.

auto spw = std::make_shared<Widget>();

std::make_shared를 사용하면 컴파일러가 객체와 control block 모두를 담을 수 있는 크기의 메모리를 한 번에 할당하기 때문이다.

메모리 할당 호출 코드가 한 번만 있으면 프로그램의 크기도 줄어들고 속도도 빨라진다. 또한 control block이 가지고 있는 내부 관리 정보도 줄어서 메모리 사용량이 감소하는 장점이 있다.

따라서 가능하면 std::make_shared를 사용하는 것이 더 효율적이다.

std::make_shared 뿐만 아니라 std::allocate_shared도 포함되는 이야기이다.


make 함수들의 단점

make 함수는 다음과 같은 단점이 있다.

custom deleter를 사용할 수 없음

Item 18, 19에서 말한 것처럼 make 함수는 custom deleter를 사용할 수 없다.

따라서 custom deleter를 사용하기 위해서는 new를 이용해서 스마트 포인터를 생성해야 한다.

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

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

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

중괄호 초기화를 사용할 수 없음

make 함수는 중괄호 초기화를 사용할 수 없다.

Item 7에서 말한 것처럼 std::initializer_list를 받는 생성자와 받지 않는 생성자를 모두 가진 객체를 생성할 때 생성자 인자들을 {}(중괄호)로 감싸면 overloading 과정에서 std::initalizer_list가 높은 우선 순위를 가진다.

하지만 make 함수는 구현상 인자들을 모두 괄호로 전달한다.

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

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

따라서 위의 예시에서 std::vector는 값이 10인 20개의 원소를 생성한다.

만약 make 함수와 함께 중괄호 초기화를 사용하고 싶으면 다음과 같은 우회책이 있다.

std::initializer_list 객체를 생성한 후 make 함수의 인자로 넣어주면 중괄호 초기화를 사용할 수 있다.

// std::initializer_list로 타입 추론
auto initList = { 10, 20 };

auto spv = std::make_unique<std::vector<int>>(initList);

std::vector는 각각 10, 20 값을 가지는 2개의 원소를 생성한다.

custom new/delete를 사용하는 클래스는 사용할 수 없음

앞서 두 단점은 make 함수의 공통적인 단점이었다면 지금부터는 std::make_shared가 가지고 있는 단점이다.

std::make_shared는 자신만의 operator new, operator delete를 사용하는 클래스에는 적합하지 않다.

그런 클래스들은 대부분 클래스 크기에 맞는 메모리를 할당하고 해제할 텐데 std::make_shared가 요구하는 메모리의 크기는 객체의 크기 + control block의 크기라서 문제가 발생할 수 있다.

메모리 해제 시점이 늦어져 메모리 낭비가 생길 수 있음

원래 std::shared_ptr의 reference count가 0인 상황에서 std::weak_ptr의 weak count가 0이 아니면 객체의 메모리는 파괴하되 control block의 메모리는 남아있다.

하지만 앞서 std::make_shared 함수를 써서 std::shared_ptr를 만들면 컴파일러가 더 효율적으로 코드를 최적화할 수 있다고 했다.

최적화 과정에서 객체와 control block 모두를 담을 수 있는 크기의 메모리를 한 번에 할당하기 때문에 해제할 때도 같이 해야하는 문제가 있다.

따라서 std::make_shared 함수를 사용하면 std::shared_ptr의 reference count가 0인 상황에서도 std::weak_ptr의 weak count가 0이 될 때까지 객체의 메모리가 해제되지 않는다.

즉, 의도치 않게 메모리 해제 시점이 늦어져 메모리를 낭비할 수 있다. 이는 객체의 크기가 크면 클수록 치명적이다.


make 함수를 못 쓰는 경우 예외에 안전하게 구현하는 방법

make 함수의 단점에서 알 수 있듯이 언제나 make 함수를 사용할 수 있는 것은 아니다.

하지만 make 함수를 못 쓰는 상황에서 new를 잘못 쓰면 자원 누수가 발생할 수 있어서 예외에 안전하게 구현해야 한다.

// 예외가 발생하면 자원 누수가 발생할 수 있음
processWidget(std::shared_ptr<Widget>(new Widget, customDeleter), computePriority());

// 예외가 발생해도 자원 누수가 발생하지 않음
std::shared_ptr<Widget> spw(new Widget, customDeleter);
processWidget(spw, computePriority());

여기서 성능을 높이기 위해 std::shared_ptr를 복사하지 말고 이동하면 된다.

processWidget(std::move(spw), computePriority());


카테고리:

업데이트: