KoreanFoodie's Study
Effective Modern C++ | 항목 21 : new 를 직접 사용하는 것보다 std::make_unique 와 std::make_shared 를 선호하라 본문
Effective Modern C++ | 항목 21 : new 를 직접 사용하는 것보다 std::make_unique 와 std::make_shared 를 선호하라
GoldGiver 2022. 10. 26. 09:57
C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!
항목 21 : new 를 직접 사용하는 것보다 std::make_unique 와 std::make_shared 를 선호하라
핵심 :
1. new 의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_shared 와 std::allocate_shared 의 경우 더 작고 빠른 코드가 산출된다.
2. make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기치를 전달해야 하는 경우가 있다.
3. std::shared_ptr 에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우와 (2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr 들이 해당 std::shared_ptr 들보다 더 오래 살아남는 경우이다.
C++11 에서는 std::make_shared 를 사용할 수 있지만, std::make_unique 는 C++14 에서야 새롭게 추가되었다. 하지만 std::make_unique 의 구현은 어렵지 않다. 다만, 커스텀 구현을 std 네임스페이스 안에 넣지는 말자. 충돌의 우려가 있기 때문이다.
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
std::make_shared 와 std::make_unique 말고도 make 함수는 하나가 더 있는데(총 3개), 그것이 바로 std::allocate_shared 이다. 이 함수는 std::make_shared 처럼 작동하되, 첫 인수가 동적 메모리 할당에 쓰일 할당자 객체라는 점이 다르다.
make 함수를 선호해야 하는 이유로는 코드 중복을 피할 수 있다는 점과, 예외 안전성 문제에 덜 민감하다는 것이 있다.
어떤 Widget 객체를 그 객체의 우선순위(priority) 에 따라 적절히 처리하는 함수가 있다고 하자.
// 우선순위대로 일을 처리
void processWidget(std::shared_ptr<Widget> spw, int priority);
// 우선순위 계산
int computePriority();
// 실제 사용
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
그리고 실제 사용 코드 줄에서, 다음과 같은 순서로 프로그램이 실행된다고 가정하자.
- "new Widget" 이 실행
- computePriority() 호출 -> 그런데 예외가 발생
- std::shared_ptr 생성
만약 두 번째 단계에서 예외가 발생하면, 동적으로 할당된 Widget 객체가 새게 된다!
그런데 std::make_shared 를 사용하면, 자원 누수의 위험이 없다.
processWidget(std::make_shared<Widget>(), computePriority());
이 경우, 어떤 코드가 먼저 실행되든 std::shared_ptr 의 소멸자가 피지칭 Widget 객체를 파괴하기 때문이다. 이는 std::make_unique 의 경우에도 마찬가지이다.
std::make_shared 를 사용하면 컴파일러가 간결한 자료구조를 사용하는 더 작고 빠른 코드를 산출한다. new 를 직접 사용하면 Widget 객체를 위한 메모리 할당과 제어 블록을 위한 메모리 할당, 총 2번의 메모리 할당이 발생한다. 하지만 make 함수는 한 번의 할당이면 충분하다(Widget 객체와 제어 블록을 한 번에 할당). 또한 std::make_shared 는 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요가 없어져 전체적인 메모리 사용량도 줄어들 수 있다.
다만 make 함수를 사용할 수 없는 경우가 있다. 바로 커스텀 삭제자를 지정하는 경우이다.
auto widgetDeleter = [](Widget* pw) { ... };
std::unique_ptr<Widget, decltype(widgetDeleter)>
up(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);
또한, 피지칭 객체를 중괄호 초기치로 생성하려면 반드시 new 를 사용해야 한다.
auto upv = std::make_unique<std::vector<int>>(10, 20);
// 컴파일 에러!
auto upv2 = std::make_unique<std::vector<int>>{10, 20};
// 우회책 : std::initializer_list 객체 생성 이후 인자로 전달
auto initList = {10, 20};
auto upv3 = std::make_unique<std::vector<int>>(initList);
앞서 설명한 두 가지 경우 이외에 make 함수를 쓰면 좋지 않은 경우들은 조금 극단적인 경우에 해당한다. 그러니 간단하게만 짚고 넘어가도 좋다.
예를 들어, 해당 클래스에 커스텀 operator new 와 operator delete 가 있다면, 해당 클래스를 make 함수로 만드는 것은 대체로 바람직하지 않은 선택이다. 왜냐하면 make 함수가 요구하는 메모리 조각의 크기는 동적으로 할당되는 객체의 크기가 아니라 그 크기에 제어 블록의 크기를 더한 것이기 때문이다.
new 에 비해 std::make_shared 가 가지는 크기 및 속도상의 장점은 std::shared_ptr 의 제어 블록이 관리 대상 객체와 동일한 메모리 조각에 놓인다는 것이다. 그런데 다음과 같은 상황을 보면, 이 때문에 객체가 파괴된 시점과 객체가 점유하던 메모리가 해제되는 시점 사이에 시간 지연이 생길 수 있다는 것을 알 수 있다. 왜냐하면, 제어 블록을 참조하는 std::weak_ptr 들이 존재하는 한(즉, 약한 횟수가 0보다 크다면), 제어 블록은 계속해서 존재해야 한다. 그리고 제어 블록이 존재하는 한, 제어 블록을 담고 있는 메모리는 여전히 할당된 상태이어야 하므로, make 함수가 할당한 메모리 조각은 그것을 참조하는 마지막 std::shared_ptr 와 마지막 std::weak_ptr 가 둘 다 파괴된 후에만 해제될 수 있다.
class ReallyBigType { ... };
// 아주 큰 객체를 std::make_shared 를 이용해 생성
auto pBigObj =
std::make_shared<ReallyBigType>();
// 큰 객체를 가리키는 std::shared_ptr 들과
// std::weak_ptr 들을 생성해서 사용
...
// 객체를 가리키는 마지막 std::shared_ptr 가 파괴됨
// std::weak_ptr 들은 여전히 남아있음
...
// 큰 객체가 차지하던 메모리는 여전히 할당
...
// 마지막 std::weak_ptr 가 파괴
// 제어 블록과 객체가 차지하던 메모리가 해제
...
만약 new 를 사용해서 std::shared_ptr 를 생성했다면, 마지막 std::shared_ptr 가 파괴되었을 때, 객체를 가리키는 메모리는 해제되고, std::weak_ptr 를 위한 제어 블록 메모리만 할당된 상태가 될 것이다.
하지만 위와 같은 경우는 조금 극단적인 케이스이므로, 웬만하면 make 함수를 사용하는 것이 좋다는 것을 기억해 두자.