Smart Pointer
카테고리: Cpp
태그: Programming
모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.
c++
이후에 나온 많은 언어(Java 등등)들은 대부분 가비지 컬렉터 (Garbage Collector - GC) 라 불리는 자원 청소기가 기본적으로 내장되어 있다. 이 가비지 컬렉터의 역할은 프로그램 상에서 더 이상 쓰이지 않는 자원을 자동으로 해제해 주는 것이다. 따라서 프로그래머들이 코드를 작성할 때 자원을 해제하는 일에 대해 크게 싱경쓰지 않아도 된다. 하지만 c++
의 경우 한 번 획득한 자원은, 직접 해제하지 않는 이상 프로그램이 종료되기 전 까지 영원히 남아있게 된다.
class A {
int *data;
public:
A() { data = new int[100]; }
~A() { delete[] data; }
};
void do_something() { A *pa = new A(); }
int main() {
do_something();
}
위 코드와 같이 do_something
안에서 delete
를 하지 않는다면, 생성된 객체를 가리키던 pa
포인터는 메모리에서 사라지게 된다. 따라서 Heap
어딘가에 클래스 A
객체가 그대로 남아있고, 그 객체를 가리키는 포인터는 메모리 상에 존재하지 않는 것이다. 따라서 그 객체는 영원히 해제되지 못한 채 힙에서 자리만 차지하며 메모리 누수를 발생시키는 것이다.
언뜻 생각하기에 자원 해제를 꼼꼼히 하지 않은 프로그래머 책임 아님?
이라 할 수 있으나 프로그램의 크기가 커지고 복잡해지면 자원을 해제하는 위치가 애매해지는 경우가 많아진다. 특히 예외처리가 포함 되어있을 경우가 그렇다.
void thrower() {
// 예외를 발생시킴!
throw 1;
}
void do_something() {
A *pa = new A();
thrower();
// 발생된 예외로 인해 delete pa 가 호출되지 않는다!
delete pa;
}
int main() {
try {
do_something();
} catch (int i) {
std::cout << "예외 발생!" << std::endl;
}
}
위와 같이, thrower()
로 발생된 예외로 인해, delete pa;
가 실행 되지 않고 넘어가버리기 때문이다. 이런 경우 예외는 정상적으로 처리되었지만, 메모리 누수는 피하지 못했다.
Resource Acquisition Is Initialization - RAII
c++
창시자인 비야네 스트로스트룹은 c++
에서 자원을 관리하는 방법으로 흔히 RAII라 불리는 디자인 패턴을 제안했다. 이는 스택에 할당한 객체를 통해 자원 관리를 수행하는 것이다.
지난 강좌에서 예외가 발생해 함수를 빠져나가더라도, 그 함수의 스택에 정의되어있는 모든 객체들은 빠짐 없이 소멸자를 호출하는데, 이를 Stack Unwiding
이라 한다고 했다. 물론 예외가 발생하지 않아도, 함수가 종료될 때 당연히 소멸자들이 호출된다.
위 코드에서 pa
는 객체가 아니기 때문에 소멸자가 호출되지 않고, 객체를 가리키고 있는 포인터 값 메모리만 해제된다. 그렇다면 그 대신에, pa
를 일반적인 포인터가 아닌 포인터 객체
로 만들어 소멸자가 호출될 때 자신이 가리키는 데이터도 같이 delete
하면 어떨까? 그렇게 된다면 자원 관리를 스택의 객체를 통해 수행할 수 있다.
이렇게 똑똑하게 작동하는 포인터 객체를 스마트 포인터(Smart Pointer)라고 한다.
[!tip] c++ 11 dlwjsdp, auto_ptr이란게 잠시 등장했지만, 문제가 너무 많아 지금은 사용하지 않는다.
객체의 유일한 소유권 - unique_ptr
c++
에서 메모리를 잘못 관라하였을 때 발생하는 문제점은 크게 두 가지 종류이다.
첫 번째는 앞서 이야기한 메모리를 사용한 후 해제하지 않는 경우이다. 이를 메모리 누수(memory leak)
이라고 부른다. 간단한 프로그램의 경우 크게 문제될 일이 없지만, 서버 처럼 장시간 작동하는 프로그램의 경우 시간이 지남에 따라 점점 사용하는 누수되는 메모리의 양이 늘어난다. 결과적으로 시스템 메모리가 부족해져 서버가 죽어버리는 문제가 발생할 수 있다. 다행히 해당 문제는 위에서 이야기한 RAII
패턴을 사용해 해결할 수 있다.
두 번째로 발생 가능한 문제는 이미 해제된 메모리를 다시 참조하는 경우이다.
Data* data = new Data();
Date* data2 = data;
// data 의 입장 : 사용 다 했으니 소멸시켜야지.
delete data;
// ...
// data2 의 입장 : 나도 사용 다 했으니 소멸시켜야지
delete data2;
예를 들어 위 코드에서는 이미 소멸된 객체를 다시 소멸시키려고 한다. 이를 double free
버그라 하는데 이런 경우 메모리 오류가 나면서 프로그램이 죽어버린다.
위와 같은 문제가 발생한 이유는 객체의 소유권이 명확하지 않기 때문이다. 만약 어떤 포인터에 객체의 유일한 소유권을 부여하여, 이 포인터 말고는 객체를 소멸시킬 수 없다!
라고 한다면, 위와 같은 문제를 방지할 수 있을 것이다.
c++
에서는 이렇게 특정 객체에 유일한 소유권을 부여하는 포인터 객체를 unique_ptr 이라 한다.
class A {
int *data;
public:
A() { data = new int[100]; }
void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }
~A() { delete[] data; }
};
void do_something() {
std::unique_ptr<A> pa(new A());
pa->some();
}
int main() {
do_something();
}
unique_ptr
은 ->
연산자를 오버로드해서 마치 포인터를 다루는 것과 같이 사용할 수 있다. 또한 이 unique_ptr
덕분에 RAII
패턴을 사용할 수 있다. 즉, pa
는 스택에 정의된 객체이기 때문에 do_something
함수가 종료될 때 자동으로 소멸자가 호출된다.
void do_something() {
std::unique_ptr<A> pa(new A());
// pb 도 객체를 가리키게 할 수 있을까?
std::unique_ptr<A> pb = pa;
}
[!error] ‘std::unique_ptr<A,std::default_delete<_Ty>>::unique_ptr(const std::unique_ptr<_Ty,std::default_delete<_Ty>> &)': attempting to reference a deleted function
만약 unique_ptr
을 복사하려고 하면 위와 같이 삭제된 함수를 참조한다는 오류가 뜬다.
삭제된 함수
class A {
public:
A(int a){};
A(const A& a) = delete;
};
int main() {
A a(3); // 가능
A b(a); // 불가능 (복사 생성자는 삭제됨)
}
` = delete; 키워드는
c++ 11`에 추가된 기능으로, 사용을 원치 않는 함수를 명시적으로 삭제시키는 방법이다.
unique_ptr은 특정 객체의 소유권을 유일하게 보존하기 위해 복사 생성자를 명시적으로 삭제했다.
unique_ptr 소유권 이전
unique_ptr
은 복사 생성자가 정의되어 있지 않지만, 이동 생성자는 정의되어 있다(생성자 & 대입 연산자). 즉, 객체의 유일한 소유권을 다른 포인터에게 이전할 수 있다는 것이다.
void do_something() {
std::unique_ptr<A> pa(new A());
pa->some();
// pb 에 소유권을 이전.
std::unique_ptr<A> pb = std::move(pa);
pb->some();
}
std::move
를 이용해 이동 생성자를 호출하면 pb
가 new A
로 생성된 객체의 소유권을 갖게 되고, pa
는 아무것도 가리키고 있지 않게 된다. 실제로 pa.get()
을 통해 pa
가 가리키고 있는 실제 주소값을 확인해보면 0 (nullptr)
이 찍힌다. 따라서 소유권을 이전시키고 난 이후에는 기존의 unique_ptr
에 접근하지 않도록 조심해야 한다.
[!warning] 소유권이 이전된 unique_ptr을 댕글링 포인터(dangling pointer)라고 하며 이를 재 참조할 시에 런타임 오류가 발생한다. 따라서 소유권 이전은, 댕글링 포인터를 절대 다시 참조하지 않겠다는 확신 하에 이동해야 한다.
unique_ptr 함수 인자로 전달
앞서 unique_ptr
은 복사 생성자가 없다고 했다. 만약 어떠한 unique_ptr
을 함수의 인자로 전달하고 싶다면 어떻게 해야 할까? 그냥 레퍼런스로 전달해야 할까?
// 올바르지 않은 전달 방식
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); }
int main() {
std::unique_ptr<A> pa(new A());
do_something(pa);
}
위 코드와 같이 unique_ptr
의 레퍼런스를 사용하는 것은 유일한 소유권이라는 의미를 망각한 채 단순히 포인터의 Wrapper
로 사용하는 것에 불과하다. 즉, 코드가 작동은 하지만 우리의 의도대로 작동하지 않을 것이다.
그렇다면 unique_ptr
을 올바르게 전달하는 방법은 무엇일까? 이는 생각보다 단순한데, unique_ptr
의 get
함수를 이용해 원래의 포인터 주소값을 전달해주면 된다.
void do_something(A* ptr) { ptr->do_sth(3); }
int main() {
std::unique_ptr<A> pa(new A());
do_something(pa.get());
}
unique_ptr 생성하기
auto ptr = std::make_unique<Foo>(3, 5);
c++ 14
부터 unique_ptr
을 간단히 만들 수 있는 std::make_unique
함수를 제공한다.
또한 make_unique
함수는 아예 템플릿 인자로 전달된 클래스 생성자에 인자들을 직접 완벽한 전달을 통해 전달한다. 따라서 불필요한 임시 객체의 생성, 복사 과정을 생략할 수 있고, 코드도 간결해진다.
unique_ptr을 STL에 집어 넣기
int main() {
std::vector<std::unique_ptr<A>> vec;
std::unique_ptr<A> pa(new A(1));
vec.push_back(pa); // error
}
vector
의 push_back
함수는 전달된 인자를 복사해서 집어 넣기 때문에 복사 생성자가 제거된 unique_ptr
을 집어 넣으면 에러가 발생한다.
이를 방지하기 위해서는 명시적으로 pa
를 vector
안으로 이동 시켜주어야 한다.
vec.push_back(std::move(pa)); // 잘 실행됨
이때 재미있게도, emplace_back
함수를 이용하면 vector
안에 unique_ptr
을 직접 생성하면서 집어 넣을 수도 있다. 즉 불필요한 과정을 생략할 수 있다는 것이다. emplace_back
함수는 전달된 인자를 완벽한 전달을 통해 직접 unique_ptr<A>
의 생성자에 전달한다.
// vec.push_back(std::unique_ptr<A>(new A(1))); 과 동일
vec.emplace_back(new A(1));
shared_ptr
여러 객체에서 하나의 자원을 사용하는 경우가 존재한다. 이런 경우 해당 자원을 사용하는 모든 객체들이 소멸되어야 자원을 해제할 수 있는데, 어떤 객체가 먼저 소멸되는지 알 수 없기 때문에 자원을 해제시키는 타이밍 역시 알기 힘들다.
따라서 이런 경우, 특정 자원을 몇 개의 객체에서 가리키는지를 추적하며, 그 수가 0이 되는 순간 자원을 해제시켜주는 좀 더 스마트한 포인터가 필요하다.
이러한 방식을 정확히 수행하는 포인터가 바로 shared_ptr
이다. 유일하게 객체를 소유하는 unique_ptr
과는 다르게, shared_ptr
로 객체를 가리킬 경우, 다른 shared_ptr
역시 그 객체를 가리킬 수 있다.
std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1); // p2 역시 생성된 객체 A 를 가리킨다.
// 반면에 unique_ptr 의 경우
std::unique_ptr<A> p1(new A());
std::unique_ptr<A> p2(p1); // 컴파일 오류!
여기서 퀴즈 하나! 말했다시피 개개의 shared_ptr
들은 레퍼런스 카운트가 몇인지 알고 이어야만 한다. 이 경우 어떻게 하면 같은 객체를 가리키는 shared_ptr
끼리 동기화 시킬 수 있을까?
[1] shared_ptr
내부에 레퍼런스 카운트 저장하기
std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);
std::shared_ptr<A> p3(p2);
p3
를 선언할 때 여차저차 해서 p2
의 레퍼런스 카운트를 증가시킬 수 있다 해도, p1
에 저장되어 있는 레퍼런스 카운트는 건드릴 수 없다. 즉, 개개의 shared_ptr
이 서로 독립적으로 존재하기 때문에 내부에서 관리할 수가 없다.
[!fail]
[2] 별도의 제어 블록 관리
따라서 이와 같은 문제를 방지하기 위해 처음으로 자원을 가리키는 shared_ptr
이 제어 블록(control block) 을 동적으로 할당한 후, shared_ptr
들이 이 제어 블록의 정보를 공유하는 방식으로 구현된다.
make_shared 를 통한 생성
std::shared_ptr<A> p1(new A());
앞서 shared_ptr
을 위와 같이 생성하였으나, 사실 이는 바람직한 생성 방법은 아니다. 왜냐하면 shared_ptr
을 생성하기 위해서는 일단 A
를 생성하기 위한 동적 할당이 한 번 일어나고, 그 다음 shared_ptr의 제어블록
을 위한 동적 할당이 한 번 일어나야 한다.
알다시피 동적 할당은 상당히 비싼 연산이기 때문에, 어차피 이미 정해진 사이즈로 두 번 할당할 것을 합친 크기로 한 번 할당하는 것이 훨씬 효율적이다.
std::shared_ptr<A> p1 = std::make_shared<A>();
make_shared
함수는 A
의 생성자 인자들을 받아 객체 A
와 제어 블록
을 한 번에 동적 할당하고, 만들어진 shared_ptr
을 리턴한다.
enable_shared_from_this
A* a = new A();
std::shared_ptr<A> pa1(a);
std::shared_ptr<A> pa2(a);
shared_ptr
을 생성할 떄 주의할 점이 있다. 위 코드와 같이 인자로 주소값이 전달되면, 마치 자기가 해당 객체를 첫 번째로 소유하는 shared_ptr
인 것 마냥 행동한다. 다시 말해, 같은 객체를 가리키는 p1
, p2
가 각각의 control block
을 갖게 된다는 말이다.
이러한 문제를 방지하려면 shared_ptr
을 주소값을 통해 생성하는 것을 지양해야 한다. 하지만 어쩔 수 없는 상황도 있는데, 바로 객체 내부에서 자기 자신을 가리키는 shared_ptr
을 만들 때이다. (DX11 강의에서 고통받았던 부분..)
class A {
int *data;
public:
A() {
data = new int[100];
std::cout << "자원을 획득함!" << std::endl;
}
~A() {
std::cout << "소멸자 호출!" << std::endl;
delete[] data;
}
std::shared_ptr<A> get_shared_ptr() { return std::shared_ptr<A>(this); }
};
int main() {
std::shared_ptr<A> pa1 = std::make_shared<A>();
std::shared_ptr<A> pa2 = pa1->get_shared_ptr();
std::cout << pa1.use_count() << std::endl;
std::cout << pa2.use_count() << std::endl;
}
[!result] 자원을 획득함! 1 1 소멸자 호출! 소멸자 호출! test(38479,0x10e0945c0) malloc: ** error for object 0x7fa1e0e02700: pointer being freed was not allocated test(38479,0x10e0945c0) malloc: ** set a breakpoint in malloc_error_break to debug [1] 38479 abort ./test
위 코드에서 get_shared_ptr
함수는 shared_ptr
을 생성할 때, 이미 자기 자신을 가리키는 shared_ptr
이 있다는 사실을 모른채 새로운 제어 블록을 생성한다. 따라서 get_shared_ptr
이 아직 객체를 가리키고 있음에도 p1
이 해당 객체를 지워버려 double free
버그가 발생하게 된다.
이 문제는 enable_shared_from_this
를 통해 깔끔하게 해결할 수 있다.
std::shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
enable_shared_from_this
클래스에는 shared_from_this
라는 멤버 함수를 정의하고 있는데, 이 함수는 이미 정의되어 있는 제어 블록을 사용해 shared_ptr
을 생성한다.
이때 주의할 점은 기존 제어블록을 가지고 있는 shared_ptr
이 반드시 먼저 정의되어 있어야 한다. 그렇지 않으면 shared_from_this
는 있는 제어 블록을 확인해서 가져만 올 뿐, 없는 제어 블록을 만들지는 않기 때문에 오류가 발생하게 된다.
weak_ptr
앞서 shared_ptr
은 레퍼런스 카운트가 0이 되면 가리키는 객체를 메모리에서 해제 시킨다고 했다. 그런데, 겍체를 더 이상 사용하는 않는데도 불구하고 참조 갯수가 절대 0 이 될 수 없는 경우가 있다. 바로 아래 그림처럼 서로가 서로를 참조하는 경우이다.
void set_other(std::shared_ptr<A> o) { other = o; }
int main() {
std::shared_ptr<A> pa = std::make_shared<A>();
std::shared_ptr<A> pb = std::make_shared<A>();
pa->set_other(pb);
pb->set_other(pa);
}
객체 1이 해제되기 위해서는 객체 1을 가리키고 있는 shared_ptr
의 참조 갯수가 0이 되어야 한다. 즉, 객체 2가 소멸되어야 한다. 하지만 객체 2가 해제되기 위해서는 마찬가지로 객체 2를 가리키고 있는 shared_ptr
의 참조 갯수가 이 되어야 하고, 그러기 위해서는 객체 1이 소멸되어야만 한다.
이 문제는 shared_ptr
자체에 내재되어 있는 구조적인 문제이기 때문에 shared_ptr
만으로는 문제를 해결할 수 없다. 이러한 순환 잠조 문제를 해결하기 위해 등장한 것이 바로 weak_ptr
이다.
예를 들어 위 그림과 같은 트리 구조를 지원하는 클래스를 만든다고 해보자. 즉, 한 개의 노드는 여러개의 자식 노드를 가질 수 있지만, 단 한 개의 부모 노드만을 가진다. 이러한 구조는 아래와 같이 구현될 것이다.
class Node {
std::vector<std::shared_ptr<Node>> children;
/* 어떤 타입이 와야할까? */ parent;
public:
Node(){};
void AddChild(std::shared_ptr<Node> node) { children.push_back(node); }
};
이때 고민되는 것이 과연 parent
노트의 타입을 무엇으로 하느냐 이다.
-
만약 일반 포인터(
Node*
) 로 하게 된다면, 메모리 해제를 제대로 하지 않을 경우, 혹은 예외가 발생한 경우 적절하게 자원을 해제하기 어렵다. 또한 이미 해제된 메모리를 계속 가리키고 있을 위험도 있다. -
그렇다고
shared_ptr
로 하게 된다면 부모와 자식이 서로를 가리키기 때문에 앞서 본 순환 참조 문제가 발생한다.
weak_ptr
은 일반 포인터와 shared_ptr
사이에 위치한 스마트 포인터로, 스마트 포인터 처럼 객체를 안전하게 잠조할 수 있게 해주지만, shared_ptr
와는 다르게 참조 갯수를 늘리지는 않는다.
따라서 어떤 객체를 weak_ptr
이 가리키고 있다 하더라도, 다른 shared_ptr
들이 가리키고 있지 않다면 이미 메모리에서 소멸되었을 것이다.
이 떄문에 weak_ptr
자체로는 원래 객체를 참조할 수 없고, 반드시 shared_ptr
로 변환해서 사용해야 한다. 만약 이미 소멸된 객체를 가리키고 있다면 빈 shared_ptr
로 변환되고, 아닐 경우 해당 객체를 가리키는 shared_ptr
로 변환된다.
class A {
std::string s;
std::weak_ptr<A> other;
public:
A(const std::string& s) : s(s) { std::cout << "자원을 획득함!" << std::endl; }
~A() { std::cout << "소멸자 호출!" << std::endl; }
void set_other(std::weak_ptr<A> o) { other = o; }
void access_other() {
std::shared_ptr<A> o = other.lock();
if (o) {
std::cout << "접근 : " << o->name() << std::endl;
} else {
std::cout << "이미 소멸됨 ㅠ" << std::endl;
}
}
std::string name() { return s; }
};
int main() {
std::vector<std::shared_ptr<A>> vec;
vec.push_back(std::make_shared<A>("자원 1"));
vec.push_back(std::make_shared<A>("자원 2"));
vec[0]->set_other(vec[1]);
vec[1]->set_other(vec[0]);
// pa 와 pb 의 ref count 는 그대로다.
std::cout << "vec[0] ref count : " << vec[0].use_count() << std::endl;
std::cout << "vec[1] ref count : " << vec[1].use_count() << std::endl;
// weak_ptr 로 해당 객체 접근하기
vec[0]->access_other();
// 벡터 마지막 원소 제거 (vec[1] 소멸)
vec.pop_back();
vec[0]->access_other(); // 접근 실패!
}
[!result] 자원을 획득함! 자원을 획득함! vec[0] ref count : 1 vec[1] ref count : 1 접근 : 자원 2 소멸자 호출! 이미 소멸됨 ㅠ 소멸자 호출!
먼저 weak_ptr
을 정의하는 부분 보터 살펴보자
void set_other(std::weak_ptr<A> o) { other = o; }
vec[0]->set_other(vec[1]);
vec[1]->set_other(vec[0]);
weak_ptr
은 생성자로 shared_ptr
이나 다른 weak_ptr
을 받는다. 또한 shared_ptr
과는 다르게, 이미 제어 블록이 만들어진 객체만을 받을 수 있으므로 평범한 포인터 주소값으로는 weak_ptr
을 생성할 수 없다.
그 다음으로 weak_ptr
을 shared_ptr
로 변환하는 과정을 살표보자.
void access_other() {
std::shared_ptr<A> o = other.lock();
if (o) {
std::cout << "접근 : " << o->name() << std::endl;
} else {
std::cout << "이미 소멸됨 ㅠ" << std::endl;
}
}
앞서 말했듯이 weak_ptr
은 그 자체로는 원소를 참조할 수 없고, shraed_ptr
로 변환해야 한다. 이 작업은 lock 함수를 통해 수행된다.
weak_ptr
에 정의된 lock
함수는 만일 객체의 참조 갯수가 0이 아니라 살아있다면, 해당 객체를 가리키는 shared_ptr
을 반환, 이미 해제된 객체라면 아무것도 가리키지 않는 shared_ptr
을 반환한다.
std::shared_ptr<A> o = other.lock();
if (o) {
std::cout << "접근 : " << o->name() << std::endl;
}
아무것도 가리키지 않는 shared_ptr
은 if
문 내에서 false
로 형변환되므로 간단하게 확인할 수 있다.
앞서 제어 블록에는 몇개의 shared_ptr
이 가리키고 있는지를 나타내는 차모 갯수가 있다고 했다. 그렇다면 참조 갯수가 0이 될 때 객체와 더불어 제어 블록까지 즉시 해제해야 할까?
아니다. 만약 shared_ptr
이 가리키는 참조 갯수가 0개 이지만, 아직 weak_ptr
이 남아있다고 해보자. 물론 이 상태에서는 이미 객체는 해제 되어있겠지만, 제어 블록마저 해재해 버린다면 weak_ptr
은 해당 객체의 참조 갯수가 0이라는 사실을 알 수 없게 된다.
즉, 제어 블록을 메모리에서 해제하기 위해서는 이를 가리키는 weak_ptr
역시 0 개여야 한다. 따라서 제어 블록에는 참조 갯수와 더불어 약한 참조 갯수(weak count) 도 기록한다.
댓글 남기기