스마트 포인터 란?

C++ 프로그램에서 new 키워드를 사용하여 동적으로 할당받은 메모리는, 반드시 delete 키워드를 사용하여 해제해야 하는데, 만약 해제하지 않고 넘어갈 경우에 메모리 누수 (Memory Leak) 문제가 발생해서 프로그램의 안정성을 보장받을 수 없게 된다.

이러한 위험들 때문에 스마트 포인터라는 개념이 생겨나게 되었고, 포인터처럼 동작하는 클래스 템플릿으로써 사용이 끝난 메모리를 자동으로 해제해주어 메모리 누수 문제가 일어나지 않도록 해준다.

스마트 포인터가 동작하는 방법은 기본 포인터 (Raw Pointer)가 실제 메모리를 가리키도록 초기화한 후에, 기본 포인터를 스마트 포인터에 대입하여 사용된다.

스마트 포인터의 종류

스마트 포인터는 C++ 11 표준 이전에도 auto_ptr 이라는 스마트 포인터로 작업을 처리했었는데, 현재 모던 C++ 이라 불리우는 C++ 11 이상의 표준에 대해서는 auto_ptr이 사라지고 새로운 스마트 포인터들이 제공되었다.

그 종류는 대표적으로 unique_ptr, shared_ptr, weak_ptr 가 있다.

Unique Pointer

unique_ptr 은 하나의 스마트 포인터만이 특정 객체를 소유할 수 있도록, 객체에 소유권 개념을 도입한 스마트 포인터다.

unique_ptr 스마트 포인터는 해당 객체의 소유권을 가지고 있을 때만, 소멸자가 해당 객체를 삭제할 수 있다.

unique_ptr 인스턴스는 move() 멤버 함수를 통해 소유권을 이전할 수는 있지만, 복사할 수는 없다.

소유권이 이전되면, 이전 unique_ptr 인스턴스는 더이상 해당 객체를 소유하지 않게 재설정된다.

아래는 간단한 unique_ptr의 사용 예이다.

#include <iostream>
using namespace std;

int main()
{
	//Unique Pointer 초기화와 함께 int형 값 10으로 동적할당
	unique_ptr<int> ptr1(new int(10));
	
	//auto 키워드로 ptr2는 ptr1의 타입을 추론해 받게된다.
	//move 키워드는 ptr1에서 ptr2로 메모리의 소유권을 이전하기 위해 사용된다.
	auto ptr2 = move(ptr1);

	//애초에 ptr1이 소멸되어 접근이 불가하다.
	//대입 연산자를 이용한 복사는 오류를 발생시킨다.
	unique_ptr<int> ptr3 = ptr1; // ERROR

	if (ptr1 == nullptr)
	{
		cout << "I'm Dead. Call ptr2 instead." << endl;
		cout << ptr1 << endl;
	}
	cout << *ptr2 << endl;

	//reset 함수로 메모리 영역을 삭제할 수 있다.
	ptr2.reset();
	ptr1.reset();
	return 0;
}

잘못된 예를 들기 위해서 넣은 ptr3 번 줄을 지우고 실행해보면 아래와 같은 결과를 볼 수 있다.

ptr1에서 ptr2로 소유권이 이전되면서 ptr1은 자동으로 소멸된다. 그래서 nullptr과 비교연산을 했을 때 참이기 떄문에, 죽었다는 메시지와 ptr1의 주소값을 보여주게 되었다.

ptr2로 메모리가 잘 이전된 것을 확인하기위해 ptr2의 값을 보는 코드에서도 10이 정확히 잘 출력되었다.

C++ 14 표준 이후부터 제공되는 make_unique() 함수를 사용하면 unique_ptr 인스턴스를 안전하게 생성할 수 있다.

make_unique() 함수는 전달받은 인수를 사용해 지정된 타입의 객체를 생성하고, 생성된 객체를 가리키는 unique_ptr을 반환해준다. 이 함수를 사용하면 예외 발생에 대해 안전하게 대처할 수 있다.

아래는 make_unique()를 사용하는 예제 코드이다.

#include <iostream>
using namespace std;

class HGT 
{
public:
	string name = "";
	HGT() { cout << "생성" << endl; }
	HGT(string _name) { name = _name; cout << "생성" << endl; }
	~HGT() { cout << "소멸" << endl; }
	void HelloWorld() { cout << name <<" : Hello World!" << endl; }
};

int main()
{
	//HGT 클래스를 생성
	unique_ptr<HGT> hgt_ptr = make_unique<HGT>("홍규태");
	hgt_ptr->HelloWorld();
	return 0;
}

make_unique 함수를 사용해 HGT 클래스의 인스턴스가 생성되었다. 그 인스턴스는 hgt_ptr의 소유이다.

위의 예제 코드에서 HGT 객체를 가리키는 unique_ptr 인스턴스 hgt_ptr은 일반 포인터와는 다르게 스마트 포인터이기때문에 사용이 끝난 후에 delete 키워드를 사용해 메모리를 해제할 필요가 없어졌다.

Shared Pointer

shared_ptr은 하나의 특정 객체를 참조하는 스마트 포인터가 총 몇개인지 를 참조하는 스마트 포인터다.

참조하고 있는 스마트 포인터의 개수를 참조 횟수(Reference Count)라고 하며, 참조 횟수는 특정 객체에 새로운 shared_ptr이 추가될 때마다 1씩 증가하고, 추가되었던 shared_ptr이 해제되어 참조 카운트가 0이 되면 delete 가 자동으로 진행되어 메모리를 자동으로 해제해준다.

아래는 간단한 shared_ptr의 사용 예이다.

#include <iostream>
using namespace std;

int main()
{
	shared_ptr<double> ptr1(new double(123.456));
	cout << ptr1.use_count() << endl;
	auto ptr2(ptr1);
	cout << ptr2.use_count() << endl;
	auto ptr3(ptr2);
	cout << ptr3.use_count() << endl;
	return 0;
}

하나의 메모리에 여러 shared_ptr가 붙었다. 붙은 shared_ptr 갯수만큼 참조 카운트가 올라가고 그 갯수는 use_count() 함수로 알아낼 수 있다.

이 외에도 아까 보았던 unique_ptr과 비슷한 make_shared() 함수를 통해 shared_ptr의 인스턴스를 안전하게 만들 수 있다.

make_shared() 함수는 전달받은 인수를 사용해 지정된 타입의 객체를 생성하고, 생성된 객체를 가리키는 shared_ptr을 반환해준다. 이 함수도 예외 발생에 대해 안전하다.

아래는 make_shared()의 사용 예이다.

#include <iostream>
using namespace std;

class Monster {
public:
	Monster() { cout << "생성" << endl; }
	~Monster() { cout << "소멸" << endl; }
};

int main()
{
	shared_ptr<Monster> mst_ptr1 = make_shared<Monster>();
	cout << mst_ptr1.use_count() << endl;
	auto mst_ptr2 = mst_ptr1;
	cout << mst_ptr1.use_count() << endl;
	mst_ptr2.reset();
	cout << mst_ptr1.use_count() << endl;
	return 0;
}

Weak Pointer

weak_ptr은 하나 이상의 shared_ptr 인스턴스가 소유하는 객체에 대한 접근을 제공하지만, 참조 카운트에 포함되지 않는 스마트 포인터이다.

shared_ptr은 참조 카운트를 기반으로 동작하는 스마트 포인터이다.

만약에 서로가 상대를 가르키는 shared_ptr을 가지고 있다면, 참조 횟수는 절대 1 이하로 내려가지 않기 때문에, 0이 되어야 자동으로 해제되는 스마트 포인터에 가장 크리티컬한 문제가 된다.

이렇게 서로가 상대를 참조하는 상황을 순환 참조(Circular Reference)라고 한다.

weak_ptr은 바로 이러한 shared_ptr 인스턴스 사이의 순환 참조를 제거하기 위해서 사용한다.

아래는 weak_ptr의 사용 예이다.

#include <iostream>
using namespace std;

class Monster {
public:
	//shared_ptr로 선언할 경우 순환 참조 발생
	//weak_ptr로 선언하여 순환 참조를 예방함
	weak_ptr<Monster> otherMonster;
	Monster() { cout << "생성" << endl; }
	~Monster() { cout << "소멸" << endl; }
};

int main()
{
	//철수와 민수에 대한 shared_ptr을 선언
	shared_ptr<Monster> chul_su = make_shared<Monster>();
	shared_ptr<Monster> min_su = make_shared<Monster>();

	cout << "철수 참조 카운트 : " << chul_su.use_count() << endl;
	cout << "민수 참조 카운트 : " << min_su.use_count() << endl;
	
	chul_su->otherMonster = min_su;
	min_su->otherMonster = chul_su;

	cout << "철수 참조 카운트 : " << chul_su.use_count() << endl;
	cout << "민수 참조 카운트 : " << min_su.use_count() << endl;

	return 0;
}

왼쪽은 shared_ptr이 Monster의 멤버일 경우, 오른쪽은 weak_ptr이 Monster의 멤버일 경우이다.

이처럼 소멸에 대해서 발목잡히지 않도록 도와주는 weak_pointer 까지 알아보았다.