본문 바로가기
PROGRAMMING/Effective Modern C++

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

by La-KanTo 2021. 3. 12.

- new의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_shared와 std::allocate_shared의 경우 더 작고 빠른 코드가 산출된다.

 std::make_shared는 C++11의 일부이지만, std::make_unique는 C++14에서야 표준 라이브러리에 포함되었다. 하지만 C++11 환경에서 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를 최대한 쉽게 만들어 내는 한 가지 방법은 make_unique의 표준화에 쓰인 제안 문서를 찾아서 거기에 나온 구현을 복사해 쓰는 것이다.
그 문서는 스테픈 T. 라와웨이드(Stephan T. Lavavej) 가 작성한 N3656(2013년 4월 18일자)이다.
한 가지 주의할 점은, 직접 만든 구현을 std 이름공간에 집어넣지는 말아야 한다.

 

std::make_unique와 std::make_shared는 임의의 개수와 타입을 인수들을 받아서 그것들을 생성자로 완벽 전달해서 객체를 동적으로 생성하고, 그 객체를 가리키는 스마트 포인터를 돌려주는 세 가지 make함수 중 둘이다.

나머지 하나는 std::allocate_shared이다. 이 함수는 std::make_shared처럼 작동하되, 첫 인수가 동적 메모리 할당에 쓰일 할당자 객체라는 점이 다르다.

스마트 포인터를 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); // 사용하지 않음

make 함수를 사용하지 않는 경우에는 코드에서 Widget이 두 번 등장한다.
코드가 중복되고 있는 것이다. 당연히 소스 코드 중복은 피하는 것이 좋다.

 

make 함수를 사용하면 그렇지 않을 때보다 예외 안전성이 향상되는 효과도 있다.

어떤 Widget 객체를 그 객체의 우선순위에 따라 적절히 처리하는 함수가 있다고 하자.

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

std::shared_ptr을 값으로 전달하고 있는데 이것에 관한 자세한 내용은 항목 41에서 보기 바란다.
다음으로, 우선순위를 다음과 같은 함수로 계산한다고 하자.

int computePriority(void);

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

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

이 경우 processWidget이 실행되기 전에 다음 세 가지 작업이 수행된다.

 

1. 표현식 "new Widget"이 평가된다.
즉, Widget이 힙에 생성된다.

2. new가 산출한 포인터를 관리하는
std::shared_ptr<Widget>의 생성자가 샐행된다.

3. computePriority가 실행된다.
문제는 1 이 2 보다 먼저 실행되어야 한다는 점을 제외하고, 위 세 작업의 실행 순서가 정해져 있지 않아서 컴파일러마다 순서가 달라질 수 있다는 것이다.
즉 다음과 같은 순서로 실행될 수도 있다.

1. 표현식 "new Widget"이 평가된다.
2. computePriority가 실행된다.
3. std::shared_ptr<Widget>의 생성자가 호출된다.
이런 순서로 실행되는 경우에 computePriority에서 예외가 발생하면 "new Widget"을 통해 할당된 메모리가 샌다.

 

이제 make 함수를 사용하도록 고쳐 보자.

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

이 경우 역시 std::make_shared가 먼저 호출될 수도 있고 computePriority가 먼저 호출될 수도 있다.
std::make_shared가 먼저 호출되더라도 생성된 객체는 computePriority가 호출되기 전에 std::shared_ptr<Widget>에 담긴다.
따라서 computePriority 실행되는 도중 예외가 발생해도, std::shared_ptr<Widget>의 소멸자가 호출되어 피지칭 Widget 객체를 안전하게 파괴한다.

computePriority가 먼저 호출되는 경우에는 computePriority에서 예외가 발생해도 아직 Widget 객체를 생성하기도 전이므로 누수를 걱정할 자원이 없는 상태이다.

std::shared_ptr와 std::make_shared를 std::unique_ptr와 std::make_unique로 대체해도 정확히 동일한 추론이 적용된다.

 

혹시라도 std::make 함수를 사용할 수 없거나 사용이 부적합한 상황에 부닥친다면, 이러한 예외 안전성 문제들을 세심하게 방지할 필요가 있다.
new를 직접 사용하면서도 예외 안전성 문제를 겪지 않는 최선의 방책은, new의 결과를 다른 일은 전혀 하지 않는 문장에서 스마트 포인터의 생성자에 즉시 넘겨주는 것이다.
위에서 본 예제 코드를 new를 직접 사용하면서도 예외 안전성 문제가 없도록, 다음과 같이 고칠 수 있다.

// 정확하지만 최적은 아님
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority());

Effective C++의 항목 17에서도 관련된 내용을 찾을 수 있다.
여기서 spw를 좌측값으로 넘겨주고 있다.
processWidget의 std::shared_ptr 매개변수는 값 전달 방식이므로 우측값으로 전달하는 것이 더 효율적일 것이다.

// 예외 안전성과 효율성을 모두 갖춘 방식
processWidget(std::move(spw), computePriority());

std::move은 spw를 우측값으로 변환해 준다(항목 23 참고).

std::make_shared 함수를 사용해야 하는 또 다른 이유는 향상된 효율성이다. 

std::make_shared 함수를 사용하면 컴파일러가 좀 더 간결한 자료구조를 사용하는 더 작고 빠른 코드를 산출할 수 있게 된다.

 

다음처럼 new를 직접 사용한다고 하자.

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

이 코드가 한 번의 메모리 할당을 실행하는 것 처럼 보이지만, 실제로는 두 번의 할당이 일어난다.
항목 19에서 설명하듯이, 모든 std::shared에는 피지칭 객체의 참조 카운트를 비롯한 여러 가지 관리 데이터를 담는 제어 블록을 가리키는 포인터가 있다.
std::shared_ptr 생성자는 이 제어 블록을 위한 메모리를 할당한다.
즉 new를 직접 사용해서 std::shared_ptr를 생성하면 Widget 객체를 위한 메모리 할당과 제어 블록을 위한 또 다른 메모리 할당이 일어난다.
대신 std::make_shared를 사용하면,

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

한 번의 할당으로 충분하다.

 

이는 std::make_shared가 Widget 객체와 제어 블록 모두를 담을 수 있는 크기의 메모리 조각을 한 번에 할당하기 때문이다.
동적 할당 횟수가 줄어들 뿐만 아니라, 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요가 없어져서 프로그램의 전체적인 메모리 사용량이 줄어들 여지가 생긴다.
방금 살펴본 내용들은 std::allocate_shared에도 거의 그대로 적용된다.

 

- make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기치를 전달해야 하는 경우가 있다.
make 함수에는 커스텀 삭제자를 지정하는 기능이 없다.

항목 7에서 설명하듯이, std::initializer_list를 받는 생성자와 받지 않는 생성자를 모두 가진 타입의 객체를 생성할 때, 생성자 인수들을 중괄호로 감싸면 오버로딩 해소 과정에서 std::initializer_list를 받는 버전이 선택되고, 괄호로 감싸면 std::initializer_list 를 받지 않는 버전이 선택된다.

make 함수들은 내부적으로 매개변수들을 완벽 전달할 때 중괄호가 아니라 괄호를 사용한다. 이 때문에, 피지칭 객체를 중괄호 초기치로 생성하려면 반드시 new를 직접 사용해야 한다.

항목 30에서 설명하듯이 중괄호 초기치의 완벽 전달은 불가능하다. 그러나 항목 30에 우회책이 하나 나온다.

auto 타입 추론을 이용해서 중괄호 초기치로부터 std::initializer_list 객체를 생성하고(항목 2 참고), 그것을 make 함수에 넘겨주면 된다.

 

피지칭 객체를 중괄호 초기치로 생성하려면 new를 직접 사용하거나, 우회책을 사용해야 한다.
다음은 우회책의 예시이다.
std::initializer_list 객체를 생성

auto initList = { 10, 20 };

그 std::initializer_list 객체를 이용해서 std::vector를 생성

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

 

- std::shared_ptr에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우와 (2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr들이 해당 std::shared_ptr들보다 더 오래 살아남는 경우이다.
클래스 중에는 자신만의 operator new와 operator delete를 정의하는 것들이 있다. 이런 클래스 고유(class-specific) 메모리 관리 루틴들은 단지 클래스의 객체와 정확히 같은 크기의 메모리 조각들만 할당, 해제하는 경우가 많다.

그런 루틴들은 커스텀 std::shared_ptr의 커스텀 할당(std::allocate_shared를 통한)과 커스텀 해제(커스텀 삭제자를 통한)에는 잘 맞지 않는다. 왜냐하면, std::allocate_shared가 요구하는 메모리 조각의 크기는 동적으로 할당되는 객체의 크기에 제어 블록의 크기를 더한 것이기 때문이다.

제어 블록의 참조 카운트가 0이 되면 std::shared_ptr이 가리키던 피지칭 객체를 파괴하고, 피지칭 객체가 차지하고 있던 메모리를 해제할 수 있다.

하지만, 같은 피지칭 객체를 가리키는 std::weak_ptr들이 모두 소멸되기 전('두 번째' 참조 카운트가 0이 되기 전)에는 제어 블록을 파괴할 수 없다(std::weak_ptr들이 만료되었는지를 점검하기 위해서 제어 블록을 사용하기 때문이다. 항목 20을 보라).

그런데 std::make_shared를 통해 생성한 std::shared_ptr은, 피지칭 객체와 제어 블록이 하나의 메모리 조각(한 번의 동적할당으로 얻은 메모리)에 있게 된다. 따라서, 이 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를 직접 사용하는 경우에는 ReallyBigType 객체를 가리키던 마지막 std::shared_ptr가 파괴되는 즉시 그 객체의 메모리가 해제될 수 있다.

class ReallyBigType { ... };

// 아주 큰 객체를 new를 이용해서 생성
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);

이전처럼 객체를 가리키는 std::shared_ptr들과 std::weak_ptr들을 생성해서 사용한다.
여기서 객체를 가리키는 마지막 std::shared_ptr가 파괴되나, std::weak_ptr들은 여전히 남아 있다.
객체의 메모리는 해제된다.
이 부분에서, 제어 블록을 위한 메모리만 할당된 상태이다.
여기서 객체를 가리키는 마지막 std::weak_ptr가 파괴 된다. 이제 제어 블록이 차지하던 메모리가 해제된다.

 

지금까지 살펴본, make 함수들을 사용할 수 없는 상황은 흔치 않다. 이처럼 make 함수를 사용하지 않을 특별히 강력한 이유가 없는 한, make 함수를 사용하는 것이 옳은 선택이다.

댓글