Advanced C++

[C++] 스마트 포인터 shared_ptr/unique_ptr/weak_ptr

로파이 2021. 3. 6. 01:26

스마트 포인터

클래스 혹은 함수 내에서 동적 할당을 한 객체를 해제하기 위해 해당 객체에 대한 소멸자를 명시적으로 호출하여 삭제시켜야하는데

스마트 포인터는 내부에 이 동적 할당을 한 객체를 소유함으로 객체 삭제를 자동으로 대신 해준다.

스마트 포인터는 내부에 포인터를 맴버변수로 가지고 있는 value-type의 일반적인 클래스 인스턴스라고 볼 수 있다.

 

자동 소멸의 기능 뿐만아니라 스마트 포인터는 A와 같이 객체를 포인터 형태로 소유함으로 A 자원을 공유하고 필요에 따라 스마트 포인터를 통해 공유 자원을 접근하도록 해준다.

 

자원 공유를 할 수 있는 간단한 스마트 포인터를 구현해보면 다음과 같이 구현할 수 있다.

template<typename T>
class SmartPointer
{
private:
	int* ref_count = nullptr;
	T* obj = nullptr;
private:
	void Ref() { ++(*ref_count); }
	void DeRef() { --(*ref_count); }
public:
	SmartPointer<T>(T* obj_in)
		:
		obj(obj_in)
	{
		if (ref_count == nullptr)
		{
			ref_count = new int(1);
		}
		else {
			Ref();
		}
	}
	~SmartPointer()
	{
		DeRef();
		if (*ref_count == 0)
		{
			delete obj;
			delete ref_count;
			obj = nullptr;	
			ref_count = nullptr;
		}
	}
}

스마트 포인터의 핵심은 자원을 공유하는 스마트 포인터의 갯수, 즉 참조 갯수를 알아야한다. 또한 참조 개수는 같은 자원을 공유하는 스마트 포인터끼리 동기화해야하기 때문에 동적 할당을 통해 힙 메모리에서 참조 개수를 동기화할 수 있다.

 

클래스 A 인스턴스를 소유한 스마트 포인터는 스마트 포인터가 소멸하면서 클래스 A 인스턴스를 자동으로 삭제해 준다.

class A {
public:
	int a;
	A() { cout << "A 생성 !" << endl; }
	~A() { cout << "A 삭제 !" << endl; }
};

int main()
{
	A* instA = new A;
	SmartPointer<A> pA(instA);
	return 0;
}

결과

다음 복사 생성자와 대입 연산을 통한 복사를 정의한다면 

SmartPointer(const SmartPointer<T>& rhs)
{
	*this = rhs;
}
SmartPointer& operator=(const SmartPointer<T>& rhs)
{
	ref_count = rhs.ref_count;
	obj = rhs.obj;
	Ref();
	return *this;
}

다음과 같은 코드에서 참조 개수를 확인할 수 있다.

int main()
{
	A* instA = new A(5);
	SmartPointer<A> pA(instA);
	SmartPointer<A> pA2 = pA;
	SmartPointer<A> pA3 = pA2;
	std::cout << pA3.GetRefCount() << std::endl;

	return 0;
}

std::shared_ptr / std::make_shared

shared_ptr은 위와 같이 자원을 공유하고 삭제하는 역할을 수행해주는 스마트 포인터로 C++ 기본 라이브러리로 제공되는 클래스이다. 구현 예시와 같이 참조 개수를 동기화하기 위해 내부에 제어 블록을 동적 할당하여 사용하고 있다.

shared_ptr은 다음과 같이 동적 할당한 포인터를 가지고 직접 초기화하면 참조 개수를 동기화 할 수 없기 때문에 다음 코드에서 두번째로 정의한 pbb 스마트 포인터는 참조 개수가 동기화가 되자 않았고 이는, 프로그램에 종료할 때, paa가 삭제 되면서 이미 삭제한 instA를 삭제하려고 하기 때문에 오류가 발생한다.  

A* instA = new A(5);
shared_ptr<A> paa(instA); // 참조 개수 1
shared_ptr<A> pbb(instA); // 참조 개수 1
std::cout << "\t" << "A 참조 개수:" << paa.use_count() << std::endl;
std::cout << "\t" << "A 참조 개수:" << pbb.use_count() << std::endl;

따라서 공유를 위해서 스마트 포인터를 생성 하기 위해서는 복사 생성자를 통한 공유를 해야한다.

shared_ptr<A> pbb = paa;

또한 이러한 직접 포인터 전달을 피하고 A 인스턴스를 동적 할당함과 동시에 스마트 포인터를 초기화 하기 위해서 다음과 같이 make_shared 함수를 통해 생성하는 것이 바람직하다.

// A* instA = new A(5);
// shared_ptr<A> paa(instA);
shared_ptr<A> paa = make_shared<A>(5);

 

구현 코드의 문제점

다음과 같은 코드에서 서로 다른 인스턴스를 소유하는 pA1과 pA2 스마트 포인터가 있고 pA2에 pA1를 대입하면서 기존 pA2가 소유하고 있던 instA2 인스턴스에 대한 포인터를 잃어 버린다. 따라서 instA2는 프로그램 종료시 삭제되지 않고 힙 메모리에 남아있기 때문에 메모리 누수가 발생한다.

int main()
{
	A* instA1 = new A(5);
	A* instA2 = new A(10);
	SmartPointer<A> pA1(instA1);
	SmartPointer<A> pA2(instA2);
	pA2 = pA1;
	std::cout << "\t" << "A 참조 개수:" << pA1.GetRefCount() << std::endl;

	return 0;
}

shared_ptr의 동작 방식

shared_ptr은 대입시 기존에 소유하고 있던 인스턴스와 다른 인스턴스로 대입된다면, 기존 인스턴스에 대한 참조 개수가 줄어들면서 삭제를 하고 다른 인스턴스를 소유하게 된다.

int main()
{
	shared_ptr<A> aa = make_shared<A>(10);
	shared_ptr<A> bb = make_shared<A>(20);
	cout << aa.use_count() << endl;
	bb = aa;
	cout << bb.use_count() << endl;
	return 0;
}

 

스마트 포인터 구현 버전 2

template<typename T>
class SmartPointer
{
private:
	int* ref_count = nullptr;
	T* obj = nullptr;
private:
	void Ref() { ++(*ref_count); }
	void DeRef()
	{ 
		--(*ref_count); 
		if (*ref_count == 0)
		{
			delete obj;
			delete ref_count;
			obj = nullptr;
			ref_count = nullptr;
		}
	}
public:
	SmartPointer() {}
	SmartPointer<T>(T* obj_in)
		:
		obj(obj_in),
		ref_count(new int(1))
	{
	}
	~SmartPointer()
	{
		DeRef();
	}
	int GetRefCount() const { return *ref_count; }
	SmartPointer(const SmartPointer<T>& rhs)
	{
		*this = rhs;
	}
	SmartPointer& operator=(const SmartPointer<T>& rhs)
	{
		// 소유하고 있는 인스턴스가 다르다면 
		// 먼저 참조 개수를 줄인다.
		if (obj != nullptr && obj != rhs.obj)
		{
			DeRef();
		}
		ref_count = rhs.ref_count;
		obj = rhs.obj;
		Ref();
		return *this;
	}
};

대입 연산 함수에 기존 소유하고 있던 인스턴스와 다른 인스턴스로 대입된다면 참조 개수를 먼저 줄여준다.

위 구현으로 다음과 같은 코드에서 메모리 누수 없이 잘 대입이 되는 것을 확인할 수 있다.

A* instA1 = new A(5);
A* instA2 = new A(10);
SmartPointer<A> pA1(instA1);
SmartPointer<A> pA2(instA2);
SmartPointer<A> pA3(pA2);
pA2 = pA1;
std::cout << "\t" << "instA2 참조 개수:" << pA3.GetRefCount() << std::endl;

unique_ptr

unique_ptr은 소유 객체를 처음 초기화한 다음 자원 공유를 막고 유일한 소유를 보장한다. 주로 클래스 내부에서 다른 클래스의 유일한 인스턴스를 소유할 때 사용가능하다. ex) 모든 사람이 하나의 핸드폰만 소유하고 사용한다면 사람 인스턴스는 각각 핸드폰 객체에 대한 포인터를 unique_ptr로 관리할 수 있다.

 

make_unique 함수를 통해 객체를 생성하고 유일한 소유를 보장하므로 복사 생성, 대입 연산을 통한 복사 모두 삭제된다.

 

template<typename T>

unique_ptr(const unique_ptr<T>& rhs> = delete;

unique_ptr& operator=(const unique_ptr<T>& rhs) = delete;

 

소유 인스턴스에 대한 복사 대신, 소유권을 이전하고 싶다면 (핸드폰을 다른 사람에게 파는 등...)

std::move() 함수를 이용하여 && 이동 연산자에 대한 생성, 대입이 오버로딩 되도록 한다.

 

unique_ptr를 매개변수로 사용하는 함수

복사 생성이 삭제되었기 때문에 매개변수 타입을 참조형으로 받도록 한다.

하지만 unique_ptr를 매개 변수로 받는 함수가 적지 않다면 해당 인스턴스를 unique_ptr로 관리하는 것은 지양하는 것이 좋다. 그러한 함수는 대체적으로 소유권을 이전하는 용도로 사용하는 것이 좋다.

void foo1(unique_ptr<A> paa) {} // 에러
void foo2(const unique_ptr<A>& paa) {}

한 클래스를 스마트 포인터로 관리한다면 그 클래스를 매개변수로 사용하는 함수는 모두 스마트 포인터로 대체하여 사용해야한다. 왜냐하면 날것의? 포인터를 전달한다면 함수 내부에서 어떤 스마트 포인터가 새로 생기면서 동기화가 되지 않거나 unique_ptr을 생성한다면 유일한 소유를 보장할 수도 없다.

void foo3(const shared_ptr<A>& pbb) {}

 

shared_ptr의 순환 참조 문제

shared_ptr 객체는 순환 참조 문제 가능성을 안고 있는데 주로 소유하고 있는 객체가 다른 객체의 일부분일 때 발생한다. 즉 트리와 같은 부모와 자식 구조에서 부모는 자식에 대한 포인터를 shared_ptr로 관리하고 자식은 어떤 부모에 속해 있는지 shared_ptr로 관리한다면 서로 참조를 하고 있는 상황이다.

 

이때, 부모나 자식이 서로 가리키고 있다면 참조 개수는 각각 2인 상황인데, 부모나 자식이 먼저 삭제되어야 나머지가 하나가 삭제된다. 하지만 프로그램 종료 후 참조 개수가 한 개씩만 줄어들고 삭제가 되지 않기 때문에 결과적으로 소유 인스턴스에 대한 삭제가 이루어지지 않는다.  

class Child;
class Parent {
	SmartPointer<Child> child;
public:
	~Parent() { cout << "parent destroyed " << endl; }
	void SetChild(const SmartPointer<Child> &child_in)
	{
		child = child_in;
	}
};
class Child
{
	SmartPointer<Parent> parent;
public:
	~Child() {cout << "child destroyed " <<endl;}
	void SetParent(const SmartPointer<Parent> &parent_in)
	{
		parent = parent_in;
	}
};
int main()
{
	_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

	SmartPointer<Parent> pA1 = new Parent;
	SmartPointer<Child> pA2 = new Child;
	pA1.Get()->SetChild(pA2);
	pA2.Get()->SetParent(pA1);
}

코드 실행 시 아무런 출력이 없고 대신 메모리 누수 보고서만 우수수 나오게 된다.

 

weak_ptr

weak_ptr은 순환 참조 문제에 적용할 수 있는 스마트 포인터인데, shared_ptr를 복사 생성 혹은 대입 연산 복사가 가능하다.

shared_ptr<A> pa = make_shared<A>(1);
weak_ptr<A> pb(pa);
weak_ptr<A> pc = pa;
cout << "참조 카운트: " << pa.use_count() << endl; // 참조 카운트는 1

shared_ptr과 다르게 참조 개수를 증가시키지 않고 해당 인스턴스 파괴는 shared_ptr에 맡기도록 한다. 따라서 참조하고 있는 shared_ptr이 없다면 해당 weak_ptr은 nullptr이 된다.

일반 포인터를 대입하거나 직접 동적 할당한 포인터를 대입하는 것은 불가능하다.

 

weak_ptr은 소유 인스턴스가 실체가 있는지 모르기 때문에 접근이 불가능하고 lock() 함수를 통해 share_ptr로 내부에서 생성하여 접근해야한다. lock()함수는 소유하고 있는 인스턴스가 하나 이상의 shared_ptr에 의해 공유되고 있다면, nullptr이 아닌 shared_ptr 인스턴스가 반환된다.

cout << pb.lock()->a << endl;

 

shared_ptr를 위한 weak_ptr

만약 다음과 같이 코드를 넣는다면,

weak_ptr<A> pd = make_shared<A>(3); // 의미 없음 생성되자마자 삭제됨

make_shared를 통해 share_ptr<A> 인스턴스가 반환되고 r-value의 임시 인스턴스이기 때문에 대입 후 삭제된다.

즉 pd는 실체가 사라진 nullptr를 가리키는 의미없는 weak_ptr이다.

 

순환 참조 해결

class Child;
class Parent {
	weak_ptr<Child> child;
public:
	~Parent() { cout << "parent destroyed " << endl; }
	void SetChild(const shared_ptr<Child> &child_in)
	{
		child = child_in;
	}
};
class Child
{
	weak_ptr<Parent> parent;
public:
	~Child() {cout << "child destroyed " <<endl;}
	void SetParent(const shared_ptr<Parent> &parent_in)
	{
		parent = parent_in;
	}
};
int main()
{
	//SmartPointer<Parent> pA1 = new Parent;
	//SmartPointer<Child> pA2 = new Child;
	//pA1.Get()->SetChild(pA2);
	//pA2.Get()->SetParent(pA1);

	shared_ptr<Parent> pA1 = make_shared<Parent>();
	shared_ptr<Child> pA2 = make_shared<Child>();
	pA1->SetChild(pA2);
	pA2->SetParent(pA1);
}

shared_ptr과 weak_ptr로 순환 참조를 해결하면 다음과 같이 정상적으로 소멸자가 호출되며 삭제된다.

 

스마트 포인터 정리

스마트 포인터는 객체 삭제를 알아서 해주고 자원 공유 기능을 제공한다. 하지만 내부적으로 제어 블록을 생성하기 위해 동적 할당 비용이 들고 shared_ptr은 참조 개수를 세는 참조 카운트로 다중 스레드 환경에서 안전하지 않다. (공유 자원에 대한 문제) 또한 생성, 삭제가 날 것의 포인터 보다 느리기 때문에 속도가 중요한 프로그램에서는 shared_ptr는 잘 사용되지 않고 직접 참조 카운터 기능을 구현한 클래스를 상속하여 사용하는 것 같다.

unique_ptr은 클래스 내부에서 유일한 인스턴스를 포인터로 관리할 때 유용하게 사용할 수 있으며 복사가 불가능하기 때문에 유일한 인스턴스를 항상 보장할 수 있을 것이다. 함수 매개변수로 잘 사용하지 않고 맴버 변수 unique_ptr로 두는 경우 활용도가 높을 것 같다.