[Modern C++] SmartPointer

나우히즈·2024년 11월 15일

CXX

목록 보기
11/11

출처 : Microsoft learn
https://learn.microsoft.com/en-us/cpp/cpp/smart-pointers-modern-cpp?view=msvc-170

Effective C++ 을 보면서 RAII에 대한 내용으로 자원 관리는 객체를 통해 하라는 항목을 읽어보았다. 그 RAII의 개념을 적용한 것이 스마트포인터라는데, 스마트포인터를 명확히 사용해보지 않아서 모던C++에서 숱하게 사용되는 스마트포인터에 대해 정리해보는 시간을 가지려고 한다. 단순히 unique_ptr, share_ptr 등이 뭔지에 그치는 것이 아니라 다양한 관련 함수들도 좀 정리해보려고 한다.


스마트 포인터의 용도

가장 중요한 부분은 RAII 의 실현이다.
자원 습득과 함께 자원 관리 객체의 초기화가 동시에 일어나게 하여, 모든 자원들이 객체에 의해 생성되고 초기화되도록 한다.

RAII는 자원의 관리를 객체에 위임하여 생성자를 통해 자원 할당 및 초기화가 발생하고, 소멸자를 통해 자원의 반납을 진행한다. 특정 객체에 자원에 대한 소유권을 갖게 하는 것이라고 보면 된다. 스마트포인터는 스택 메모리에 할당되어 원시 포인터 값을 캡슐화 하여 가지고있게 된다. 사용이 끝나 스코프를 벗어나면 자동 소멸되어 자원을 반납한다. 자동적으로 자원의 관리가 이루어질 수 있어서 사용자가 자원관리에 대한 실수를 줄일 수 있다.

이 같은 장점에 의해, 모던 C++에서는 스마트 포인터를 통해 자원관리를 진행하라고 한다. 원시 포인터는 작은 코드블럭, 자원 관리에 혼동이 없는 명확한 경우, 퍼포먼스가 중요한 경우에 활용하는 것을 추천한다.

중요!
항상 스마트 포인터를 선언할 때는 독립적으로 분리된 한 라인에 생성하도록 하자. 만약 파라미터 리스트서 생성할 경우, 파라미터 할당에 대한 복사 등 여러가지 과정들에 의해 누수가 발생할 수 있음.

class LargeObject
{
public:
    void DoSomething(){}
};

void ProcessLargeObject(const LargeObject& lo){}
void SmartPointerDemo()
{    
    // Create the object and pass it to a smart pointer
    std::unique_ptr<LargeObject> pLarge(new LargeObject());

    //Call a method on the object
    pLarge->DoSomething();

    // Pass a reference to a method.
    ProcessLargeObject(*pLarge);

} //pLarge is deleted automatically when function block goes out of scope.

간단한 스마트 포인터의 사용 예시이다.

  1. 스마트 포인터는 항상 로컬 변수로 선언하자. 스마트포인터 자체를 동적할당으로 힙 메모리에 두어선 안됌!!
  2. 스마트 포인터의 타입은 사용할 포인터가 가리키는 데이터의 자료형을 써줄 것.
  3. 생성자의 인자로 원시 포인터(new로 갓 할당한)를 넘겨주자. 이후 이 포인터는 캡슐화되어 스마트포인터가 지켜줄 것이다.
  4. 이렇게 생성된 객체에 접근할 때는 ->, * 를 사용하자
  5. 내가 객체를 delete하지말고 스마트포인터가 직접 관리하게 해주자.

스마트포인터를 사용하는게 원시포인터를 사용하는 것에 비해 효율성, 퍼포먼스적으로 밀리지 않는다고 함.
스마트 포인터의 유일한 멤버 데이터는 우리가 인자로 넘겨준 캡슐화된 원시 포인터 뿐이므로, 스마트포인터의 크기는 원시 포인터 자체와 완전히 동일하고, *, -> 의 접근 또한 원시포인터에 직접 접근보다 크게 느리지 않다고 함.

추가적으로 스마트포인트가 가지는 함수들이 있다. 이 함수들에는 . 을 붙여서 접근할 수 있다.

자주 사용되는 몇 가지 스마트 포인터 함수들
get() 스마트 포인터가 관리하는 원본 포인터를 반환 (소유권은 변경되지 않음)
release() 스마트 포인터의 소유권을 해제하고, 해당 객체를 관리하지 않게 함
reset() 객체를 파괴하고 새 객체를 할당하거나, nullptr로 설정
swap() 두 스마트 포인터의 객체를 교환
use_count() shared_ptr 객체의 참조 카운트 반환
lock() weak_ptr가 관리하는 객체를 shared_ptr로 반환
expired() weak_ptr가 관리하는 객체가 이미 소멸되었는지 확인


스마트 포인터의 종류

unique_ptr

인자로 들어온 원시 포인터에 대한 유일한 소유권만을 허용하는 스마트 포인터. shared_ptr 를 사용해야하는 것이 명확하지 않다면 기본적으로 unique_ptr 사용을 추천한다. 소유권은 복사나 나누어줄 수 없으며 아예 소유권의 이동 자체만 가능하다.

shared_ptr

"Reference-counted" 스마트 포인터이다. 하나의 원시 포인터를 여러 소유권으로 두고자 할 때 사용한다. 예를 들어 원래의 포인터를 유지하면서 포인터의 복사본을 리턴해줘야 하는 상황 등에 사용한다. 원시 포인터는 모든 shared_ptr의 소유자들이 스코프 밖으로 사라지거나 소유권을 포기하기 전까지 유지된다. shared_ptr은 위에서 이야기한 것과는 달리 두 개의 포인터값을 보유한다. 하나는 원시 포인터 자체이며 하나는 reference-count 를 세는 "shared control block"을 가리킨다.

weak_ptr

shared_ptr의 특별한 케이스이다. shared_ptr 과 전반적으로 동일하게 동작하지만 reference-counting에 포함되지 않는다.
사용되는 예로, 그저 단순히 원시포인터의 상태만을 확인하고자 할 때는 굳이 카운팅될 필요가 없으니 weak_ptr을 사용하여 객체에 접근하는 것이다. 또한 shared_ptr 의 순환 참조를 해제시키기 위해서 필요한 부분이다.


번외: make_unique

스마트포인터를 사용하면서 new 에 실패한다면 어떻게 되는걸까 싶어서 찾아보니 make_unique 가 탄생한 비화가 여기있었다.

new를 사용하여 동적으로 메모리를 할당할 때 할당 실패가 발생할 수 있습니다. 만약 메모리 할당이 실패하면, new는 예외(std::bad_alloc)를 던지게 됩니다. 그런데, std::make_unique는 이 점을 안전하게 처리합니다.

new 사용 시의 위험성:

MyClass* ptr = new MyClass(42);  // 메모리 할당 시 실패할 수 있음.
  • 만약 메모리 할당이 실패하면 예외가 발생하기 전에 ptrnullptr이 할당될 수 있고, 메모리 해제가 제대로 이루어지지 않거나 스마트 포인터로 관리되지 않은 상태에서 문제가 발생할 수 있습니다.

std::make_unique가 안전한 이유:

new를 사용하여 동적으로 메모리를 할당할 때 할당 실패가 발생할 수 있습니다. 만약 메모리 할당이 실패하면, newnullptr을 반환하지 않고, 예외(std::bad_alloc)를 던지게 됩니다. 그런데, std::make_unique는 이 점을 안전하게 처리합니다.

핵심:

  • new는 예외 처리 없이 nullptr을 반환할 수 있지만, std::make_unique예외 처리를 통해 메모리 할당 실패 시 자동으로 안전한 예외를 던집니다. 이로 인해 불필요한 예외 처리나 예외 안전성을 걱정하지 않아도 됩니다.

참고로 make_unique() 는 -std=c++14.


정리

스마트포인터는 객체를 통해 자원을 관리하는 RAII 의 개념을 사용하였다.

오직 하나의 소유권만 부여하는 unique_ptr, 다수의 소유권을 나눠주는 shared_ptr, 그리고 shared_ptr의 특별한 케이스인 weak_ptr에 대해 살펴보았다.

42서울에서는 C++98만을 가지고 프로젝트를 진행하기 때문에 스마트포인터 부분에 대한 정리가 부족했는데 이 기회에 잘 정리한 것 같다.

0개의 댓글