학교에서 DirectX 수업을 듣는데, DX는 고사하고 스마트 포인터도 모르는 나의 무지를 반성하며... 여러 자료를 참고하면서 스마트 포인터에 대해 기록을 해봤다.
스마트 포인터의 개요
C++은 JAVA, C# 등 여느 언어와는 다르게 GC(가비지 컬렉터)가 존재하지 않는다. 내가 Unity를 할 때 변수를 new로 생성해도 delete를 하지 않는 이유는 GC가 있기 때문이다.
따라서 new 키워드를 사용해 특정 메모리를 동적 생성했다면, Heap에 메모리가 올라갈 것이다.
자원의 사용을 끝냈을 때 반드시 delete라는 키워드를 사용해 쓴 메모리를 반납해야 한다.
그렇지 않으면, 해당 자원은 프로그램이 끝나기 전까지 영원히 남아있게 되고, 따라서 메모리 누수가 발생하게 된다. 메모리 누수가 발생하면, 이런 저런 버그가 생기거나 아예 프로그램이 뻗을 수도 있다.
어떤 자원을 항상 해제하도록 만드는 방법은 해당 자원을 가리키는 객체를 만드는 것이다.
객체
- 자원을 가짐
- 객체의 소멸자에서 자원을 해제
이렇게 객체를 만든다면, 해당 객체는 블록이 끝나면 자동으로 호출되는 소멸자에 의해 소멸되고 가지고 있는 객체 또한 자동으로 해제될 것이다. 이 패턴은 RAII의 자원 관리는 스택에 할당된 객체를 통해서 수행한다는 디자인 패턴에서 기반되었다고 한다.
https://blog.seulgi.kim/2014/01/raii.html
이 블로그가 RAII에 대해 잘 설명해주고 있다. 까먹었을 때 보자.
아무튼, C++에서 만들어준 그 객체가 바로 스마트 포인터라는 것!
unique_ptr
int main()
{
Class* c1 = new Class();
Class* c2 = c1;
delete c1;
delete c2; // 에러 발생!
}
이 코드는 에러가 날 수 밖에 없다.
같은 객체를 가리키고 있는 두 포인터가 있는데, 한 포인터가 해당 객체를 삭제해주었다면, 다른 포인터 또한 이미 소멸된 객체를 가지고 있다. 그런데, 그때 이미 소멸된 객체를 한 번 더 소멸해주니까 오류가 날 수 밖에 없다. (double free)
이는 객체의 소유권이 명확하지 않아서 생긴 문제이지 않을까?
unique_ptr은 특정 객체에 유일무이한 소유권을 부여해서 이러한 문제를 막는다.
class C
{
public:
C() { cout << "생성"; }
~C() { cout << "소멸"; }
};
이런 클래스가 있다고 생각해보자.
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
unique_ptr<C> cptr2(c);
}
이러한 코드는 에디터에서 컴파일시 오류가 나지 않는다.
그런데 생각을 해보면, unique_ptr 객체에서 c라는 포인터를 가지고 있다. 함수가 끝나면, cptr1과 cptr2는 각각 소멸자를 통해 c하는 자원을 자동으로 해제할 것이다.
아까 봤던 것처럼 delete를 두 번 한 것과 같은 셈이다! double free!
결과는 소멸자가 두 번 실행이 되며 런타임 에러가 발생한다.
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
unique_ptr<C> cptr2 = cptr1;
}
이러한 코드는 cptr2 선언 줄에 빨간 줄이 그어지게 된다. 이는 C++에서 복사 생성자를 지웠기 때문이다. 작년 C++ 시간에 했던 것 같은데 기억이 나지 않는다... 돌이켜보자면...
class Class
{
public:
Class(int num);
Class(const Class& c) = delete;
}
이렇게 복사 생성자를 지우게 되면...
int main()
{
Class c(1);
Class c2(c); // 에러
}
해당 생성자를 쓰는 게 불가능하다. 지워졌기 때문에...
이렇듯 unique_ptr 객체에 유일한 소유권을 가진다.
하지만, unique_ptr은 객체를 공동 소유할 수는 없지만, 소유권을 양도할 수는 있다.
헌데 나는 이게 어떨 때에 쓰이는지는 잘 모르겠다. 프로그램 코드에서 ptr의 소유권을 양도할 일이 있을까...?
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
unique_ptr<C> cptr2 = move(cptr1);
}
std::move를 써서 cptr2에 할당해주면, 컴파일시 오류도 나지 않고 런타임 에러 또한 나지 않게 된다.
이때, 소유권을 이전한 (현재 소유권이 없는) 포인터를 댕글링 포인터라고 한다. 댕글링 포인터는 절대 다시 참조하지 않겠다는 전제 하에 소유권을 이전해야 안전하게 프로그램이 돌아갈 수 있다고 한다.
귀엽다... 댕글댕글댕글링
일단, 복사 생성자를 지운 데에서 의문이 들면 정상이다. 만약 unique_ptr<T>을 함수 인자로 사용하는 경우, 값에 의한 복사가 진행되기 때문에 에러가 날 수밖에 없다. 복사 생성자부터가 없기 때문이다.
void func(unique_ptr<C> sdf) {}
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
func(cptr1); // 에러!!
}
그렇다면 참조 형식으로 인자를 받는 건 어떨까?
void func(unique_ptr<C>& sdf) {}
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
func(cptr1); // 오류가 나지 않는다!
}
오류가 나지 않는다! 잘 해결된 것 같지만...
다시 unique_ptr의 정의를 살펴보자. 객체의 유일무이한 소유권을 가지는 포인터이다. 하지만, 참조 형식으로 unique_ptr을 전달하게 된다면 유일한 소유권이 아니게 되는 것이다! 물론, 함수가 끝나도 객체가 소멸되진 않겠지만 아무튼 '유일한 소유권'에는 위배되는 코드라고 생각한다.
이럴 때는 그냥 unique_ptr이 가지고 있는 객체의 포인터을 전달하면 된다고 한다!
void func(C* c) {}
int main()
{
C* c = new C();
unique_ptr<C> cptr1(c);
func(cptr1.get());
}
get()을 쓰면 해당 객체의 주소값이 반환된다. 이렇게 주소값만 인자로 넣어준다면, 유일한 소유권도 지킬 수 있고, 함수에서 객체도 접근할 수 있다!
#include<bits/stdc++.h>
using namespace std;
class C
{
public:
C(int a, float b) { cout << "생성"; }
~C() { cout << "소멸"; }
};
int main()
{
unique_ptr<C> cptr1 = make_unique<C>(3, 2.f);
}
항상 객체의 포인터를 만들어서, new로 할당해주기 귀찮으니 C++에서 지원해주는 make_unique를 쓰자!!
또, 객체를 만들어서 할당하면 두 개의 unique_ptr의 생성자에 객체를 넣어주는 오류를 범할 수 있지만, make_unique를 쓰면 그럴 일은 없을 것이다. 이게 make_unique의 이점이라고 혼자 생각하긴 했는데... 아닐 수도 있다.
unique_ptr은 복사 생성자가 없기 때문에, stl에 써줄 때에도 주의하는 게 좋다.
만약 vector 컨테이너에 그냥 unique_ptr을 넣어줬다면...
int main()
{
vector<unique_ptr<C>> ptrs;
unique_ptr<C> cptr1 = make_unique<C>(3, 2.f);
ptrs.push_back(cptr1);
}
vector의 push_back 함수는 전달된 인자를 복사해서 넣기 때문에 오류가 발생할 수밖에 없다. 아까 인자로 넣어줬을 때 오류가 난 이유와 마찬가지이다.
해결 방법은 바로 emplace_back을 사용하는 것이다.
emplacec_back은 쉽게 말해 삽입할 객체를 받는 것이 아닌, 삽입할 객체의 생성자의 매개변수들을 받는 함수이다.
pair를 넣을 때밖에 쓰지 않았는데, 생성자의 매개변수를 받는 함수인지 몰랐던 나 반성해... vector 내에서 직접 객체를 생성하여 삽입한다. 따라서 push_back과 다르게 임시 객체의 복사, 생성, 소멸이 적어 성능상 더 유리한 함수라고 한다.
아무튼 emplace_back을 통해 복사가 아닌 생성을 하니 오류가 나지 않는 것 같다.
int main()
{
vector<unique_ptr<C>> ptrs;
ptrs.emplace_back(new C(1, 1));
}
shared_ptr
shared_ptr은 이름에서부터 느낄 수 있듯이, unique와는 반대라고도 말할 수 있다. 여러 개의 shared_ptr이 한 객체를 가리킬 수 있기 때문이다.
그렇다면 어떻게 해당 객체의 자원이 소멸될 수 있을까?
바로 참조 개수를 통해서이다.
참조 개수는 이름에서도 알 수 있듯이, 해당 객체를 가리키는 shared_ptr의 개수를 말한다. 위 그림은 참조 개수가 3이다.
참조 개수가 0개가 되면, 자동으로 객체를 해제하는 아주 똑똑이 포인터이다. C#이나 다른 GC가 있는 언어들도 참조 개수가 0이 되었음을 이용해서 객체를 소멸할까? 나중에 자세히 알아봐야겠다.
int main()
{
vector<shared_ptr<C>> vec;
// 여러 shared_ptr에서 객체 주소를 가지고 있을 수 있다!
shared_ptr<C> ptr = make_shared<C>();
cout << ptr.use_count() << " > ";
vec.push_back(ptr);
cout << ptr.use_count() << " > ";
vec.push_back(vec.front());
cout << ptr.use_count() << " > ";
}
실행하면 참조 카운트가 1, 2, 3으로 올라가는 것을 볼 수 있다.
vector에 있는 요소를 삭제하면 참조 카운트가 줄어들까?
int main()
{
vector<shared_ptr<C>> vec;
shared_ptr<C> ptr = make_shared<C>();
vec.push_back(ptr);
vec.push_back(vec.front());
vec.pop_back();
cout << ptr.use_count() << " > ";
vec.pop_back();
cout << ptr.use_count() << " > ";
}
줄어든다!!
0이 되지 않는 이유는 ptr이 아직 객체의 주소를 가리키고 있기 때문이다. main문이 종료되며 스택 메모리에 있는 ptr의 소멸자가 불러지면, 그때 비로소야 객체가 소멸되는 것이다.
그런데, shared_ptr이라면서 공유가 안 돼서 이상했던 적이 있다.
int main()
{
C* c;
shared_ptr<C> ptr1(c);
shared_ptr<C> ptr2(c);
}
int main()
{
C* c = new C();
shared_ptr<C> ptr1(c);
shared_ptr<C> ptr2(ptr1.get());
}
내 경우이지만... 두 코드 다 에러가 나서 shared_ptr에 대해서 혼란이 온 적 있다. 분명 같은 객체를 소유할 수 있다고 했는데!!
문제 상황을 인터넷에 찾아봤고, shared_ptr이 참조 카운트를 어떻게 세는지를 보면 왜 저 코드가 에러가 나는지 알 수 있었다.
처음으로 어떠한 객체를 가리키는 shared_ptr은 제어 블록이라는 것을 갖게 된다고 한다. 제어 블록은 해당 객체의 참조 카운트를 가지고 공유하는 역할을 한다. 이 제어 블록은 두 개 이상이 있어서는 안 된다. 그렇게 되면 참조 카운트가 정확해지지 않기 시작하며 위 런타임 에러처럼 더블 프리가 걸릴 수도 있기 때문이다.
제어 블록에 대한 자세한 설명. 나중에 제어블록에 대해 까먹었을 때 이 블로그를 보자. https://pppgod.tistory.com/40
이는 위 코드가 실행되지 않는 것을 잘 설명해준다. shared_ptr에 객체의 주소를 직접 넣어주었으니, 제어 블록이 두 개가 생성되면서 소멸할 때 오류가 나는 것이다.
그래서 shared_ptr을 쓸 때, 위처럼 해당 객체의 주소값을 할당하는 것에 주의를 해야 한다.
하지만, 인생이 늘 그렇듯객체의 주소값을 할당해야만 하는 상황이 생길 수도 있다. 바로 this 포인터를 가리키는 shared_ptr을 반환할 때이다.
소멸자가 2번 생성이 된다.
함수에서 제어 블록이 있는지, 없는지 모르니 일단 제어 블록을 생성해주기 때문이다. 그러면 소멸자가 2번 호출이 되는 더블 프리... 생각해보면 스마트 포인터의 만악의 근원은 double free인 것 같다.
해결 방법은 당연히 있다.
shared_ptr을 반환하는 클래스는
class C : public enable_shared_from_this<C>
{
shared_ptr<C> getsptr()
{
return shared_from_this();
}
}
해당 클래스는 enable_shared_from_this를 상속받고, 그 부모의 함수인 shared_from_this를 반환해주면 깔끔하게 해결이 된다.
int main()
{
C* c = new C();
shared_ptr<C> ptr1(c);
shared_ptr<C> ptr2 = c->getsptr();
shared_ptr<C> ptr3 = ptr2->getsptr();
}
그러면 main에서 이렇게 난잡하게 써도...
소멸자가 한 번만 출력이 된다!
DirectX 수업을 들을 땐 왜 상속을 받는지 이해가 가지 않았었는데, 혼자 천천히 공부하니 이해가 되어서 기분이 좋다 ㅎ_ㅎ
shared_ptr은 편해 보이지만 (정말 편함) 단점이 없지는 않다.바로 순환 참조 문제를 불러온다는 것이다.
본디 shared_ptr의 참조 카운트가 0이 되면 객체가 소멸된다. 하지만, shared_ptr 두 개가 서로서로를 가리키고 있다면 어떻게 될까? 프로그램이 종료될 때까지 참조 카운트는 2가 되며 객체가 소멸이 안 되지 않을까?
사실 난 이런 문제를 생각하지도 못했는데... 사람들은 참 대단한 것 같다.
일단 순환 참조를 발생 시켜보자.
#include<bits/stdc++.h>
using namespace std;
class C
{
public:
shared_ptr<C> sPtr;
C() { cout << "생성"; }
~C() { cout << "소멸"; }
};
int main()
{
shared_ptr<C> ptr1 = make_shared<C>();
shared_ptr<C> ptr2 = make_shared<C>();
ptr1->sPtr = ptr2;
ptr2->sPtr = ptr1;
cout << ptr1.use_count();
cout << ptr2.use_count();
cout << ptr1->sPtr.use_count();
cout << ptr2->sPtr.use_count();
}
출력과 같이 생성만 되고 소멸은 되지 않는다.
이해가 잘 되지 않아 참조를 화살표로 그려봤다.
멤버 변수인 sPtr을 가리키는 화살표만 그렸지만, 객체 C를 가리키는 대상 또한 2개라는 것을 알 수 있다. 이는 shared_ptr의 고질적인 문제이며 shared_ptr로는 해결할 수 없다고 한다.
이 문제를 해결하기 위해 나온 우리의 마지막 스마트 포인터가 weak_ptr이다.
weak_ptr
weak_ptr은 그 이름대로 약한 포인터이다.
스마트 포인터와 같이 안전하게 객체를 참조할 수는 있지만, shared_ptr처럼 참조의 개수는 늘릴 수 없다. 따라서 shared_ptr이 객체를 가리키고 있지 않으면, weak_ptr 또한 객체를 가리킬 수 없다는 말이다.
int main()
{
shared_ptr<C> ptr1 = make_shared<C>();
weak_ptr<C> ptr2(ptr1);
weak_ptr<C> ptr3(ptr2);
weak_ptr<C> ptr4(new C()) // 에러
}
이런 의미와 상응하게 weak_ptr의 생성자는 shared_ptr이나 또다른 weak_ptr만을 받는다고 한다. 이유를 생각해봤는데, shared_ptr이 있어야만하는 포인터라는 말인 즉슨 제어 블록이 필요하다는 뜻인 것 같다. 왜냐하면 제어 블록의 참조 카운트가 0일 때는 weak_ptr이 가리키는 곳이 비어있어야 하기 때문이다.
weak_ptr이 객체 자체를 가리킬 수 없다는 것은 잘 알겠다. 그런데, 접근할 수 없다면 의미가 있나... 싶던 와중에
weak_ptr을 shared_ptr로 변환해서 사용해야 한다고 한다. 이는 lock 함수를 이용한다.
lock => weak_ptr이 가리키는 객체가 소멸되지 않았다면 shared_ptr 반환, 소멸되었다면 빈 shared_ptr을 반환한다.
lock 함수를 이용해 weak_ptr을 shared_ptr로 변환해봤다.
int main()
{
shared_ptr<C> ptr1 = make_shared<C>();
weak_ptr<C> ptr2(ptr1);
shared_ptr<C> temp = ptr2.lock();
if (temp)
{
temp->hello();
}
else
{
cout << "error";
}
}
접근이 잘 된다!!
만약 shared_ptr로 변환하기 전 해당 객체를 소멸시켜버리면 어떨까?
int main()
{
shared_ptr<C> ptr1 = make_shared<C>();
weak_ptr<C> ptr2(ptr1);
ptr1.reset();
shared_ptr<C> temp = ptr2.lock();
if (temp)
{
temp->hello();
}
else
{
cout << "error";
}
}
shared_ptr이 빈 값을 가리키고 있어 error가 잘 나오는 것을 확인할 수 있다.
아까 shared_ptr의 문제였던 순환 참조를 weak_ptr로 고쳐보자.
#include<bits/stdc++.h>
using namespace std;
class C
{
public:
weak_ptr<C> sPtr;
C() { cout << "생성"; }
~C() { cout << "소멸"; }
void hello() { cout << "hello!"; };
};
int main()
{
shared_ptr<C> ptr1 = make_shared<C>();
shared_ptr<C> ptr2 = make_shared<C>();
ptr1->sPtr = ptr2;
ptr2->sPtr = ptr1;
cout << ptr1.use_count();
cout << ptr2.use_count();
cout << ptr1->sPtr.use_count();
cout << ptr2->sPtr.use_count();
}
클래스의 멤버 함수를 weak_ptr로 고쳤다.
아까는 소멸이 적용되지 않고 참조 카운트가 2였지만, weak_ptr을 참조 카운트로 계산하지 않고 소멸 또한 제대로 되는 걸 확인할 수 있다.
shared_ptr로 변환하고 사용할 때 Null 체크를 잘해주면 shared_ptr과 같이 메모리를 잘 관리할 수 있을 것 같다.
스마트 포인터에서 정말 많은 정보를 한꺼번에 알게 되었다만, 아는 것과 사용할 줄 아는 것은 천지차이라고 생각한다. 이번 C++ WinAPI 팀 프로젝트에서 게임을 만들 때 원시 포인터 (우가우가....) 말고도 스마트 포인터를 사용해봐야겠다.
또, 다이렉트 시간에 쓰인 shared_ptr, weak_ptr도 어떻게 쓰였는지 봐야겠다.
참고 https://modoocode.com/229, https://modoocode.com/252, ttps://pppgod.tistory.com/40