C++ 스마트 포인터, unique_ptr, shared_ptr, weak_ptr
이 글은 이재범 님의 '모두의 코드'에서 '씹어먹는 C++ 13장'을 참고하여 공부한 내용입니다.
처음 게임 서버 개발자 면접을 봤을 때가 생각납니다. 그때 질문 중 하나로 스마트 포인터에 대해 설명하는 것이었고, 저는 "자세히는 모르겠습니다. 단지 자원의 할당과 해제를 자동적으로 해주는 요소로 알고 있습니다."라고 대답했습니다. 요즘 공부하고 보니 나는 정말 아는 것이 없구나 느낍니다.
C와 C++은 자원 관리를 수동으로 하기에 개발자는 신경 쓸 일이 더 많습니다. 거기에 게임 서버의 경우 메모리 관리가 잘못되면 메모리 부족으로 모든 서비스가 다운될 수도 있는 치명적인 문제를 발생시킬 수 있습니다. 하지만 대규모 작업을 여러 개발자들과 협업하게 되면 자원 관리는 더더욱 힘들어질 것입니다. 그런 문제를 해결하기 위해 나온 개발원칙이 있습니다.
Resource Acquisition Is Initialization, RAII
C++의 창시자인 Bjarne Stroustrup 제안하였습니다. 이 개발원칙은 C++에서 자원의 안정적인 관리를 위해 사용되는 코드 설계 원칙입니다. 이 원칙의 중요한 점은 객체의 수명을 통해 자원을 관리한다는 것입니다. 객체의 탄생, 생성자를 통해 자원(메모리, 열린 파일, 실행 스레드, 열린 소켓 등)을 할당하고, 객체의 수명이 끝나면, 소멸자를 통해 자원을 해제하는 것입니다.
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void safeFunction() {
std::lock_guard<std::mutex> lock(mtx); // RAII class
std::cout << "everything is ok\n";
} // if safeFunction() returns normally, the mutex is released
int main() {
std::thread t1(safeFunction);
std::thread t2(safeFunction);
t1.join();
t2.join();
return 0;
}
everything is ok
everything is ok
위 예시는 RAII 원칙 기반 클래스인 lock_guard를 이용한 멀티스레드 동기화 작업입니다. 기존에는 스레드들이 mtx.lock()을 통해 뮤텍스를 획득하고, mtx.unlock()을 통해 뮤텍스를 반환합니다. 하지만 위 예시에서는 lock_guard 객체 생성자를 통해 뮤텍스를 획득하고, safeFunction 함수가 종료되면 스택에서 제거됨과 동시에 lock_guard 객체 소멸자가 뮤텍스를 해제하는 방식입니다. RAII는 위와 같이 객체의 수명을 통해 자원을 관리하는 것입니다.
스마트 포인터
스마트 포인터는 위와 같이 RAII 원칙으로 만들어진 포인터 기반 객체입니다. 그래서 스마트 포인터들은 할당된 메모리 자원을 참조하고 있다가 스마트 포인터가 소멸하면 참조된 자원을 해제합니다.
unique_ptr
이 스마트 포인터는 이름 그대로 자원을 관리하는 유일한 객체를 뜻합니다. 자원을 단일 객체를 사용해서 관리하여 중복적인 해제를 막는 것입니다. 예를 들어 같은 주소를 참조하는 객체 2개가 있다면, 1개의 객체가 해제될 때 소멸자가 참조하고 있는 주소를 해제할 것입니다. 하지만 다른 1개의 객체는 해제된 주소를 참조하고 있기 때문에 치명적인 문제가 생길 수 있습니다. 이런 상황을 방지하고자 unique_ptr은 복사를 금지합니다.
#include <iostream>
#include <memory>
class Test {
public:
Test() { std::cout << "Constructor\n"; }
~Test() { std::cout << "Destroyer\n"; }
};
int main() {
std::unique_ptr<Test> ptr1 = std::make_unique<Test>();
return 0;
}
Constructor
Destroyer
위 예시는 unique_ptr을 사용하여 객체의 생성자와 소멸자를 호출하는 코드입니다. 위 코드에서 나오는 std::make_unique C++ 14부터 도입된 템플릿 함수입니다. 이 함수는 객체를 직접 인자로 받는 것이 아닌, 생성될 객체의 매개변수를 인자로 받습니다. 그래서 std::forward 함수를 통해 매개변수의 참조유형을 완벽히 전달하고 생성합니다.
이 템플릿 함수를 사용하는 이유는 예외 안정성을 보장할 수 있기 때문입니다.
std::unique_ptr<Test> ptr(new Test());
위 코드도 동일하게 unique_ptr을 만들지만, Test의 생성자에서 예외가 발생된다면 new에서 할당한 메모리를, 생성되지 못한 unique_ptr이 해제하지 못하고 누수가 발생할 확률이 있습니다. 하지만 make_unique를 사용하면 내부적으로 예외 발생 시 할당한 자원을 해제하기 때문에 예외 안정성을 보장할 수 있는 것입니다.
unique_ptr은 복사 생성자를 제거하여 동일한 참조를 가진 객체 복사를 방지하였습니다. 하지만 컴파일러는 복사생성자가 선언되어 있지 않다면 기본으로 얕은 복사를 하는 복사 생성자를 자동으로 선언해 줍니다. 그래서 명시적으로 delete 표시를 해주면 컴파일러는 해당 생성자를 선언하지 않습니다.
class NotCopy
{
public:
NotCopy(const NotCopy& target) = delete;
};
복사는 불가능 하지만 이동은 가능합니다.
#include <iostream>
#include <memory>
std::unique_ptr<int> createUniquePtr() {
return std::make_unique<int>(100);
}
int main() {
std::unique_ptr<int> ptr = createUniquePtr();
std::cout << ptr.get() << "\n";
std::unique_ptr<int> copyPtr = std::move(ptr);
std::cout << ptr.get() << "\n";
std::cout << copyPtr.get() << "\n";
}
000001F6764E68C0
0000000000000000
000001F6764E68C0
위 예시와 같이 우측값을 통해 unique_ptr의 이동 생성자를 사용할 수 있습니다. unique_ptr.get 메서드는 참조하는 주소를 반환합니다. 그리고 이동이 아닌 레퍼런스를 통해 참조 주소를 전달할 수도 있습니다.
#include <iostream>
#include <memory>
void readValue(const std::unique_ptr<int>& p) {
std::cout << p.get() << "\n";
}
int main() {
std::unique_ptr<int> ptr = std::make_unique<int>(50);
std::cout << ptr.get() << "\n";
readValue(ptr);
}
0000016962383080
0000016962383080
이렇게 사용하는 경우 unique_ptr의 특성인 단일성을 해친다고 해석할 수 있습니다. 코드의 방식은 개발자마다 다르기에 사용하기 전 주의가 필요합니다.
shared_ptr
이 스마트 포인터는 하나의 자원에 여러 객체가 참조할 수 있게 설계된 객체입니다. 그래서 같은 자원을 참조 중인 shared_ptr들은 하나의 컨트롤 블록을 통해 참조 횟수를 기록합니다. 참조 중인 자원이 해제되는 경우는 자원을 참조하는 마지막 shared_ptr이 소멸하거나 마지막 shared_ptr이 다른 자원을 참조하게 되면 참조하는 자원을 해제합니다.
#include <iostream>
#include <memory>
class Empty
{
public:
Empty() { std::cout << "contructor called\n"; }
~Empty() { std::cout << "destoryer called\n"; }
};
int main() {
std::shared_ptr<Empty> ptr1 = std::make_shared<Empty>();
{
std::shared_ptr<Empty> ptr2 = ptr1;
std::cout << "ptr2 get: " << ptr2.get() << "\n";
std::cout << "ptr2 use_count: " << ptr2.use_count() << "\n";
}
std::cout << "ptr1 get: " << ptr1.get() << "\n";
std::cout << "ptr1 use_count: " << ptr1.use_count() << "\n";
}
contructor called
ptr2 get: 000001B001366810
ptr2 use_count: 2
ptr1 get: 000001B001366810
ptr1 use_count: 1
destoryer called
위 예시는 shared_ptr을 통해 자원관리를 테스트하는 코드입니다. 이번에도 동일하게 템플릿 함수, std::make_shared를 사용하여 포인터를 생성합니다. 이 함수는 예외 안정성을 보장할 뿐만 아니라, 컨트롤 블록과 자원을 한 번에 할당하여 자원할당 비용을 절감합니다.
stdf::make_shared 함수를 통해 객체 생성과 shared_ptr 참조를 동시에 진행합니다. 그리고 스코프를 사용하여 스코프 안에 생성된 객체들이 중간에 해제될 수 있게 합니다. 스코프 안에 있는 ptr2는 ptr1을 복사하여 같은 자원을 참조하게 합니다. 그래서 참조주소가 동일하게 나오고 참조 카운트도 2개가 된 것을 확인할 수 있습니다. 그리고 ptr2가 소멸하여 참조 카운트가 1개로 줄어든 것을 확인할 수 있습니다. 마지막으로 ptr1도 소멸하며 참조된 자원이 해제되었습니다.
굉장히 유용한 스마트 포인터이지만 주의해야 할 점이 있습니다. 그것은 바로 같은 컨트롤 블럭을 통해 참조를 해야 하다는 것입니다.
#include <iostream>
#include <memory>
class Empty
{
public:
Empty() { std::cout << "contructor called\n"; }
~Empty() { std::cout << "destoryer called\n"; }
};
int main() {
Empty *obj = new Empty();
std::shared_ptr<Empty> ptr1(obj);
std::shared_ptr<Empty> ptr2(obj);
std::cout << "ptr1 get: " << ptr1.get() << "\n";
std::cout << "ptr1 use_count: " << ptr1.use_count() << "\n";
std::cout << "ptr2 get: " << ptr2.get() << "\n";
std::cout << "ptr2 use_count: " << ptr2.use_count() << "\n";
}
contructor called
ptr1 get: 000001A5F7C778D0
ptr1 use_count: 1
ptr2 get: 000001A5F7C778D0
ptr2 use_count: 1
destoryer called
destoryer called
위 예시는 잘못된 shared_ptr을 사용하여 자원 해제가 중복된 코드입니다. 이런 상황을 방지하려면 참조 주소를 이용하여 shared_ptr을 생성하는 것은 피해야 합니다. 그리고 가장 주의해야 할 점은 순환 참조로 인한 교착상태입니다.
#include <iostream>
#include <memory>
struct A;
struct B;
struct A {
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyer called\n"; }
};
struct B {
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyer called\n"; }
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
return 0;
}
위 예시에서는 shared_ptr을 사용하여 2개의 객체 안에 서로를 가리키는 shared_ptr을 가지고 있게 하여 참조 카운트가 각각 1개씩 남게 되어 해제가 되지 않습니다. std::make_shared 함수를 통해 생성된 new A(), new B() 객체 안의 shared_ptr이 서로를 참조하며 해제되지 못하는 상황인 것입니다.
객체 안에서 다른 객체를 참조하는 경우는 비일비재하기 때문에 잘못되어 순환참조가 발생할 수도 있습니다. 이런 순환참조 문제를 해결하기 위해서는 해당 자원에 대한 참조는 하지만 소유권이 없는 약한 참조를 통해 해결할 수 있습니다.
weak_ptr
이 스마트 포인터는 shared_ptr와 함께 사용되며, 이름 그대로 약한 참조를 가지는 객체입니다. shared_ptr이 관리하는 자원을 참조할 수 있으며, 자원의 소유권이 없기에 해제는 하지 않습니다. 그리고 가장 중요한 순환 참조 문제를 해결하는 데 사용합니다.
순환 참조를 해결하기 위해 weak_ptr만의 주요 특징이 있습니다. 우선 weak_ptr은 참조 카운트를 증가시키지 않습니다. 그래서 weak_ptr이 참조하고 있어도 마지막 shared_ptr이 제거되면 자원은 해제됩니다. 대신 약한 참조 카운트를 증가시킵니다. 약한 참조 카운터도 shared_ptr 내부의 컨트롤 블록을 통해 관리되며, 약한 참조 카운트가 남아 있다면, 참조 자원은 해제되어도 컨트롤 블록은 해제되지 않습니다. 그렇기에 weak_ptr은 shared_ptr의 자원 해제유무를 컨트롤 블록을 통해 알 수 있습니다.
#include <iostream>
#include <memory>
struct A;
struct B;
struct A {
std::weak_ptr<B> b_ptr;
~A() { std::cout << "A destroyer called\n"; }
};
struct B {
std::weak_ptr<A> a_ptr;
~B() { std::cout << "B destroyer called\n"; }
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
return 0;
}
B destroyer called
A destroyer called
각 개체안의 shared_ptr을 weak_ptr로 변경하니 소멸자가 잘 호출된 모습을 확인할 수 있습니다. B가 먼저 소멸된 것은 스택 프레임의 지역변수를 역순으로 해제하기 때문입니다.
하지만 weak_ptr은 unique_ptr과 shared_ptr처럼 ->를 통해 객체에 접근할 수 없습니다. 그래서 일시적으로 weak_ptr을 shared_ptr로 변환하여 객체를 사용하게 됩니다.
#include <iostream>
#include <memory>
void observe(std::weak_ptr<int> weak)
{
if (auto p = weak.lock())
std::cout << "\tobserve() is able to lock weak_ptr<>, value=" << *p << '\n';
else
std::cout << "\tobserve() is unable to lock weak_ptr<>\n";
}
int main()
{
std::weak_ptr<int> weak;
std::cout << "weak_ptr<> is not yet initialized\n";
observe(weak);
{
auto shared = std::make_shared<int>(42);
weak = shared;
std::cout << "weak_ptr<> is initialized with shared_ptr\n";
observe(weak);
}
std::cout << "shared_ptr<> has been destructed due to scope exit\n";
observe(weak);
}
위 예시는 weak_ptr.lock을 사용하여 observe 함수를 간략하게 구현한 것입니다. 위처럼 weak_ptr을 통해 이벤트를 확인하고 이벤트 리스너들을 호출할 수 있습니다.