티스토리 뷰

 

스마트 포인터,  왜 사용하는 걸까?

C언어로 개발을 하다 보면 new 사용해서 메모리를 동적으로 할당받아 사용해야 하는 경우를 많이 볼 수 있다. 

이때, 주의해야 할 점은 메모리를 할당받을 경우, 사용 후 반드시 Delete 해줘야 하는 것인데, 그렇지 않을 경우 '메모리 누수(Memory Leak)'가 발생하여 메모리를 낭비하게 된다. 

그래서 최신 C++에서는 사용자가 메모리를 관리해야 하는 번거로움을 줄이고, 메모리/리소스 누수와 예외 안전성을 보장하기 위해 포인터처럼 동작하는 클래스 템플릿, '스마트 포인터'를 도입했다. 간단히 말하자면 스마트 포인터는 사용이 끝난 메모리를 자동으로 해제해준다. 


 스마트 포인터,  어떻게 사용하면 될까?

우선, 스마트 포인터는 세 가지 종류가 있다. 이들에 대해 각각 알아보자.

1. unique_ptr
이름에서 감이 오듯 unique_ptr는 하나의 객체에 대해 '유일한' 포인터를 쓰고 싶을 때 사용한다. 뒤이어 나오는 shared_ptr와 비교하면 이해하기 쉬울 것이다.

unique_ptr에서 주목해야 하는 함수 두 가지가 있다. 첫 번째 'release()'함수, 두 번째 'reset()'함수다.

밑에 예제와 함께 살펴보자.

#include <iostream>
#include <memory>
using namespace std;

class Test {
private:
	int x;
public:
	Test(int x):x(x) { cout << "생성자"; }
	~Test() { cout << "소멸자"; }
	int getX() { return x; }
};

int main() {
	unique_ptr<Test> a(new Test(5));
	unique_ptr<Test> b(a.release());	// 1)release 함수를 통해 객체의 소유권이 b에게 넘어감 
	b.reset(new Test(6));	//2)reset 함수를 통해 원래 b가 가리키고 있던 x=5인 객체가 사라지고 x=6인 객체를 가리킴 
	return 0;
}

main 함수 첫 줄에 먼저 a라는 unique_ptr를 선언하고 이와 동시에 Test 객체를 할당한다. 

 

이후, unique_ptr b를 선언하는데 필자는 b를 a가 가리키고 있는 객체를 가리키게 하고 싶다. 하지만 우리 모두 알고 있듯 여러 unique_ptr가 하나의 객체를 동시에 가리킬 수 없다. 그러므로 b 포인터 생성자에 'a.release()'를 삽입함으로써 b는 a에게서 객체 소유권을 뺏어올 수 있다. 

 

이제, reset 함수에 대해 알아보자. reset은 말 그대로 지금 포인터가 가지고 있는 객체를 다른 객체로 리셋하는 것이다. 예제를 보면 b가 원래 가리키던 Test 객체의 x 값은 5였다. 그런데 reset 함수를 통해 x 값을 6으로 가지는 객체로 바꿨다. 여기서 리셋 과정과 순서를 좀 더 들여다보면, '1) 새 객체 생성, 2) 구 객체 delete, 3) 포인터 할당 ' 과 같다.

 

쉽게 말하면, release()는 'unique 포인터->다른 unique 포인터 바꾸기', reset()은 '객체->다른 객체 바꾸기' 이렇게 정리할 수 있다.

 

 


 

2. shared_ptr
unique_ptr와 다르게 하나의 객체에 대해 여러 포인터와 함께 가리킬 수 있는 것이다. 이 포인터에서 중요한 점은 현재 객체를 참조하고 있는 스마트 포인터의 개수인 '참조 횟수(use count)'가 중요한 역할을 하고 있다는 것인데, 해당 객체를 참조하는 포인터가 하나씩 늘어날수록 참조 횟수가 1씩 증가하고, 포인터가 수명을 다하면 1씩 감소한다. 그러다가 참조 횟수가 0이 되는 순간, 객체는 자동으로 delete 된다.
#include <iostream>
#include <memory>
using namespace std;

class Test {
private:
	int x;
public:
	Test(int x):x(x) { cout << "생성자"<<endl; }
	~Test() { cout << "소멸자"; }
	int getX() { return x; }
};

int main() {
	shared_ptr<Test> a(new Test(5)); // make_shared 함수를 사용해 생성시: shared_ptr<Test> a= make_shared<Test>(5) 
	cout << "a 생성 후 a.use_count: "<<a.use_count() << endl; //1) use_count = 1
	{
		shared_ptr<Test> b = a;
		cout << "b 생성 후 a.use_count: "<<a.use_count() << endl; // 2) use_count = 2
		cout << "b 생성 후 b.use_count: " << b.use_count() << endl; //3) use_count = 2
	}
	cout << "b 소멸 후 a.use_count: " << a.use_count() << endl; //4) use_count = 1 
	return 0;
}

 

Result:

생성자
a 생성 후 a.use_count: 1
b 생성 후 a.use_count: 2
b 생성 후 b.use_count: 2
b 소멸 후 a.use_count: 1
소멸자

 

shared_ptr에서 가장 중요한 점은 'use_count'의 값이다. 예제를 통해 확인해보자. use_count 값이 a, b가 생성됨에 따라 자동으로 1씩 증가했음을 결과창을 통해 확인할 수 있다. 또한 b는 괄호 블록에서만 유효하기 때문에 블록을 빠져나오면 자동으로 소멸되는데 이로 인해 블록을 빠져나온 직후 use_count값이 하나 감소했음을 확인할 수 있다. 그리고 해당 객체의 유일한 포인터인 a마저 프로그램이 종료함에 따라 소멸되면, use_count는 0이 되므로, 객체는 자동으로 delete 된다.

 

 


 

3. weak_ptr
이 포인터는 shared_ptr와 함께 이해하면 좋다. weak라는 네임에서 힌트를 얻을 수 있듯 해당 포인터는 약하다. 그러므로 어떤 객체를 가리키도록 할당되어도 '참조 횟수'가 증가하지 않는다.  그럼 왜 사용할까? 만약 서로를 가리키는 shared_ptr 두 개가 있다고 하자. 그럼 참조 횟수는 절대 0이 되지 않으므로 메모리는 영원히 해제되지 않는다. 이렇게 서로가 서로를 참조하고 있는 상황을 순환 참조(circular reference)라고 한다. weak_ptr는 바로 이러한 순환 참조를 제거하기 위해 사용된다. 밑에 예제와 함께 사용법을 익혀보자.
#include <iostream>
#include <memory>
using namespace std;

class Test {
private:
	int x;
public:
	Test(int x):x(x) { cout << "생성자"<<endl; }
	~Test() { cout << "소멸자"<<endl; }
	int getX() { return x; }
};

int main() {
	weak_ptr<Test> a;
	{
		shared_ptr<Test> b(new Test(5));
		a = b;

		// (1)
		if (!a.expired()) {	//조건: a가 가리키고 있는 객체가 살아 있다면 실행하라
			cout << a.lock()->getX() << endl; //'a->getX()'불가! lock함수 사용해야 객체 접근 가능
		}

		cout << "b 생성 후 a.use_count: "<<a.use_count() << endl; // 1) use_count = 1
	}

	// (2)
	if (!a.expired()) {	//a의 shared_ptr는 죽었으므로 if문 실행 안함.
		cout << a.lock()->getX() << endl; //'a->getX()'불가! lock함수 사용해야 객체 접근 가능
	}

	cout << "b 소멸 후 a.use_count: " << a.use_count() << endl; //2) use_count = 0 
	return 0;
}

 

Result:

생성자
5
b 생성 후 a.use_count: 1
소멸자
b 소멸 후 a.use_count: 0

weak_ptr사용 시 주목해야 할 함수는 lock()과 expired() 함수 두 가지이다. weak_ptr를 사용하면 객체에 '->'를 이용해 직접 접근할 수 없다. 위 예제로 보면 'a->getX()'와 같이 쓸 수 없다는 것이다. 이때, 두 함수를 사용하면 shared_ptr를 사용해 객체에 접근할 수 있다. 먼저 expired()는 weak_pointer의 참조 횟수가 0이라면, 즉 shared_ptr가 하나도 없이 weak_ptr만 살아있다면 True를 반환한다. 이 함수를 사용하면 위 예제의 (1), (2)와 같은 if문에 조건을 구성할 수 있다.(1) 번 조건문에서는 블록 안에서 b포인터가 살아있기 때문에  조건문을 실행하게 되고, lock 함수를 이용해 shared_ptr에 접근할 수 있다. 그럼 약한 포인터 a 대신에 b가 객체에 접근해 x값을 리턴해줄 수 있게 된다.하지만 (2)은 b가 소멸했으므로 참조 횟수는 0, 객체도 소멸된 상태라 조건문을 실행하지 않는다.

 

 

참고로 스마트 포인터는 std 헤더 파일의 네임스페이스에 <memory>에 정의된다. 그러므로 이를 사용할 때, '#include <memory>'를 적어줘야 한다. 


그럼 다른 언어에도 스마트 포인터가 있을까?

여러 가지 개발 언어들의 메모리 관리 법에 대해 간략히 알아보자.

  • Java: Java는 JVM에 유효하지 않은 메모리(Garbage)를 알아서 정리해주는 'Garbage Collection(GC)' 기능이 있기 때문에 개발자가 직접 해제해 줄 필요가 없다.
  • Python: 파이썬에도 Garbage Collection이 존재하는데, 레퍼런스 카운팅(Reference Counting), 세대별 가비지 컬렉션(Generational garbage collection) 두 가지 측면으로 작동한다고 한다.
  • Swift: Swift는 'Automatic Reference Counting(ARC)'이라는 메모리 관리 모델을 사용한다. 프로그램이 실행될 때 메모리를 관리하고 감시하는 GC와 다르게 컴파일할 때 개발자 대신에 release 코드를 적절한 위치에 넣어준다.

 


 

함께 읽어보면 좋은 레퍼런스

  1.  Microsoft Docs | Smart pointers : https://docs.microsoft.com/en-us/cpp/cpp/smart-pointers-modern-cpp?view=msvc-160
  2. TCP school | 스마트 포인터 : http://tcpschool.com/cpp/cpp_template_smartPointer
  3. YouTube | 채널: 두들낙서 | 96강. 스마트 포인터 : https://www.youtube.com/watch?v=DYSEulQoj8Q
  4. 유셩장 깃 헙 | GC vs ARC : https://sihyungyou.github.io/iOS-GC-vs-ARC/
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함