스마트 포인터(smart pointer)
왜 필요한가?
c++의 양날의 검이였던 포인터. 직접적으로 메모리를 읽거나 수정할수 있는 큰 장점 엉뚱한 메모리에도 접근할 수 있다는 치명적인 단점. 메모리 오염은 너무나 큰 일이다.
가장 큰 문제: 댕글링 포인터
또 메모리를 할당한 후 해제를 안하면 메모리 부족현상이 나타나고 소유권을 이전한 상태로 해제를 안하면 누수가 일어나는 문제가 있었다. 그래서 Unmanaged인 C++에 C++11부터 스마트포인터가 도입되었다. memory 헤더파일에 있어
#include <memory> 를 해줘야한다.
주시대상이 있는(예-pvp게임) Knight클래스가 있다고 해보자
class Knight
{
public:
Knight() { cout<<"Knight 생성"<<endl;}
~Knight() { cout<<"Knight 소멸"<<endl;}
void Attack()
{
if(_target)
{
target->_hp -= damage;
cout<<"HP"<< _target->_hp<<endl;
}
}
public:
int _hp = 100;
int _damage = 10;
Knight* _target = nullptr; // 주시 대상
};
int main()
{
Knight* k1 = new Knight();
Knight* k2 = new Knight();
k1->_target = k2; //k1이 k2를 주시하다가 공격할 것이다.
k1->Attack();
}
실제론 이렇게 모두 main함수에서 사용된는게 아니라 Knight를 관리하는 매니저에서 사용될 것이다.
그런데 k2가 접속 종료를 했다고 가정하면 k2객체를 소멸해도 k1이 가지고 있던 _target이 가리키는 것은 nullptr로 바뀌진 않는다. 크래쉬가 일어나진 않고 그냥 메모리가 날아간 상태인 것이다.
댕글링포인터의 문제. 만약 _target이 가리키는 메모리가 사용중이었다면..? 그 데이터가 중요한 값이고 수정이 된다면?!
따라서 만약 삭제를 하게되면 k2를 가리키고있는 애들을 모두 찾아서 nullptr로 밀어주던가 아니면 k2를 가리키고 있는야들이 없어지기 전까지는 delete k2;를 못하도록 해야하는데...포인터를 이용해 직접적으로 동적할당하는 방식으로는 이를 해결할 수 없다. 실제로 현대에서 동적할당을 하는 방식을 이용하지도 않는다. 반드시 간접적으로 사용하는게 일반적이다. 언리얼도 그렇고 효율성보다는 안정성을 추구하는게 우선이다.
그렇담 스마트포인터란 무엇인가?
포인터를 알맞는 정책에 따라 관리하는 객체 (포인터를 래핑해서 사용). 포인터를 곧이 곧대로 사용하는게 아니라 다른 클래스를 만들어 Knight를 삭제할지 말지 정책에 따라 결정하는 방법이다.
shared_ptr, weak_ptr, unique_ptr방식이 있다.
shared_ptr
shared_ptr은 포인터를 관리하는데 레퍼런스카운트(참조카운트)도 함께 관리하는 방식이다. k2를 가리키는 포인터가 몇개인지 계속해서 추적하는것..!
// shared_ptr
// 관리하는 포인터를 아무도 기억하지 않을 때 비로소 delete한다.
// 참조 카운트를 관리하는 것은 일반적으로 따로 빼서 공용 메모리로 관리하는게 일반적인다.
class RefCountBlock
{
public:
int _refCount = 1; //0이되면 날려도된다 아님 삭제 절대 안되도록
}
template<typename T>
class SharedPtr
{
public:
SharePtr(){}
SharePtr(T* ptr) : _ptr(ptr)
{
if(_ptr != nullptr)
{
_block= new RefCountBlock();
cout<<"RefCount : "<<_block->_refCount<<endl;
}
}
SharedPtr(const SharedPtr& sptr) : _ptr(sptr), _block(sptr._block) // 복사생성자
{
if(_ptr != nullptr)
{
_block->_refCount++;
cout<<"RefCount : "<<_block->_refCount<<endl;
}
}
void operator=(const SharedPtr& sptr) // 복사대입연산자
{
_ptr(sptr);
_block(sptr._block);
if(_ptr != nullptr)
{
_block->_refCount++;
cout<<"RefCount : "<<_block->_refCount<<endl;
}
}
//소멸되려고 소멸자가 호출되었는데 관리하는 포인터가
//nullptr이 아니면 개수를 줄여준다. 0 dlaus
~SharedPtr()
{
if(_ptr != nullptr)
{
_block->_refCount--;
cout<<"RefCount : "<<_block->_refCount<<endl;
if(_block->_refCount == 0)
{
delete _ptr;
delete _block;
cout<<"Delete Data"<<endl;
}
}
}
public:
T* ptr = nullptr; // 관리하는 포인터
RefCountBlock* _block = nullptr;//
}
int main()
{
SharedPtr<Knight> k2;
{
SharedPtr<Knight> k1(new Knight()); // Knight*대신 SharePtr<Knight>를 사용해서 Knight를 관리하는 주체가 된다.
k2 = k1; // 복사 대입연산자
// k1과 k2가 같은 객체를 참조하고 있어서 스코프 밖에서 k1이 날라가도 k2에 의해 당장은 삭제하지 않을것이다.
}
return 0;
}
스마트 포인터를 사용하는 순간 명시적으로 포인터를 삭제하지 않아도 되고 내부적으로 특정 조건을 만족하면(아무도 기억하지 않을 때) 자동으로 삭제된다. 메모리 관리로부터 자유로워졌어..!
참고로 레퍼런스카운트는 클래스 안에 두지 않고 공용메모리에 따로 빼서 관리하므로 아래와 같은 형태를 갖게된다.
즉, 쉐어드포인터로 객체를 하나 만드렴 객체 원본과 카운팅 블록이 함께 생성되고 복사될 경우 같은 객체와 카운팅 블록을 가리킨다.
shared_ptr<MyClass> p1 = make_shared<MyClass>(); // 다이어그램1
shared_ptr<MyClass> p2 = p1; // 다이어그램2
shared_ptr은 위와같은 방식으로 이루어져있고 실제로 사용해보자.
class Knight
{
public:
Knight() { cout<<"Knight 생성"<<endl;}
~Knight() { cout<<"Knight 소멸"<<endl;}
void Attack()
{
if(_target)
{
_target->_hp -= damage;
cout<<"HP"<< _target->_hp<<endl;
}
}
public:
int _hp = 100;
int _damage = 10;
shared_ptr* _target = nullptr; // 주시 대상을 스마트포인터가 관리하도록 바꿨다.
};
int main()
{
// make_shared로 생성하는게 성능이 더 좋다. Knight와 메모리 블록을 함께 만들어 같은 메모리에 넣어서 조금 더 빨리 동작한다.
shared_ptr<Knight> k1 = make_shared<Knight>();
{
shared_ptr<Knight> k2 = make_shared<Knight>();
k1->_target = k2; //k2를 스마트포인터로 주시하는 상태가 된다.
}
k1->Attack(); // 별다른 문제 없이 k2의 _hp가 깎이게 된다.
return 0;
}
이러면 k2는 스코프내에서만 유효함에도 k2를 기억하고 있는게 있으니깐(k1._target) k2객체는 당장은 삭제되지 않는다.
이미 날라간 메모리를 참조하는 댕글링 포인터의 문제, use after free의 문제로부터 어느정도 자유롭게된다.
서버쪽에서 멀티스레드는 더 복잡하므로 자주 사용한다. 그러나 shared_ptr이 레퍼런스 카운트를 사용해 아무도 기억하지 않을 때 자신을 소멸시킨다는 장점이 있지만 단독으로 사용할 때 전통적인 싸이클 문제(순환참조)를 해결할 수는 없다. 이를 weak_ptr이 해결하나 아래의 예를 참조하자.
weak_ptr
int main()
{
shared_ptr<Knight> k1 = make_shared<Knight>();
{
shared_ptr<Knight> k2 = make_shared<Knight>();
k1->_target = k2;
k2->_target = k1;
}
k1->Attack(); // 댕글링포인터는 아니나 사이클이 생겨 k1과 k2 둘 다 절대 소멸하지 않는다.
// 심지어 main()이 끝나도 서로를 가리키는 레퍼런스카운터는 줄어들지 않아 메모리 자체는 삭제되지 않고
// k1,k2 포인터만 사라져 메모리를 할당 해지할 방법이 없어진다.
return 0;
}
스코프내에서 k1과 k2의 _refCount는 둘 다 2가 된다. (자기자신과 서로)그리고 스코프 밖으로 나가면서 k2의 _refCount가 1이 되지만(k2 자신의 소멸자가 호출되어서) 여기서 끝나고 둘 다 절대 소멸되지 않는다.
이러면 메모리가 절대 삭제되지 않으니 메인함수가 끝나고 프로그램이 끝날 때까지 절대 소멸되지 않는다. (이 문제는 스코프 밖에있어서도 마찬가지다.) 따라서 shared_ptr만 단독으로 사용하면 싸이클 문제를 조심해야하고 정 사용한다하면 k1->_target = nullptr; k2->_target = nullptr;로 밀어서 순환구조를 끊어줘야한다.
이를 해결할수 잇는 또 다른 방법이 weak_ptr이다. 즉,순환구조가 일어날 수 있는 부분을 weak_ptr로 바꿔보자.
class Knight
{
public:
Knight() { cout<<"Knight 생성"<<endl;}
~Knight() { cout<<"Knight 소멸"<<endl;}
void Attack()
{
if(_target.expired() == false) // weak_ptr로 없어졌는지 간접적으로 확인하고
{
shared_ptr<Knight> sptr = _target.lock(); // 실제로 사용할 땐 shared_ptr을 사용한다.lock이 shared_ptr반환
sptr->_hp -= damage;
cout<<"HP"<< _target->_hp<<endl;
}
}
public:
int _hp = 100;
int _damage = 10;
weak_ptr<Knight> _target; // 순환구조가 일어날 수 있는 부분을 weak_ptr로 관리하도록 바꿨다.
};
int main()
{
shared_ptr<Knight> k1 = make_shared<Knight>();
{
shared_ptr<Knight> k2 = make_shared<Knight>();
// weak_ptr이 자원에 대한 참조를 받으려면 shared_ptr이나 다른 weak_ptr의 자원을 복사 생성자나 대입 연산자를 통해서 할당 받을수있다.
k1->_target = k2;
k2->_target = k1;
}
k1->Attack();
return 0;
}
weak_ptr은 shared_ptr로 설명하자면 shared_ptr은 포인터과 블록(레퍼런스카운트)을 같이 관리하고 있엇는데
weak_ptr과 shared_ptr을 같이 사용하게 되면 RefCountBlock에 아래와 같이 _weakCount를 추가해서 사용하는 것과 같다.
class RefCountBlock
{
public:
int _refCount = 1;
int _weakCount = 1;
}
_refCount는 이 객체를 참조하는 애가 몇개인지를 관리하고 _weakCount는 weak_ptr 몇개가 이 객체를 참조하는지 분리해서 관리한다. 이를 이용해 기존엔 _refCount가 0이되면 _block까지 날려버렸는데 _block은 살려둬서 weak_ptr을 이용해 해당 메모리가 날라갔는지 확인하는 용도로 사용할 수 있다.
즉, shared_ptr처럼 객체의 생명주기에 직접 관여하진 않지만 그 객체가 날라갔는지 아닌지 간접적으로 확인하는 용도이다.
장점: 생명주기에선 자유롭게된다. weak_ptr인 _target은 생명주기엔 직접적인 영향을 주기 않기 때문에 순환구조가 일어날 수 없다.
단점: 사용하기 위해선 _target.expired()처럼 명시적으로 확인한 다음에 다시 shared_ptr로 바꿔서 사용하는 약간의 번거로움이 생긴다.
다시 main함수를 보면 _target이 weak_ptr로 잡혀있기 때문에 _refCount에는 영향을 주지 않아 굳이 nullptr로 밀어버리지 않아도 모두 소멸이 된다. 또 Attack에서 expired()로 확인이 되어서( shared_ptr의 참조 카운트가 0이 되어 객체가 해제 된 상태 확인) 댕글링포인터의 문제도 해결할 수 있게되었다.
이렇게 스마트포인터는 포인터의 생명주기를 자동으로 관리하는 것으로 필요에 따라 shared_ptr만 쓰거나 weak_ptr과 함께 쓸 수 있다.
unique_ptr
unique_ptr<Knight> uptr = make_unique<Knight>();
//unique_ptr<Knight> uptr2 = uptr; // Error
unique_ptr<Knight> uptr2 = std::move(uptr); // 모든 권리를 uptr2에 이양하고 uptr은 사용하지 않는다
전세계적으로 나만 참고하고 있어야할때 사용한다. 넘겨주는것 불가하다.
넘겨주려면 오른값으로 이동론을 사용해 uptr은 앞으로 유효하지 않고 uptr2한테 모든 권리를 이양한다고 하면 가능하하다. 일반적인 복사의 형태가 막힌 포인터. 이동만 가능한 포인터. 일반 포인터처럼 사용할 수 있다.
'[C++]' 카테고리의 다른 글
[C++ 20] Module (1) | 2024.11.13 |
---|---|
CHAR/ TCHAR/ WCHAR (0) | 2022.04.21 |
STL #1 Vector (0) | 2021.08.24 |
포인터 1 - 포인터, 참조 기초 (0) | 2021.08.24 |