dev/C++

Effective Modern C++ #18 소유권 독점 자원의 관리에는 std::unique_ptr 를 사용하라

dev_dev 2025. 6. 14. 23:29

Smart Pointer

raw pointer는 아래와 같은 이유 때문에 좋아할 수가 없다.

  1. 선언만으로는 객체를 가리키는지 배열을 가리키는지 알 수 없음
  2. 포인터 사용이 완료된 뒤, 가리키는 대상을 직접 파괴해야하는지 알 수 없음 (소유 여부 모름)
  3. 가리키는 대상을 직접 파괴해야한다는 것을 알아도, 어떻게 파괴하는지 모름
  4. delete 로 파괴해야함을 알아도, 1번 이유로 delete, delete[] 어떤 것 사용해야하는지 모름
  5. 포인터가 가리키는 대상 소유 여부를 알고 파괴법도 알아도, 모든 경로에서 1번만 파괴되는지 보장하기 어려움
  6. 포인터가 가리키는 대상을 잃었는지 아는법 없음 (가리키는 대상이 유효한지, 파괴되었는지 모름)

smart pointer (raw pointer의 wrapper) 를 사용해서 이러한 문제를 해결할 수 있다.

동적으로 할당된 객체가 문제없이 파괴되도록 보장함으로써 memory leak 이 발생하지 않게 설계되어 있다.

스마트 포인터는 4종류가 있다.

  • std::auto_ptr
  • std::unique_ptr
  • std::shared_ptr
  • std::weak_ptr

std::auto_ptr 는 C++98 에서 사용했던 과거의 smart pointer로, 비권장 기능이며 C++11 이상부터는 사용할 필요 없다.

18. 소유권 독점 자원의 관리에는 std::unique_ptr 를 사용하라

smart pointer 를 사용하기로 결정했다면, 가장 먼저 옵션으로 생각해야할 것은 std::unique_ptr 이다.

raw pointer 와 유사한 크기를 가졌고, 동일한 명령을 실행한다.

따라서 컴퓨팅 환경이 넉넉하지 않은 곳에서도 사용 가능하다.

 

std::unique_ptr 는 자원에 대해 독점적 소유권을 가진다. (항상 자신이 가리키는 객체를 소유함)

  • 포인터 간 이동 연산
    • 허용됨
    • 원본 → 대상으로 포인터가 옮겨짐
  • 포인터 간 복사 연산
    • 허용되지 않음
    • 동일한 객체를 두 포인터가 가리키게 되기 때문 ('독점적 소유권'이라는 정의와 맞지 않음)

따라서 이동 연산만 사용가능한 move-only type 이다.

 

std::unique_ptr는 팩토리 함수의 리턴 타입으로 사용하기 적합하다.

팩토리 함수가 반환하는 자원은 일반적으로 호출자가 독점적으로 소유하기 때문이다.

팩토리 함수 예시)

template<typename... Ts>
std::unique_ptr<Investment>
makeInvestment(Ts&&... params);

 

std::unique_ptr 는 소유권을 이전하는 시나리오에서도 활용된다.

소유권 이전 시 발생할 수 있는 아래 두 케이스 모두 안전하게 자원을 관리한다.

  • 소유권 이전 성공
    • 특정 객체로 std::unique_ptr 가 이동한 경우, 그 객체가 파괴될 때 std::unique_ptr 의 소멸자에 의해 자원도 파괴됨
  • 소유권 이전 실패
    • exception 등 동작으로 소유권을 잃어도, std::unique_ptr 의 소멸자가 호출되어 관리 자원이 파괴됨

Custom Deleter (커스텀 삭제자)

보통 std::unique_ptr 의 소멸자는 delete 를 통해 파괴한다.

목적에 따라 커스텀 삭제자를 사용하여, 파괴하기 전에 로그를 기록하는 등의 구현도 가능하다.

이 때 std::unique_ptr 의 템플릿 둘째 형식 인수에 커스텀 삭제자의 형식을 지정해야한다.

커스텀 삭제자를 사용하는 경우, 호출자 입장에서 파괴할 때 해야하는 특별한 처리에 대해 몰라도 되는 장점이 있다.

 

커스텀 삭제자 예시)

auto delInvmt = [] (Investment* pInvestment) = {
	makeLogEntry(pInvestment);
	delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&&... params) {
	std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
	if ( /*Stock 객체 생성해야하는 경우*/) {
		pInv.reset(new Stock(std::forward<Ts>(params)...))
	} else {
    	// 다른 객체별 케이스
    }
	return pInv
}

 

 

 

예시의 makeInvestment 함수는 아래와 같은 동작으로 구성된다.

  1. 커스텀 삭제자를 사용하는 null std::unique_ptr 생성
  2. 조건에 따라 std::unique_ptr가 적절한 형식의 객체 가리키게 함
  3. 객체 가리키는 std::unique_ptr 리턴

(2번) new 키워드로 생성한 객체로 raw pointer 를 std::unique_ptr 에 배정할 수 없다.

raw pointer → smart pointer 암묵적 변환은 성립되지 않기 때문이다.

따라서 reset 멤버함수를 사용해 pInv에 신규 객체의 소유권을 부여한다.

 

std::unique_ptr는 기본 삭제자(delete) 를 사용할 때, raw pointer 와 크기가 유사하다.

하지만 함수 객체 커스텀 삭제자를 사용하는 경우,객체 크기만큼 std::unique_ptr의 크기가 증가한다.

캡쳐 없는 람다표현식과 같은 stateless function object의 경우 std::unique_ptr의 크기 변화가 없다.

따라서 위 예시처럼 람다 표현식을 커스텀 삭제자로 사용하는 것이 적절하다.

 

아래의 raw pointer 의 단점은 std::unique_ptr에서 직관적으로 해결된다.

  • delete 로 파괴해야함을 알아도, 1번 이유로 delete, delete[] 어떤 것 사용해야하는지 모름

std::unique_ptr는 객체 대상(`std::unique_ptr`), 배열 대상(`std::unique_ptr<t[]></t[]>`)으로 나뉘기 때문이다.

형태가 다르므로, std::unique_ptr 가 어떤 객체 가리키는지 알 수 없는 문제는 발생하지 않는다.

참고로 배열용 std::unique_ptr는 굳이 사용할 필요가 없다.

내장 배열보다는 std::array, std::vector 등 stl 이 더 나은 선택이기 때문이다.

 

마지막으로 unqiue_ptr는 std::shared_ptr 로 변환도 쉽고 효율적이다.

std::shared_ptr<Investment> sp = makeInvestment(params);

 

이러한 특성으로 팩토리 함수로 사용하기 더 적절하다.

호출할 때 자원 소유권에 대한 선택을 유연하게 변경할 수 있기 때문이다.