[Modern C++] 항목 21: new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 4. 스마트 포인터

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

🔍 make 함수란?

  • std::make_shared는 C++11의 일부이지만 std::make_unique는 C++14에서 표준 라이브러리에 포함되었다.
  • C++11을 사용하는 경우 std::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)...));
}
  • std::make_uniquestd::make_shared는 임의의 개수와 타입의 인수들을 받아서 그것들을 생성자로 완벽 전달해서 객체를 동적으로 생성하고, 그 객체를 가리키는 스마트 포인터를 돌려준다.
  • 나머지 하나는 std::allocate_shared로, std::make_shared처럼 작동하되 첫 인수가 동적 메모리 할당에 쓰일 할당자 객체라는 점이 다르다.

✅ make 함수를 선호해야 하는 이유

이유 1: 코드 중복 방지

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); // 사용하지 않음
  • new를 사용하는 버전에서는 생성할 객체의 타입이 되풀이해서 나오지만, make 함수 버전은 그렇지 않다.
  • 타입을 여러 번 되풀이하는 것은 “코드 중복을 피하라”는 소프트웨어 공학의 핵심 교의와 충돌한다.

이유 2: 예외 안전성

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

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

컴파일러가 다음과 같은 순서로 실행하는 목적 코드를 산출할 수도 있다.

  1. "new Widget"을 실행한다.
  2. computePriority를 실행한다.
  3. std::shared_ptr 생성자를 실행한다.

만일 단계 2에서 예외가 발생하면, 단계 1에서 동적으로 할당된 Widget이 새게 된다. std::shared_ptr가 아직 생성되지 않았기 때문이다.

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

std::make_shared를 사용하면 std::make_shared가 먼저 호출되거나 computePriority가 먼저 호출되더라도 안전하다.

이유 3: 효율성 향상 (std::make_shared)

std::shared_ptr<Widget> spw(new Widget); // 메모리 할당 두 번 발생
  • new를 직접 사용해서 std::shared_ptr를 생성하면 Widget 객체를 위한 메모리 할당과 제어 블록을 위한 또 다른 메모리 할당이 일어난다.
auto spw = std::make_shared<Widget>(); // 메모리 할당 한 번만 발생
  • std::make_shared를 사용하면 Widget 객체와 제어 블록 모두를 담을 수 있는 크기의 메모리 조각을 한 번에 할당한다.
  • 메모리 할당 호출 코드가 한 번만 있으면 되므로 프로그램의 정적 크기가 줄어든다.
  • 실행 코드의 속도도 빨라진다.
  • std::make_shared의 효율성 분석은 std::allocate_shared에도 그대로 적용된다.

⚠️ make 함수를 사용할 수 없거나 사용하지 않아야 하는 상황

한계 1: 커스텀 삭제자 지정 불가

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

// make 함수로는 이런 일을 할 수 없다
std::unique_ptr<Widget, decltype(widgetDeleter)>
    upw(new Widget, widgetDeleter);

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

한계 2: 중괄호 초기화 리스트 전달 불가

// 두 호출 모두 값이 20인 요소 열 개짜리 std::vector를 생성한다
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
  • make 함수들은 내부적으로 매개변수들을 완벽 전달할 때 중괄호가 아니라 괄호를 사용한다.
  • 중괄호 초기화 리스트를 사용하고 싶다면 반드시 new를 직접 사용해야 한다.
  • 우회책: auto 타입 추론을 이용해서 std::initializer_list 객체를 생성하고 make 함수에 넘겨주면 된다.
auto initList = { 10, 20 };  // std::initializer_list 객체를 생성
auto spv = std::make_shared<std::vector<int>>(initList);

한계 3: 클래스 고유 operator new/delete가 있는 경우

  • 클래스 고유 operator newoperator delete가 있는 타입의 객체를 make 함수로 생성하는 것은 대체로 바람직하지 않다.
  • std::allocate_shared가 요구하는 메모리 조각의 크기는 동적으로 할당되는 객체의 크기가 아니라 그 크기에 제어 블록의 크기를 더한 것이기 때문이다.

한계 4: 메모리 해제 시점 차이 (std::make_shared)

  • std::make_shared를 사용하면 객체와 제어 블록이 동일한 메모리 조각에 있어서, 마지막 std::weak_ptr가 파괴되기 전까지는 객체가 차지하던 메모리도 해제될 수 없다.
  • 객체 타입이 상당히 크고, 마지막 std::shared_ptr의 파괴와 마지막 std::weak_ptr의 파괴 사이의 시간 간격이 꽤 길다면, 객체가 파괴된 시점과 객체가 점유하던 메모리가 해제되는 시점 사이에 시간 지연이 생길 수 있다.
// std::make_shared 사용: 마지막 std::weak_ptr가 파괴될 때까지
// 객체와 제어 블록의 메모리가 모두 유지된다
auto pBigObj = std::make_shared<ReallyBigType>();

// new 직접 사용: 마지막 std::shared_ptr가 파괴되는 즉시
// 객체의 메모리가 해제될 수 있다
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);

🛡️ make 함수를 쓸 수 없을 때 예외 안전한 코드 작성

std::make_shared를 사용할 수 없다면 Widget의 할당과 std::shared_ptr의 생성을 개별적인 문장으로 두는 것이 최선이다.

std::shared_ptr<Widget> spw(new Widget, cusDel); // 개별 문장으로 분리

processWidget(spw, computePriority());            // 정확하지만 최적은 아님; 본문 참고

processWidget(std::move(spw), computePriority()); // 예외 안전성과 효율성을 모두 갖춘 방식
  • std::shared_ptr의 경우 이동과 복사의 차이가 클 수 있다. std::shared_ptr를 복사하려면 참조 횟수를 원자적으로 증가해야 하지만, 이동할 때에는 참조 횟수를 조작할 필요가 없기 때문이다.

🧐 정리

  • new의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_sharedstd::allocate_shared의 경우 더 작고 빠른 코드가 산출된다.
  • make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기화 리스트를 전달해야 하는 경우가 있다.
  • std::shared_ptr에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우(2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr들이 해당 std::shared_ptr들보다 더 오래 살아남는 경우이다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기