https://www.inflearn.com/course/%EC%96%B8%EB%A6%AC%EC%96%BC-3d-mmorpg-1/dashboard
[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part1: C++ 프로그래밍 입문 강의 | Rookiss - 인프런
Rookiss | , MMORPG 개발에 필요한 모든 기술, C++ & Unreal로 Step By Step! 🕹️ 기초부터 끝판왕까지, MMORPG 개발하기 🎮 [사진] 포트폴리오 완성을 목표로 하는 게임 프로그래머 취업 준비생, C++과
www.inflearn.com
참고 강의
1. 개요
오늘은 C++의 스마트 포인터에 대해 알아보도록 합시다.
그전에 스마트 포인터는 왜 써야하는 것일까요?
그것은 바로 일반 포인터의 문제점들 때문입니다.
일반 포인터의 문제점
- 메모리 누수(Memory Leak): 할당된 메모리를 해제하지 않음
- 댕글링 포인터(Dangling Pointer): 이미 해제된 메모리를 가리킴
- 이중 해제(Double Free): 동일한 메모리를 두 번 해제함
이러한 문제들은 개발을 하며 정말 비일비제하게 발생하고, 프로그래머가 완벽하게 대응하는 것도 굉장히
힘든 부분중에 하나입니다.
그렇다면 스마트 포인터의 장점이 이러한 부분들을 보완해주는 것이겠죠?
스마트 포인터의 장점
- 자동 메모리 관리 RAII( Resource Acquisition Is Initialization ) 원칙 적용
객체의 생성자에서 자원을 획득하고 소멸자에서 자원을 해제하는 방식 - 소유권 명확화
- 예외 발생 시에도 안전한 자원 해제
- 코드 가독성 및 유지보수성 향상
그럼 지금부터 스마트 포인터들에 대해 알아보겠습니다.
2. shared_ptr
2-1. shared_ptr의 대해서
shared_ptr는 내부적으로 참조 카운팅을 하고있습니다.
어떠한 객체가 있을때 이 객체를 참조하는 포인터들의 수를 카운팅하다가
카운트가 0이 되었을때는 자동으로 메모리를 해제해줍니다.
이렇게 된다면 일반 포인터에서 가졌던 메모리 누수, 댕글링 포인터, 이중 해제와 같은 문제들을 예방할 수 있습니다.
#include <iostream>
using namespace std;
class RefCountBlock
{
public:
int _refCount = 1;
};
template<typename T>
class SharedPtr
{
public:
SharedPtr(){}
SharedPtr(T* ptr) : _ptr(ptr)
{
if (_ptr != nullptr)
{
_block = new RefCountBlock();
cout << "RefCount" << _block->_refCount << endl;
}
}
SharedPtr(const SharedPtr& sptr) : _ptr(sptr._ptr) , _block(sptr._block)
{
if (_ptr != nullptr)
{
_block->_refCount++;
cout << "RefCount" << _block->_refCount << endl;
}
}
void operator=(const SharedPtr& sptr)
{
_ptr = sptr._ptr;
_block = sptr._block;
if (_ptr != nullptr)
{
_block->_refCount++;
cout << "RefCount" << _block->_refCount << endl;
}
}
~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;
RefCountBlock* _block = nullptr;
};
class Knight
{
};
int main()
{
SharedPtr<Knight> k2;
{
SharedPtr<Knight> k1(new Knight());
k2 = k1;
}
}
이 코드는 shared_ptr의 원리 이해를 위해 구현해본 예제입니다.
reference count를 체크하고 0이 될경우 delete를 통해 메모리를 해제해주고 있습니다.
복사함수들이 실행될때 reference count를 하나씩 증가시켜 줍니다.
k1포인터는 생성된 Knight A객체를 가리키고 있습니다. (refcount1)
그리고 k2 = k1에서
Knight A객체(k1가 가리키는중)의 refcount는 2로 변합니다.
왜냐하면 이제 k2도 A객체를 가리키고 있거든요
그리고 블럭을 나오게 되면 k1이 소멸되며 refcount는 1로 변하게 됩니다.
그러나 k2가 주시중이기에 바로 삭제되지는 않습니다.
마지막으로 main에서 벗어날때 k2가 소멸되며 refcount가 0이되고 최종적으로 메모리가 해제됩니다.
2-2. shared_ptr의 고질적인 문제
그 문제는 바로 서로가 서로를 참조할때 생기는 문제입니다. (사이클 문제)
우선 우리가 만든 SharedPtr은 잠시 넣어두고 shared_ptr을 사용하도록 하겠습니다.
또한 이제 new를 쓰지 않고 make_shared를 쓰겠습니다.
shared_ptr<Knight> k1(new Knight())
shared_ptr<Knight> k1 = make_shared<Knight>()
아래 방식을 사용하는 것이 좋은 이유
- 메모리 할당 횟수 감소:
- new Knight()를 사용하는 방식: 2번의 메모리 할당이 필요합니다.
- Knight 객체를 위한 메모리 할당
- shared_ptr의 제어 블록(참조 카운트 등)을 위한 메모리 할당
- make_shared를 사용하는 방식: 단 1번의 메모리 할당으로 객체와 제어 블록을 모두 생성합니다.
- new Knight()를 사용하는 방식: 2번의 메모리 할당이 필요합니다.
- 메모리 효율성:
- 한 번의 할당으로 Knight 객체와 제어 블록이 메모리상 연속된 공간에 배치됩니다.
- 메모리 지역성(locality)이 좋아져 캐시 효율성이 향상됩니다.
그럼 계속해서 문제점 이야기하겠습니다.
int main()
{
shared_ptr<Knight> k1 = make_shared<Knight>();
{
shared_ptr<Knight> k2 = make_shared<Knight>();
k1->target = k2;
k2->target = k1;
}
}
이 코드를 하나씩 보겠습니다.
k1, k2모두 처음 만들어졌을땐 refcount가 1일 것입니다.
그리고 서로 주시중이기에 각각 1씩 증가됩니다.
그리고 블럭을 지나오며 k2의 카운트가 1줄어듭니다.
그러나 서로가 서로를 기억중이기에 절대로 서로가 삭제될 일이 없습니다.
끝나기전에 각각 target을 nullptr로 밀어주어야만 순환이 끊겨서 정상적으로 해결됩니다.
그러나 이렇게 매번 해주는것은 매우 힘들기 때문에 weak_ptr을 써서 해결하는 것이 일반적입니다.
3. weak_ptr
shared_ptr과 weak_ptr을 함께 사용할 때, 제어 블록(control block)은 두 종류의 카운터를 관리합니다:
여기서 제어 블록은 스마트 포인터를 쓸때 heap메모리에 자동으로 할당되는 공간입니다.
- 강한 참조 카운트(strong reference count): shared_ptr이 객체를 참조할 때 증가하는 카운트입니다.
- 약한 참조 카운트(weak reference count): weak_ptr이 객체를 참조할 때 증가하는 카운트입니다.
메모리 관리는 다음과 같이 작동합니다:
- 강한 참조 카운트가 0이 되면 객체(T*)는 삭제됩니다.
- 그러나 약한 참조 카운트가 여전히 0보다 크다면 제어 블록은 유지됩니다.
- 강한 참조 카운트와 약한 참조 카운트가 모두 0이 되면 제어 블록도 삭제됩니다.
또한 weak_ptr에는 중요한 2가지 함수가 있습니다.
expired()
expired() 메서드는 weak_ptr가 가리키는 객체가 이미 삭제되었는지 확인하는 함수입니다.
bool expired() const noexcept;
- 반환값:
- true: 객체가 이미 삭제되었음 (관련된 모든 shared_ptr이 소멸되어 강한 참조 카운트가 0이 됨)
- false: 객체가 아직 존재함 (최소 하나 이상의 shared_ptr이 객체를 가리키고 있음)
- 용도: 해당 객체가 아직 유효한지 안전하게 확인할 수 있습니다.
lock()
lock() 메서드는 weak_ptr가 가리키는 객체에 접근하기 위해 임시 shared_ptr을 생성하는 함수입니다.
shared_ptr<T> lock() const noexcept;
- 반환값:
- 유효한 shared_ptr: 객체가 아직 존재하는 경우, 해당 객체를 가리키는 shared_ptr 반환
- 빈 shared_ptr (nullptr): 객체가 이미 삭제된 경우
- 용도: 객체의 존재 여부를 확인하고 안전하게 접근하는 원자적 연산을 제공합니다.
weak_ptr은 삭제에 직접적으로 영향을주지는 않지만 순환문제를 해결할 수 있습니다.
그러나 위에서 언급한 expired와 lock함수를 사용해서 유효성을 항상 체크해주어야 하는 번거로움이 있습니다.
class Knight
{
public:
void Attack()
{
if (target.expired() == false)
{
shared_ptr<Knight> sptr = target.lock();
sptr->_hp -= _dam;
cout << "HP :" << sptr->_hp;
}
}
weak_ptr<Knight> target;
int _hp = 10;
int _dam = 1;
};
int main()
{
shared_ptr<Knight> k1 = make_shared<Knight>();
{
shared_ptr<Knight> k2 = make_shared<Knight>();
k1->target = k2;
k2->target = k1;
}
k1->Attack();
}
이렇게 하면 target의 유효성을 체크한뒤 계산해서 순환으로 발생하는 문제를 피하면서 정상적으로 동작시킬 수 있습니다.
이 예시에서는 k2객체는 이미 소멸했음으로 expired가 true를 반환하여 공격로직은 실행이 되지 않을 것입니다.
4. unique_ptr
오직 하나의 unique_ptr만이 객체를 소유할 수 있습니다.
그리고 복사가 금지되어 있어서 소유권 공유가 불가능 합니다.
그러나 move를 통해서 소유권을 전적으로 위임하는 것은 가능합니다.
int main()
{
unique_ptr<Knight> uptr = make_unique<Knight>();
//unique_ptr<Knight> uptr2 = uptr; 불가능
unique_ptr<Knight> uptr2 = move(uptr);
}
'C++' 카테고리의 다른 글
[C++] 스택 풀기(Stack Unwinding) (0) | 2025.04.19 |
---|---|
[C++] 전방 선언(Forward Declaration) vs 헤더 파일 include의 차이점과 사용하는 이유 + 순환 참조 (Circular reference) (2) | 2025.04.07 |
[C++] std::map, std::unordered_map (0) | 2025.04.03 |
[C++] const와 constexpr의 차이 (0) | 2025.04.03 |