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

chooha·2024년 12월 30일

C++

목록 보기
3/23

1. 스마트 포인터(Smart Pointer)란?

: C++의 특별한 클래스 타입, 포인터처럼 동작하지만 스스로 메모리를 관리


▸ 필요성과 사용 이유

  • 메모리 누수
    : 동적으로 할당한 메모리를 해제하지 않고 잊어버리면 그 메모리는 계속 시스템에 할당된 상태로 남게 됨
    이렇게 되면 메모리 사용량이 증가하고, 결국은 시스템의 성능을 저하시키는 문제가 발생

  • 해제 후 사용 (dangling pointer)
    : 이미 해제된 메모리를 계속 사용하려는 경우 발생
    이는 데이터 손실이나 시스템 충돌을 일으킬 수 있는 심각한 문제


▸ 작동 원리

: RAII(Resource Acquisition Is Initialization)이라는 원칙을 사용해 메모리를 자동으로 관리

💡 RAII (Resource Acquisition Is Initialization)
: 객체의 수명이 그 객체가 소유한 자원의 수명과 동일하게 관리되는 것

스마트 포인터는 내부적으로 '원시 포인터(raw pointer)'를 보관하고 있음
이 원시 포인터는 스마트 포인터가 가리키는 실제 메모리를 가리킴
그러나 사용자는 이 원시 포인터에 직접 접근할 수 없으며, 스마트 포인터가 제공하는 인터페이스를 통해서만 메모리에 접근할 수 있음

❗ 스마트 포인터를 사용하더라도 메모리 관리에 대한 주의는 필요함!
스마트 포인터를 사용하더라도 순환 참조와 같은 문제가 발생할 수 있기 때문


2. std::unique_ptr

▸ 정의와 특징

이름에서 알 수 있듯이 유일한 포인터
즉, 동일한 메모리를 가리키는 두 개의 unique_ptr 인스턴스가 동시에 존재할 수 없음
이러한 특성은 메모리 누수와 같은 일반적인 문제를 방지하는 데 도움이 됨

std::unique_ptr<int> ptr1(new int(5));
std::unique_ptr<int> ptr2 = ptr1; // 컴파일 에러

하지만, 이동이라는 개념을 사용하여 포인터의 소유권을 이전할 수 있음

std::unique_ptr<int> ptr1(new int(5));
std::unique_ptr<int> ptr2 = std::move(ptr1);

ptr1은 null이 되고, ptr2는 ptr1이 이전에 가리켰던 메모리를 가지게 됨

한편, unique_ptr은 성능 비용이 거의 없음
이는 내부적으로 일반 포인터를 사용해 객체를 추적하기 때문임
∴ 안전한 리소스 관리와 효율성을 동시에 달성하는 데 도움이 됨

이러한 속성 덕분에 다양한 유스케이스에서 유용하게 사용됨
ex. 함수에서 동적으로 할당된 메모리를 반환할 때, unique_ptr를 사용하면 호출자가 반환된 메모리의 소유권을 명확하게 이해할 수 있으며, 메모리 누수의 위험을 크게 줄일 수 있음


▸ 사용법

  • 기본 사용법

     #include <memory>
      #include <iostream>
    
      using namespace std;
    
      class MyClass
      {
      public:
          MyClass(int value) : value_(value) {}
          void PrintValue()
          {
              cout << "Value : " << value_ << "\n";
          }
      };
    
      int main()
      {
          // 첫 번째 예제
          unique_ptr<MyClass> ptr1(new MyClass(3));
          ptr1->PrintValue();
    
          // 두 번째 예제
          unique_ptr<MyClass> ptr2 = make_unique<MyClass>(10);
          ptr2->PrintValue();
    
          // 세 번째 예제
          // unique_ptr<MyClass> ptr3 = ptr2; // 컴파일 에러
          unique_ptr<MyClass> ptr3 = move(ptr2); // ptr2의 소유권이 ptr3로 옮겨짐
          ptr3->PrintValue(); // Value : 10 출력
      }

    unique_ptr는 두 번째 예제처럼 make_unique 함수를 사용하여 생성하는게 좋은데, 그 이유는 객체 생성과 메모리 할당을 하나의 연산으로 결합하여 예외 안정성을 높일 수 있기 때문

  • 함수 전달 / 반환

     #include <memory>
    
      using namespace std;
    
      unique_ptr<int> CreateUniquePtr()
      {
          return unique_ptr<int>(new int(5));
          // unique_ptr를 직접 반환하면, 소유권이 이전됨
          // 이 때문에 함수가 끝난 후에도 메모리 누수는 발생하지 않음
      }
    
      void UseUniquePtr(unique_ptr<int> ptr)
      {
          // 함수에 전달된 unique_ptr는 이 함수 안에서만 유효
          // 함수가 끝난 후에는 자동으로 메모리가 해제됨
      }
    
      int main()
      {
          unique_ptr<int> ptr = CreateUniquePtr();
          UseUniquePtr(move(ptr));
          // unique_ptr를 함수에 전달할 때는 move를 사용해야 함
          // 이제 ptr는 nullptr임
      }

▸ 커스텀 deleter

#include <memory>
#include <cstdio>

using namespace std;

struct FileDeleter
{
	void operator()(FILE* file) const
    {
    	if(file)
        {
        	fclose(file);
        }
    }
};

int main()
{
	unique_ptr<FILE, FileDeleter> file_ptr(fopen("myfile.txt", "r"));
}

FileDeleter는 unique_ptr가 파일 핸들을 제거하는 방법을 정의하는 커스텀 deleter


2. std::shared_ptr

▸ 정의와 특징

메모리에 대한 공유 소유권을 제공하는 스마트 포인터
내부적으로 레퍼런스 카운팅을 수행하여, 메모리를 가리키는 shared_ptr 인스턴스의 수를 추적함
→ 여러 shared_ptr 인스턴스가 동일한 메모리를 카리킬 수 있음

std::unique_ptr<int> ptr1(new int(5));
std::unique_ptr<int> ptr2 = ptr1; // it's okay!

ptr1과 ptr2는 모두 동일한 메모리를 가리키며, 레퍼런스 카운트는 2임

{
	std::shared_ptr<int> ptr1(new int(3));
    {
    	std::shared_ptr<int> ptr2 = ptr1; // 레퍼런스 카운트 2
    } // ptr2가 소멸되면서 레퍼런스 카운트 1
} // ptr1이 소멸되면서 레퍼런스 카운트가 0이 되고 메모리가 해제됨

레퍼런스 카운트가 0이 되면 즉시 메모리가 해제됨

but. 불필요한 레퍼런스 카운팅은 성능을 저하시킬 수 있으므로 반드시 필요한 경우에만 사용해야 함

❗ 또한, shared_ptr은 순환 참조 문제를 야기할 수 있음
→ weak_ptr를 통해 해결할 수 있음


▸ 사용법

  • 기본 사용법

     shared_ptr<int> p1(new int(5)); // 일반 포인터를 이용한 초기화
      shared_ptr<int> p2 = make_shared<int>(5); // make_shared를 이용한 초기화

    unique_ptr와 같이 두 가지 방법의 초기화가 있음
    shared_ptr 또한 make_shared를 이용한 초기화를 사용하는 것이 좋음

  • 참조 카운팅

     shared_ptr<int> p1 =  make_shared<int>(5);
    
      cout << "p1 use count: " << p1.use_cout() << '\n'; // p1 use cout: 1 출력
    
      shared_ptr<int> p2 = p1;
      cout << "p1 use count: " << p1.use_cout() << '\n'; // p1 use cout: 2 출력
      cout << "p2 use count: " << p2.use_cout() << '\n'; // p2 use cout: 2 출력

    참조 카운트는 shared_ptr의 use_count() 메서드를 통해 알 수 있음
    ❗ 이 메서드는 디버깅이나 학습 목적 이외에는 사용을 권장하지 않음
    use_count()의 반환 값이 순간적인 상태를 반영하기 때문에 멀티 스레드 환경에서는 신뢰할 수 없기 때문


3. std::weak_ptr

▸ 정의와 특징

std::shared_ptr와 유사하지만, 가리키는 객체의 수명에 영향을 주지 않는 약한 참조를 제공한다는 점에서 다름
순환 참조를 피하는데 유용

💡 순환 참조
: 두 객체가 서로를 참조하고, 둘 다 shared_ptr를 사용하여 참조를 유지하는 경우에 발생
이 경우, 두 객체 모두 레퍼런스 카운트가 절대 0이 되지 않아 메모리 누수 발생

struct B;
struct A
{
	std::shared_ptr<B> b_ptr;
};

struct B
{
	std::shared_ptr<A> a_ptr;
};

std::shared_ptr<A> a(new A());
std::shared_ptr<B> b(new B());

a->b_ptr = b;
b->a_ptr = a; // 순환 참조 생성

weak_ptr를 사용하여 위와 같은 순환 참조 상황을 해결할 수 있음

struct B;
struct A
{
	std::shared_ptr<B> b_ptr;
};

struct B
{
	std::weak_ptr<A> a_ptr; // 약한 참조 사용
};

std::shared_ptr<A> a(new A());
std::shared_ptr<B> b(new B());

a->b_ptr = b;
b->a_ptr = a; // 이제 순환 참조가 발생되지 않음

이제 B는 weak_ptr를 사용하여 A를 참조하므로 순환 참조가 발생하지 않음
A가 파괴되면 weak_ptr는 자동으로 nullptr로 설정됨


▸ 사용법

shared_ptr<int> sp(new int(10)); 
weak_ptr<int> wp(sp); // shared_ptr로부터 weak_ptr 생성

weak_ptr는 약한 참조만을 제공하므로, 객체에 직접 접근하려면 shared_ptr로 변환해야 함 → 이를 락(lock)이라 부름

if(shared_ptr<int> sp = wp.lock())
{
	// 객체에 접근
}

lock 메소드는 해당 객체가 아직 메모리에 있을 때 shared_ptr를 반환하며, 그렇지 않을때는 빈 shared_ptr를 반환함
→ lock 메서드를 사용하여 객체가 메모리에 있는지 확인할 수 있음

if (wp.expired())
{
	// 객체는 더 이상 메모리에 없음
}

또한, weak_ptr는 expired 메소드를 제공하여 객체가 메모리에 있는지 확인할 수 있음
❗ 이 메소드는 객체의 상태를 확인하고 객체에 접근하는 사이에 객체가 해제될 수 있으므로, 안전한 객체 접근을 위해서는 항상 lock 메서드를 사용해야 함


< 참고 자료 >

스마트 포인터

0개의 댓글