C++

스마트 포인터

스마트 포인터란?

이전 포스팅에서 배웠듯이 메모리의 동적 할당은 할당 후 수동으로 해제해주지 않으면 메모리 누수(memory leak) 문제가 생긴다. 하지만 코드가 커지면 커질수록 동적 할당을 적절히 해제해주는 것은 점점 어려워진다. 스마트 포인터는 이러한 문제를 해결하기 위해 만들어진 기능이다.

스마트 포인터(smart pointer)는 포인터처럼 쓰이는 클래스 템플릿이다. 스마트 포인터는 동적 할당을 통해 생성된 기본 포인터를 매개변수로 받아 생성되고, 생성된 스마트 포인터는 일반 포인터처럼 사용할 수 있다. 스마트 포인터는 사용이 종료된 후 소멸자를 통해 자동으로 할당을 해제해주기 때문에 메모리 누수 문제를 막을 수 있다.
스마트 포인터는 c++ 11 이상부터 추가되었으며 헤더가 include 되어야 사용 가능하다. 스마트 포인터에는 3가지가 있으며 각각 작동 방식이 다르다.

unique_ptr

unique_ptr은 하나의 객체에 소유권을 가지는 스마트 포인터이다. unique_ptr 객체를 생성했다면 그 포인터가 가리키는 객체는 오직 그 포인터만 참조가 가능하고 다른 포인터가 참조할 시 오류를 발생시킨다.

unique_ptr<int> u(new int(10));
unique_ptr<int> u1=make_unique<int>(10);
auto u2=u;

unique_ptr의 생성은 위의 2가지 방법으로 가능하다. 생성자를 이용하여 초기화해주는 방법과 지정된 객체를 생성하고 해당 객체를 참조하는 unique_ptr을 반환하는 make_unique 함수를 사용하는 방법이 있다. 위의 코드는 생성된 unique_ptr 객체를 다른 unique_ptr로 참조하려고 했으므로 오류를 발생시킨다.

unique_ptr<int> u(new int(10));
auto u2=move(u);

위의 코드와 같이 move 함수를 사용하여 u의 소유권을 u2로 이전해준다면 코드가 문재없이 작동한다.
unique_ptr는 이후 설명할 shared_ptr에서 일어날 수 있는 순환 참조(circular reference) 문제가 없기 때문에 하나의 객체를 여러 포인터가 참조해야 할 상황이 아니라면 안정적으로 사용할 수 있다.

shared_ptr

shared_ptr는 unique_ptr과 달리 하나의 객체를 여러 포인터가 참조할 수 있다. 대신 할당된 객체는 자신을 가리키는 포인터가 몇 개인지를 나타내는 ref count를 가지고, 이 ref count가 0이 되면 사용이 종료된 것으로 판단하고 할당을 자동 해제한다.

shared_ptr<int> u(new int(10));
shared_ptr<int> u1=make_shared<int>(10);
auto u2=u;
cout << u.use_count(); //2 출력

shared_ptr 또한 unique_ptr과 마찬가지로 생성자와 make_shared 함수 2가지를 사용하여 만들 수 있다. 위의 코드는 u가 가리키는 객체의 ref count가 1에서 2로 늘어날 뿐 오류가 발생하지 않는다.

그냥 unique_ptr 대신 shared_ptr만 줄창 써도 될 것 같지만 shared_ptr는 생각없이 쓰다보면 치명적인 문제가 생길 수 있다.

struct B;
struct A {
    shared_ptr<B> B_ptr;
};
struct B {
    shared_ptr<A> A_ptr;
};

shared_ptr<A> a(new A());
shared_ptr<B> b(new B());

a->B_ptr=b;
b->A_ptr=a;

순환 참조(circular reference)의 가장 간단한 예제이다. 위와 같이 shared_ptr가 서로를 참조한다면 a와 b의 사용이 종료되어도 a를 가리키는 A_ptr, b를 가리키는 B_ptr이 존재하기 때문에 서로의 ref count가 절대로 0으로 떨어지지 않는, 일종의 dead lock 상태에 빠지게 되기 때문에 메모리 누수(memory leak) 문제가 발생한다. 이를 방지하기 위해 아래 설명할 weak_ptr가 존재한다.

weak_ptr

weak_ptr는 shared_ptr와 같이 사용되며 주로 순환 참조(circular reference)가 발생할 위험이 있다고 판단될 때 사용된다. weak_ptr는 shared_ptr과 같이 어떤 객체를 가리키는 포인터로써 기능하지만, 객체의 수명에 영향을 미치는 ref count가 아닌 weak count를 증가시킨다. 이 weak_ptr를 사용해서 위의 코드가 순환 참조를 일으키지 않도록 수정해보자.

struct B;
struct A {
    shared_ptr<B> B_ptr;
};
struct B {
    weak_ptr<A> A_ptr;
};

shared_ptr<A> a(new A());
shared_ptr<B> b(new B());

a->B_ptr=b;
b->A_ptr=a;

한 쪽의 스마트 포인터를 weak_ptr로 바꿔보았다. 이렇게 수정할 경우 두 변수가 사용 종료될 때 A_ptr은 객체의 수명에 영향을 주지 않는 참조이므로 a는 파괴되고 A_ptr은 nullptr을 가리키게 된다. a가 파괴되면서 더이상 b를 가리키는 포인터도 없으므로 b도 파괴되고 순환 참조 문제는 발생하지 않는다.

weak_ptr는 약한 참조를 제공하기 때문에 weak_ptr가 가리키는 객체에 직접 접근하기 위해서는 lock() 함수를 사용하여 weak_ptr를 shared_ptr로 바꿔줄 필요가 있다. 이 과정에서 weak_ptr가 이미 할당해제된 객체를 가리키고 있다면 nullptr를 반환한다.

shared_ptr<int> s(new int(10));
weak_ptr<int> w=s;
shared_ptr<int> ss=w.lock();
cout << *ss;

weak_ptr가 가리킬 때 추가되는 weak count는 객체의 Control Block을 살리는 일을 한다. Control Block은 ref count를 저장하고 0이 되면 자동으로 할당을 해제하는 일 등을 하는데, 만약 weak count가 없고 ref count만이 있다면 ref count가 0이 될 때 객체의 할당을 해제하면서 필요없어진 Control Block 또한 죽어버린다.
이런 죽은 객체를 weak_ptr가 참조한다면 이것이 할당 해제되었다는 것을 알려줄 Control Block이 없어 해제된 메모리를 참조하여 그대로 런타임 에러를 발생시켜 프로그램이 뻗어버린다. weak count가 있다면 객체의 할당이 해제되어도 Control Block이 남아있어 weak_ptr가 lock()함수를 이용하여 참조하였을 때 nullptr를 반환하여 프로그램을 정상적으로 작동시킨다.

스마트 포인터의 각자의 특징과 차이를 유의하고 사용한다면 메모리를 동적 할당하고 관리하는데 있어 매우 유용한 도구가 될 것이다.