[EMC++] Item 21. new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호해라
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
함수의 순서는 컴파일러가 결정하기 때문이다.
만약 다음과 같은 순서로 작업이 진행되면 문제가 발생할 수 있다.
new Widget
으로 객체를 동적 할당한다.computePriority
함수를 호출한다.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
를 생성하면 두 번의 메모리 할당이 발생한다.
new
를 통한 객체의 메모리 할당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());