C++의 raw 포인터는 강력한 수단이지만 프로그래머의 집중력이 조금만 흐트러져도 버그를 발생시키기 쉽다. 이를 보완하기 위해 C++11에서는 스마트 포인터를 제공한다.
스마트 포인터는 raw 포인터가 할 수 있는 거의 모든 일을 할 수 있으며, 오류의 여지가 훨씬 적기 때문에 raw 포인터보다 스마트 포인터를 사용하는것이 권장된다.
종류는 세 가지로 각각 std::unique_ptr, std::shared_ptr, std::weak_ptr이다.(실제론 네 가지이나 하나는 비권장 기능으로 분류된다.)
unique_ptr은 독점적 소유권을 갖는 포인터를 의미한다. 다른 말로는 피지칭 객체를 가리키는 포인터가 오직 그것 하나 뿐임을 의미한다. 즉, unique_ptr이 소멸될 때 피지칭 객체 또한 소멸 시키는 것이 합당하며 실제로 그렇게 동작한다. unique_ptr은 이동 전용 형식으로서 복사가 불가능하도록 설계 되어 있다. 이러한 성질 때문에 독점적 소유권을 가짐을 보장해줄 수 있다.
#include <memory> // 스마트 포인터가 정의된 헤더
using namespace std;
class Something; // 구현 생략
unique_ptr<Something> makeSomething()
{
//unique_ptr<Something> pSomething(new Something());
auto pSomething = make_unique<Something>();
return pSomething;
}
void func()
{
auto ptr = makeSomething(); //ptr은 unique_ptr<Something> 타입
// 일련의 작업..
// func이 종료되면서 ptr이 소멸하고 makeSomething에서 만든 객체가 동시에 소멸
}
int main()
{
func();
}
unique_ptr을 만들 때는 new 연산을 활용하여 만들 수 있지만 예외 안전성의 이유로 make_unique 함수를 사용하는 것이 권장된다.
피지칭 객체를 소멸시킬 때는 기본적으로 delete 연산자를 이용한다. 그러나 필요한 경우 커스텀 삭제자를 직접 작성할 수 있다.
#include <memory>
using namespace std;
class Something;
auto delSomething = [](Something* pSomething) {//구현 생략...};
unique_ptr<Something, decltype(delSomething)> makeSomething()
{
unique_ptr<Something, decltype(delSomething)> pSomething(new Something, delSomething);
return pSomething;
};
커스텀 삭제자가 상태가 많은 함수 객체라면 unique_ptr의 크기가 상당히 커질 수 있으므로 그런 경우는 설계 변경을 고려해야할 수 있다.
shared_ptr은 독점적 소유권을 갖지 않는 포인터를 의미한다. 여러 shared_ptr이 하나의 객체를 가리킬 수 있으며 같은 대상을 가리키는 shared_ptr들은 피지칭 객체에 대한 참조 개수 등의 정보가 포함된 제어 블럭을 공유한다. shared_ptr이 소멸될 때 피지칭 객체를 가리키는 포인터가 자신이 유일한지 확인한 후 그런 경우에만 피지칭 객체를 소멸시켜 준다.
#include <memory> //스마트 포인터가 정의된 헤더
using namespace std;
class Something; //구현 생략
void func(shared_ptr<Something> ptr)
{
//일련의 작업...
//func이 종료되면서 ptr이 소멸하고 제어 블럭에서 참조 카운트 감소
}
int main()
{
auto sPtr = make_shared<Something>(); //참조 카운트 1
func(sPtr); //파라미터가 복제 생성되면서 참조 카운트 2
//참조 카운트 1이므로 객체는 유효함
}
shared_ptr은 객체 외에 제어 블럭을 가리키는 포인터도 포함하므로 일반적인 raw 포인터 크기의 2배이다. 제어 블럭은 해당 피지칭 객체를 처음 참조하는 shared_ptr이 생성될 때 같이 생성되며 기존의 shared_ptr을 복제하여 생성되는 다른 shared_ptr들은 만들어져 있는 제어 블럭에서 참조 횟수를 늘린다.
제어 블럭은 다음과 같은 상황에서 새로 생성된다.
1. std::make_shared는 항상 제어 블럭 생성
2. std::unique_ptr로부터 std::shared_ptr을 생성하면 제어 블럭 생성
3. raw 포인터로 std::shared_ptr을 생성하면 제어 블럭 생성
2번 또는 방법으로 생성되는 경우는 피지칭 객체가 이미 할당되어 있는 상태이고 제어 블럭을 위한 힙 메모리를 추가로 할당 받게되는 것인데 1번 방법으로 생성되는 경우에는 피지칭 객체와 제어블럭을 모두 담을 수 있는 크기의 힙 메모리를 한 번에 할당 받기 때문에 보다 효율적이다. 또한 예외 안정성 관련한 미묘한 문제를 회피할 수 있다.
processWidget(shared_ptr<Widget>(new Widget), computePriority()); //메모리 누수 가능성 있음
위와 같은 코드는 안전해 보이지만 사실 메모리 누수의 가능성을 가지고 있다. 컴파일 과정에서 다음과 같은 순서로 실행되는 목적 코드가 만들어질 수 있다.
new Widget을 실행 -> computePriority를 실행 -> shared_ptr 생성자를 실행
그리고 실행시점에서 computePriority가 예외를 던진다면 new Widget으로 할당된 메모리는 접근이 불가능 해지면서 메모리 누수가 발생한다. 반면 make_shared는 메모리 할당과 shared_ptr의 생성이 원자적으로 처리되기 때문에 이러한 버그로부터 안전하다.
make_shared 함수를 사용하는 게 위와 같은 이유로 더 바람직하지만 경우에 따라 그렇지 않은 경우도 있다.
첫 번째로 make_shared 함수로는 커스텀 삭제자를 지정할 수 없어 커스텀 삭제자가 필요하다면 raw 포인터를 만들어 생성자를 호출하는 방식을 택할 수 밖에 없다.
두 번째로 피지칭 객체를 가리키는 마지막 shared_ptr의 파괴와 마지막 weak_ptr의 파괴 사이의 시간 간격이 꽤 길다면, 객체가 차지하고 있던 메모리 공간은 더 이상 사용되지 않지만 점유하고 있는 상태가 오래 지속될 수 있다. 이런 경우는 make_shared가 오히려 좋지 않은 선택일 수 있다.
shared_ptr의 커스텀 삭제자 지원 방식은 unique_ptr의 방식과 다르다. unique_ptr에서는 삭제자의 형식이 스마트 포인터 형식의 일부였지만 shared_ptr에서는 그렇지 않다.
auto loggingDel = [](Something *ps)
{
makeLog(ps);
delete ps;
}
std::unique_ptr<Something, decltype(loggingDel)> ups(new Something(), loggingDel);
std::shared_ptr<Something> sps(new Something(), loggindDel);
이러한 차이가 발생하는 이유는 unique_ptr은 커스텀 삭제자가 unique_ptr 객체 자체에 포함되기 때문에 unique_ptr의 크기를 컴파일 타임에 알아야할 필요가 있고, 그렇기 때문에 템플릿 파라미터로 커스텀 삭제자를 전달해줘야 하는데, shared_ptr은 커스텀 삭제자가 동적할당되는 제어블럭 내에 위치하기 때문에 런타임에 그 크기를 알 수 있기만 하면 되므로 보다 유연한 구조로 설계된 것이다.
어떠한 객체를 가리키는 포인터 중 어떤 것은 raw 포인터이고 어떤 것은 shared_ptr인 상황은 최악의 상황이다. 이 경우는 shared_ptr의 참조 카운트에 맹점이 생겨 참조가 남아 있음에도 피지칭 객체가 소멸될 수 있고 댕글링 포인터 버그가 발생하게 된다. 또 하나의 제어블럭을 공유하는게 아니라 여러개의 제어블럭이 생성될 가능성도 있으며 중복 메모리 해제 등의 버그가 발생할 수도 있다.
만약 raw 포인터의 사용이 불가피하다면 해당 객체를 가리키는 포인터는 반드시 모두 raw 포인터여야 한다.
weak_ptr은 shared_ptr처럼 작동하되 대상을 잃을 수 있는 포인터이다. shared_ptr은 참조 카운팅을 통해 댕글링 포인터를 방지하지만 이러한 엄격함이 경우에 따라 구현에 제약이 되는 경우가 있을 수 있는데 이를 위해 준비된 포인터라고 할 수 있다.
weak_ptr가 생성될 때는 다른 shared_ptr을 통해서 생성되며 참조 카운트 대신 약한 참조 카운트를 조작한다. 객체가 소멸하더라도 약한 참조가 객체가 소멸 여부를 검사할 수 있어야하기 때문에 제어 블럭을 포함하는 메모리 공간은 해제되지 않는다. 제어 블럭의 참조 카운트와 약한 참조 카운트가 모두 0이 되어야만 그 메모리를 해제할 수 있다.
weak_ptr은 제어 블럭의 참조 카운트를 보고 만료여부를 확인하는 동작이 가능하고 만료되지 않았다면 shared_ptr을 생성해주는 동작을 할 수 있다. weak_ptr로부터 shared_ptr을 생성하기 위해서는 만료여부를 확인하는 동작과 shared_ptr을 생성하는 동작이 원자적으로 처리되어야 하므로 lock이라는 이름의 스레드 세이프한 메서드를 제공한다.
auto sps = std::make_shared<Something>(); ... std::weak_ptr<Something> wps(sps); //참조 카운트 1, 약한 참조 카운트 1 sps = nullptr; //Something 객체 소멸(소멸자 호출은 하지만 메모리는 해제되지 않음) auto sps2 = wps.lock(); //스레드 세이프라게 객체 만료 여부에 따라 shared_ptr 또는 nullptr 리턴 if (sps2 != nullptr) { ... }
weak_ptr이 필요한 상황은 드물지만 몇 가지 예시는 다음과 같다.
- 캐싱 등 최적화를 위해 사용하는 보조적인 포인터로서 객체의 수명에 관여하지 않아야 할 때
- 옵저버 패턴 등에서 대상의 수명에 관여하지 않으면서 관찰이 필요할 때
- shared_ptr의 순환 사이클을 방지해야 할 때