개발하는 리프터 꽃게맨입니다.

[C++] 스마트 포인터 - 2 (shared_ptr, weak_ptr) 본문

언어/C, C++

[C++] 스마트 포인터 - 2 (shared_ptr, weak_ptr)

파워꽃게맨 2024. 1. 18. 12:50

[ 목차 ]

     

    이전 포스팅

    https://powerclabman.tistory.com/48#6._%EB%A7%88%EC%B9%98%EB%A9%B0

     

    [C++] 스마트 포인터 - 1 (unique_ptr, unique_ptr 역설계)

    [ 목차 ] 1. 동적 메모리 C++에서 동적 메모리는 new로 할당하고 delete로 해제합니다. new는 객체를 할당하고 초기화하여 그 객체에 대한 포인터를 반환하고 delete는 소멸하며 할당한 메모리를 해제하

    powerclabman.tistory.com

     

    따로 복습을 위한 서술은 없습니다.

    1. shared_ptr

    shared_ptr 은 본격적으로 자동 메모리 관리를 하기위한 포인터입니다.

    내부에는 ptr 과 control_block 이 존재하고,

    ptr은 객체를 참조하기위한 내부 포인터

    control_block은 참조 카운팅을 담당하는 객체입니다.

     

    자동으로 메모리가 해제되기에 memory_leak 문제는 발생하지 않고,

    객체를 참조하는 포인터가 단 하나도 없어야 해제되는 특성 때문에, use-after-free 도 발생하지 않습니다.

     

     

    unique_ptr와 다르게 중복 참조도 허용합니다.

     

     

    사실 뭐 특별한 비법이 있는 건 아니고

    위 그림 그대로, 컨트롤 블록이라는 공유되는 객체가

    몇 개의 shared_ptr 가 객체를 참조하고 있는지, 진짜 하나하나 세고있는 겁니다.

     

    단순하죠?

     

    그리고 참조의 개수(정확히는 강한 참조)가 0이 되면 메모리를 해제하는 것이죠.

     

    근데, 컨트롤 블록에 '강한 참조', '약한 참조' 이건 뭘까요?

     

    이렇게 shared_ptr의 컨트롤 블럭을 까보면

    [2 strong refs] 가 잡혀있고,

     

    _Uses 가 2

    _Weaks 가 1

    이런 식으로 세팅되어있습니다.

     

    일단 강한 참조 = strong refs = _Uses 라고 보시면 되고

    약한 참조 = weak refs = _Weaks 라고 대충 이해하시면 됩니다.

     

    정확한 의미는 weak_ptr 까지 다뤄야 이해할 수 있겠지만, 개념만 말씀드리자면

     

    강한 참조는 현재 객체를 참조하고 있는 shared_ptr의 수를 뜻합니다.

    즉, shared_ptr로 참조하면 모두 '강한 참조' 라고 보시면 되요

    강한 참조가 0이 되면, 메모리가 해제됩니다.

    '참조 카운팅' 자체는 강한 참조로 인해서 카운팅되고 관리되고 해제된다는 것이죠.

     

    약한 참조는 현재 객체를 참조하고 있는 weak_ptr의 수를 뜻합니다.

    (정확히는 weak_ptr 의 개수 + (shared_ptr != 0) )

    (위 코드에서 weak_ptr이 하나도 없음에도 불구하고 _Weaks가 1로 잡히는 이유는 (shared_ptr != 0) = 1 이기 때문입니다.)

    약한 참조는 참조는 참조인데 영향을 미치지 않는 참조라고 말할 수 있겠어요.

    약한 참조는 메모리 해제가 전혀 상관없는 녀석입니다.

    조금 애매한 포지션의 개념이죠?

    필요성은 나중에 말해보도록 하겠습니다.

     

    1) 생성 방법

     

    2) 사용 예시

    원시 포인터와 유사하게 사용할 수 있으며,

    사용 예시는 유니크 포인터와 동일합니다.

     

    3) get()

     

     

    원시 포인터를 가져오는 연산입니다.

    mydogptr이 메모리를 참조하고 있음에도 강한 참조는 1로 잡힙니다.

    메모리 릭을 일으킬 수 있으니 신중하게 사용해야 합니다.

     

    4) reset()

     

     

    유니크 포인터의 reset 사용법이 동일합니다.

    쉐어드 포인터를 nullptr 로 바꾸로 이전 참조에 대한 강한 참조를 1 감소시킵니다.

    리셋과 동시에 새로운 원시 포인터를 참조하게 할 수 있습니다.

     

    5) unique()

    강한 참조가 1인지 확인합니다.

     

    6) use_count()

    현재 강한 참조 횟수를 리턴합니다.

     

    shared_ptr을 참고해서 역설계한 코드입니다.

    이전 포스팅에 있는 SPtr 클래스와 동일한 코드입니다.

    더보기

    이전 포스팅 코드에서 조금 이상한 부분을 고친 설계 버전입니다.

    #include <iostream>
    #include <cassert>
    using namespace std;
    
    class RefCount
    {
    public:
    	RefCount() = default;
    	~RefCount() = default;
    
    	void Set() { ++_cnt; }
    	
    	int Release()
    	{
    		assert(_cnt > 0);
    
    		_cnt = _cnt - 1;
    
    		if (_cnt == 0)
    		{
    			delete this;
    			return 0;
    		}
    
    		return _cnt;
    	}
    
    	int GetCnt() const { return _cnt; }
    
    private:
    	int _cnt = 0;
    };
    
    template <typename T>
    class SPtr
    {
    public:
    	SPtr() : _ptr(nullptr), _refCnt(nullptr) {}
    	~SPtr() { Release(); }
    
    	SPtr(T* ptr) : _ptr(ptr), _refCnt(new RefCount) { SetRefCnt(); }
    	SPtr(const SPtr& other) : _ptr(other._ptr), _refCnt(other._refCnt) { SetRefCnt(); }
    
    	SPtr(T&& ptr) : _ptr(ptr), _refCnt(new RefCount) { ptr = nullptr; SetRefCnt(); }
    	SPtr(SPtr&& other) noexcept : _ptr(other._ptr), _refCnt(other._refCnt) { other._ptr = nullptr; other._refCnt = nullptr; }
    	
    	SPtr& operator=(const SPtr& other)
    	{
    		if (this != &other)
    		{
    			Release();
    
    			_ptr = other._ptr;
    			_refCnt = other._refCnt;
    			SetRefCnt();
    		}
    
    		return *this;
    	}
    	SPtr& operator=(SPtr&& other) noexcept
    	{
    		if (this == other)
    			return *this;
    
    		if (_ptr)
    			Release();
    
    		_ptr = other._ptr;
    		_refCnt = other._refCnt;
    
    		other._ptr = nullptr;
    		other._refCnt = nullptr;
    
    		return *this;
    	}
    
    public:
    	T* operator->() { return _ptr; }
    	const T* operator->() const { return _ptr; }
    	T* operator*() { return _ptr; }
    	const T* operator*() const { return _ptr; }
    
    	bool operator==(const SPtr& other) const { return _ptr == other._ptr; }
    	bool operator!=(const SPtr& other) const { return _ptr != other._ptr; }
    
    	bool isNull() const { return _ptr == nullptr; }
    	bool unique() const { return _refCnt->GetCnt() == 1; }
    
    public:
    	T* get() const { return _ptr; }
    
    	T* release()
    	{
    		Release();
    		return _ptr;
    	}
    
    	int use_count() const { return _refCnt->GetCnt(); }
    
    	void reset()
    	{
    		Release();
    		_ptr = nullptr;
    		_refCnt = nullptr;
    	}
    
    	void reset(T* ptr)
    	{
    		Release();
    
    		_ptr = ptr;
    		_refCnt = new RefCount();
    		SetRefCnt();
    	}
    
    
    private:
    	inline void SetRefCnt() { if(_refCnt) _refCnt->Set(); }
    
    	inline void Release() 
    	{
    		if (_ptr)
    		{
    			if (_refCnt->Release() == 0)
    			{
    				delete _ptr;
    				_ptr = nullptr;
    				_refCnt = nullptr;
    			}
    		}
    	}
    
    private:
    	T*			 _ptr		= nullptr;
    	RefCount*	 _refCnt	= nullptr;
    };

     

    2. shared_ptr 의 문제점: this 전달

     

    직원의 출근을 체크하는 프로그램을 만드려고 합니다.

    직원은 회사에 자신의 정보를 넘겨주고, 회사는 직원의 정보를 체크해서 출근 체크를 해주는 것이죠.

     

    그런데 this를 넘겨주는 부분에서 오류가 뜨네요..?

     

    아! 생각해보니 this는 BusinessMan의 원시 포인터 형태이니 shared_ptr<BusinessMan>으로 만들어주면 되겠네요!

     

     

    이러면 될까요?

    안됩니다.

     

    먼저 main 함수의 myBm는 A라는 메모리를 할당해서 가지고 있습니다.

    그런데, TryAttendance 함수에서 this 로 새로운 shared_ptr를 만들어서 넘겨주고 있는데,

    이러면 동일한 메모리에 대한 2개의 shared_ptr가 생기는 것과 같죠

     

    이러면 문제가 심각해지는게,

    둘 중 하나의 shared_ptr의 생명 주기가 끝나면, 메모리가 해제되어 버립니다.

    그러면 다른 포인터는 use-after-free 문제를 겪을 수 밖에 없죠.

     

     

    그래서 매개변수로 this를 전달해줄때는

    enable_shared_from_this를 상속받아서

    shared_from_this() 형태로 넘겨줘야만 안전하게 전달할 수 있습니다.

     

    3. shared_ptr 의 문제점: 순환 참조

    더보기

    <참고 코드>

    #include <iostream>
    #include <cassert>
    #include <windows.h>
    using namespace std;
    
    
    class Fighter
    {
    public:
    	Fighter(string name)
    		: _name(name), _hp(100), _dmg(rand() % 50 + 20) {}
    
    	void setOppoent(const shared_ptr<Fighter>& other)
    	{
    		if (other)
    			_opponent = other;
    	}
    
    	void Fight()
    	{
    		if (_opponent)
    			_opponent->_hp = max(0, _opponent->_hp - _dmg);
    
    	}
    
    	void PrintInfo() const
    	{
    		cout << "이름: " << _name << endl
    			<< "체력: " << _hp << endl << endl;
    	}
    
    	string GetName() const { return _name; }
    		bool isDead() const { return _hp == 0; }
    
    private:
    	string _name;
    	shared_ptr<Fighter> _opponent;
    	int _hp;
    	int _dmg;
    };
    
    void PrintInfo(const shared_ptr<Fighter>& b1, const shared_ptr<Fighter>& b2)
    {
    	assert(b1);
    	assert(b2);
    
    	b1->PrintInfo();
    	b2->PrintInfo();
    
    	cout << "------------------------------" << endl << endl;
    }
    
    int main()
    {
    	while (true)
    	{
    		shared_ptr<Fighter> b1 = make_shared<Fighter>("최흥만");
    		shared_ptr<Fighter> b2 = make_shared<Fighter>("김동헌");
    
    		b1->setOppoent(b2);
    		b2->setOppoent(b1);
    
    		while (b1 && b2)
    		{
    			PrintInfo(b1, b2);
    
    			if (b1)
    				b1->Fight();
    
    			if (b1->isDead())
    			{
    				b1.reset();
    				break;
    			}
    
    			if (b2)
    				b2->Fight();
    
    
    			if (b2->isDead())
    			{
    				b2.reset();
    				break;
    			}
    		}
    
    		if (b1)
    			cout << "승자: " << b1->GetName();
    		else
    			cout << "승자: " << b2->GetName();
    
    		cout << endl;
    
    	}
    
    }

    Fighter 2명을 생성해서 hp가 0이 될 때까지 싸워서 승자를 정하는 프로그램입니다.

    Fighter 클래스는 위 사진과 같고 자세한 코드는 '접은글' 을 펼쳐서 확인할 수 있습니다.

     

    주목할 부분은 _oppoent 라는 멤버 변수로 상대방의 메모리를 shared_ptr 로 가리키고 있다는 겁니다.

     

    최흥만 선수와 김동헌 선수가 서로 치고 박고 싸우고

    둘 선수 중 한 명이라고 hp가 0이 되면 루프를 탈출해서 승자를 출력합니다.

     

    이 프로그램이 과연 잘 동작할까요?

     

    네 매우 잘 동작합니다.

    그런데 문제점이 하나 있습니다.

     

     

    메모리 누수가 발생하고 있다는 것이죠.

    왜 이럴까요?

     

    이것이 shared_ptr의 순환참조 문제입니다.

     

     

    메인 루프 내부에서는 이런 식으로 서로가 서로를 참조하고 있습니다.

    그런데 둘 중 한 명의 hp가 0이 됐다고 생각해봅시다.

     

     

    여기서 reset으로 참조를 풀어줬다해도

    서로 참조중이기에 강한 참조가 여전히 0이 아닙니다.

     

    그렇기 때문에 메모리가 해제되지 않아서 메모리 누수현상이 발생하고 있는 것입니다.

     

    그러면.. 이걸 어떻게 해결하는게 좋을까요..?

    클래스 내부에서 이중으로 관리하는 것도 하나의 방법이라고 할 수 있겠지만

    순환참조를 간단하게 해결하기 위해 탄생한 것이 weak_ptr 약한 참조입니다.

     

    1) weak_ptr

    weak_ptr는 원시 포인터와 다르게 안전하게 참조하면서 직접적으로 '참조 카운팅'에 영향을 주는 강한 참조의 횟수를 늘리지 않습니다.

     

    2) 생성

     

    기본적으로 weak_ptr는 nullptr로 만들 수 없습니다.

    empty라는 아예 참조하는 것이 없는 상태로 하거나

    shared_ptr를 참조하도록 해야 합니다.

     

    또, 평범한 원시 포인터로는 weak_ptr를 생성할 수 없습니다.

     

    3) 사용예

     

    weak_ptr는 단독사용이 불가하며 반드시 shared_ptr 로 캐스팅해서 사용해야 합니다.

    f1.lock()을 사용해서 shared_ptr로 바꿀 수 있습니다.

     

    물론 static_cast로 캐스팅하여 사용할 수도 있지만

    lock 이라는 안전한 캐스팅 함수를 지원하기에 lock 함수를 사용하도록 합시다.

     

    4) lock()

     

    weak_ptr는 단독으로 사용할 수 없기에 lock으로 캐스팅해서 사용해야만하고

    f3에 저장할 경우 당연히 강한 참조가 늘어납니다.

     

    참조 카운팅 자체가 강한 참조에 의해서만 메모리가 관리된다고 했는데

     

    이 사진처럼 약한 참조로 여전히 참조하고 있음에도

    f1.reset() 으로 인해 강한 참조를 0으로 만들어버리면

    메모리가 해제되어 더 이상 접근하지 못합니다.

     

    그래서 f2에는 메모리에 대한 참조가 들어있는게 아니라 이상한 쓰레기 값이 들어있죠

    f2를 사용하기위해 lock()으로 캐스팅 해준다면 nullptr를 리턴하게 됩니다.

    그래서 f3는 <empty> shared_ptr 이기에 해제한 메모리로는 접근할 수 없습니다.

     

    5) expired()

     

    해당 프로그램의 결과로 1이 출력됩니다.

     

    expired()는 f2가 참조중인 메모리가 만료되었는가? 를 뜻합니다.

     

    만약 f1.reset() 을 해주지 않았다면 메모리가 여전히 살아있기 때문에 expired 는 false 일 것이고

    reset() 을 해주었다면 메모리가 해제됐기에 expired 는 true 가 발생하겠죠?

     

    6) 앞서 말한 프로그램을 고쳐보자!

     

    이런 식으로 수정해줍니다.

     

     

    순환 참조 문제를 아름답게 해결할 수 있죠.

    메인 루프에서는 항상 강한 참조가 1이기 때문에 루프 블럭만 벗어나면 메모리가 해제됩니다.

     

    더보기
    #include <iostream>
    #include <cassert>
    #include <windows.h>
    using namespace std;
    
    
    class Fighter
    {
    public:
    	Fighter(string name)
    		: _name(name), _hp(100), _dmg(rand() % 50 + 20) {}
    
    	void setOppoent(const shared_ptr<Fighter>& other)
    	{
    		if (other)
    			_opponent = other;
    	}
    
    	void Fight()
    	{
    		if (!_opponent.expired()) //만약 참조가 만료되지 않았다면
    		{
    			shared_ptr<Fighter> opp = _opponent.lock(); //shared_ptr로 캐스팅
    			opp->_hp = max(0, opp->_hp - _dmg);
    		}
    
    	}
    
    	void PrintInfo() const
    	{
    		cout << "이름: " << _name << endl
    			<< "체력: " << _hp << endl << endl;
    	}
    
    	string GetName() const { return _name; }
    	bool isDead() const { return _hp == 0; }
    
    private:
    	string _name;
    	weak_ptr<Fighter> _opponent; //수정
    	int _hp;
    	int _dmg;
    };
    
    void PrintInfo(const shared_ptr<Fighter>& b1, const shared_ptr<Fighter>& b2)
    {
    	assert(b1);
    	assert(b2);
    
    	b1->PrintInfo();
    	b2->PrintInfo();
    
    	cout << "------------------------------" << endl << endl;
    }
    
    int main()
    {
    	while (true)
    	{
    		shared_ptr<Fighter> b1 = make_shared<Fighter>("최흥만");
    		shared_ptr<Fighter> b2 = make_shared<Fighter>("김동헌");
    
    		b1->setOppoent(b2);
    		b2->setOppoent(b1);
    
    		while (b1 && b2)
    		{
    			PrintInfo(b1, b2);
    
    			if (b1)
    				b1->Fight();
    
    			if (b1->isDead())
    			{
    				b1.reset();
    				break;
    			}
    
    			if (b2)
    				b2->Fight();
    
    
    			if (b2->isDead())
    			{
    				b2.reset();
    				break;
    			}
    		}
    
    		if (b1)
    			cout << "승자: " << b1->GetName();
    		else
    			cout << "승자: " << b2->GetName();
    
    		cout << endl;
    
    	}
    
    }

     

    7) 참고

     

    shared_ptr가 해제되더라도 weak_ptr의 컨트롤 블록은 해제되지 않습니다.

     

    weak_ptr 내부에는 '약한 참조'의 개수를 기록하는 컨트롤 블록이 존재합니다.

    이 컨트롤 블록은 자신에 대한 약한 참조의 개수가 0이 되어야 해제됩니다.

    참고하시길 바랍니다.

     

    4. 마치며

    스마트 포인터 3총사에 대한 설명이 끝났습니다.

    역설계 코드를 작성하고 그 내부를 이해하다보니 상당히 오랜시간이 걸렸던 것 같습니다.

    그래도 C++ 의 고질적인 문제인 메모리 관리의 어려움을 해결하는 아주 smart한 방법입니다.

     

    처음 사용하면 생성방법이 조금 생소해서 그렇지

    사용법은 기존 포인터와 동일하기에 간단하게 사용할 수 있습니다.