본문 바로가기

Prog. Langs & Tools/C++

[C++] Ch15. 스마트(Smart) 포인터

이번 포스팅에서는 포큐 아카데미 C++ 강의 중 스마트 포인터 부분에 대한 내용을 정리해 보려고 한다.

스마트 포인터에는 다음과 같이 세 가지가 있다. unique_ptr, shared_ptr, weak_ptr. 이 중에서 unique_ptr는 정말 많이 쓰이고, shared_ptr는 적당히 쓰이며, weak_ptr는 잘 쓰이지 않는다.

포인터는 다음과 같이 사용할 수 있다.

#include "Vector.h"

int main()
{
	Vector* myVector = new Vector(10.f, 30.f);
	// ...
	delete myVector;
	return 0;
}

문제는 더 이상 포인터가 필요하지 않을 때 메모리를 해제해야 한다. 스마트 포인터를 쓰면, delete를 직접 호출할 필요가 없다. 가비지 컬렉션보다도 빠르다.

 

unique_ptr

unique_ptr는 포인터(원시 포인터)를 단독으로 소유하며, 원시 포인터는 누구하고도 공유되지 않는다. 따라서 복사나 대입이 불가능하다. unique_ptr가 범위(scope)를 벗어날 때, 원시 포인터는 지워진다.(delete)

#include <memory>
#include "Vector.h"

int main()
{
	std::unique_ptr<Vector> myVector(new Vector(10.f, 30.f)); // 포인터 부호가 없음
	myVector->Print(); // 허나 포인터처럼 동작
	return 0; // delete가 없음
}

포인터를 다른 포인터로 참조하고 싶다면 reset() 메서드를 사용할 수 있다. reset() 메서드는 유니크 포인터를 재설정하며, 소유하고 있던 원시 포인터는 자동으로 소멸된다.

#include <memory>
#include "Vector.h"

int main()
{
	std::unique_ptr<Vector> myVector(new Vector(10.f, 30.f)); // 포인터 부호가 없음
	myVector->Print(); // 허나 포인터처럼 동작
	return 0; // delete가 없음
}

 

유니크 포인터는 복사할 수 없다. 단, 포인터 소유권을 다른 std::unique_ptr로 옮길 수만 있다. 대입이 아니라 이전임을 기억하자

std::unique_ptr는 소유한 원시 포인터를 아무하고도 공유하지 않는다. 주소 복사를 하지 않는다는 의미이다. 다만, const 는 예외이다.

std::move()는 개체 A의 모든 멤버를 포기하고 그 소유권을 B에게 주는 방법이다. 따라서 메모리 할당과 해제가 일어나지 않는다. 간단하게 생각하면 A에 있는 모든 포인터를 B에 대입하고 A는 nullptr를 넣는다고 생각하자.

 

메모리 관리 : 가비지 컬렉션과 참조 카운팅

보통 자동 메모리 관리를 한다고 하면 두 가지 기법을 생각한다. 첫 번째는 가비지 컬렉션으로 Java나 C#에서 사용한다. 두 번째는 참조 카운팅으로 Swift나 Obj-C에서 사용한다.

가비지 컬렉션은 메모리 누수를 막으려는 시도로서, 주기적으로 컬렉션을 실행한다. 충분한 메모리가 없을 경우에도 컬렉션을 실행하며, 매 주기마다 GC는 루트(전역 변수, 스택, 레지스터)를 확인한다. 힙에 있는 개체에 루트를 통해 접근할 수 있는지를 판단하며 접근할 수 없다면 가비지로 간주하여 해제한다.

가비지 컬렉션의 문제점은 다음과 같다.

  1. 사용되지 않는 메모리를 즉시 정리하지 않음
  2. GC가 메모리를 해제해야 하는지 판단하는 동안 어플리케이션이 멈추거나 버벅일 수 있음.

 

참조 카운팅은 가비지 컬렉션처럼, 개체에 대한 참조가 없을 때 개체가 해제된다. 언제든 참조 횟수를 활용해서 특정 개체가 몇 번이나 참조되고 있는지를 판단할 수 있다. 어떤 개체 A를 다른 개체 B가 참조할 때 횟수가 늘어나고, B가 참조를 그만둘 때 횟수가 줄어든다.

강한(Strong) 참조란 개체 A가 개체 B를 참조할 때, 개체 B는 절대 소멸되지 않음을 의미한다. 강한 참조의 수를 저장하기 위해 강한 참조 카운트를 사용한다. 일반적으로 새 인스턴스, 즉 개체에 대한 참조를 만들 때 강한 참조 횟수가 늘어난다. 강한 참조 횟수가 0이 될 때 해당 개체는 소멸된다.

참조 카운팅의 문제점은 다음과 같은 것들이 있다.

  1. 참조 횟수가 너무 자주 바뀐다. 멀티 쓰레드 환경에서 안전하려면, lock이나 원자적(atomic) 연산이 필요하다. ++mRefCount보다 확연히 느리다.
  2. 순환 참조가 발생한다. 개체 A가 개체 B를 참조하고, 개체 B가 개체 A를 참조한다. 따라서 절대 해제되지 않는다. C++에는 이에 대한 해결책이 있다.

GC나 RefCount를 쓰면 메모리 누수가 없다고 생각할 수 있다. 전통적인 메모리 누수는 없다. 하지만 여전히 메모리 누수가 발생할 수 있다. 예를 들면 순환 참조와 같은 것들이다.

 

shared_ptr ( + weak_ptr)

shared_ptr(공유 포인터)는 두 개의 포인터를 소유한다. 하나는 데이터(원시 포인터)를 가리키는 포인터이고, 다른 하나는 제어 블록을 가리키는 포인터이다. unique_ptr와 달리, 포인터를 다른 shared_ptr와 공유할 수 있다. 참조 카운팅 기반이며 원시 포인터는 어떠한 shared_ptr에게 참조되지 않을 때 소멸된다.

약한(Weak) 참조는 원시 포인터 해제에 영향을 끼치지 않는다. 약한 참조 카운트는 약한 참조의 수를 저장하는데 사용이 된다. 약한 참조로 참조되는 개체는 강한 참조 카운트가 0이 될 때 소멸된다. 이는 순환 참조 문제의 해결책이 될 수 있다.

 

강한 참조가 0이 되며 지워진다는 것은 소멸자가 호출됨을 의미한다. 아래의 이중 링크드 리스트에서 어떻게 메모리를 해제할 수 있는지 그림으로 설명한다.

 

shared_ptr와 weak_ptr의 내용을 정리하면 다음과 같다

  • shared_ptr
    • 강한 참조
    • 강한 참조 카운트를 늘림
    • 직접적으로 사용할 수 있음
    • 원시포인터가 확실히 존재하기 때문
  • weak_ptr
    • 약한 참조
    • 약한 참조 카운트를 늘림
    • 직접적으로 사용할 수 없음
    • lock을 써서 shared_ptr가 여전히 존재하는지를 확인해야 함.

 

참고자료

  1.  <C++ 언매니지드 프로그래밍> 포큐 아카데미