1.6 스마트 포인터 - unique_ptr

SeungHee Yun·2023년 8월 23일
0

전문가를 위한 C++

목록 보기
6/15

개요

C++에서 자원은 매우 중요합니다.
컴퓨터에서 자원( Resource )라 하면 여러 가지를 꼽을 수 있지만, 예를 들어보자면
할당한 메모리도 자원이고 Open 한 파일 역시 하나의 자원이라 할 수 있습니다.

당연하게도, 자원의 양은 프로그램마다 한정적이기 때문에
관리를 잘 해주어야 합니다. 이 말은 즉, 사용이 끝난 자원은 반드시 반환을 해서
다른 작업 때 사용할 수 있도록 해야 합니다.


자원 관리의 중요성

C++ 이후에 나온 많은 언어들은 대부분
가비지 컬렉터( Garbage Collector - GC )라 불리는
자원 청소기가 내장되어 있습니다. 이름에서 직관적으로 알 수 있듯 프로그램 상에서
더 이상 쓰이지 않는 자원을 자동으로 해제해 주는 역할을 합니다.

하지만 C++의 경우는 다릅니다.
한 번 획득한 자원은 직접 해제하지 않는 이상 프로그램이 종료되기 전까지 영원히 남아있게 됩니다. ( 프로그램 종료 시 운영체제가 해제 )

간단한 예시로,

#include <iostream>

class A 
{
    int* data;

public:
    A() 
    {
        data = new int[100];
        std::cout << "자원을 획득함!" <<std::endl;
    }
    
    ~A()
    {
        std::cout << "소멸자 호출!" << std::endl;
        delete[] data;
    }
};

void do_something() { A* pa = new A(); }

int main()
{
    do_something();
}

이를 성공적으로 컴파일 한다면

자원을 획득함!

으로 생성자만 호출 되고, 소멸자는 호출되지 않은 점을 확인할 수 있습니다.
그 이유는 까먹고

delete pa;

를 하지 않았기 때문이죠.
( delete는 메모리를 해제하기 직전 가리키는 객체의 소멸자를 호출합니다. )

만약 deletedo_something() 안에서 호출 하지 않는다면,
생성된 객체를 가리키던 pa는 메모리에서 사라지게 됩니다.
따라서, Heap 어딘가에 클래스 A의 객체가 남아있지만,
그 주소값을 가지고 있는 포인터는 메모리 상에 존재하지 않게 됩니다.

프로그램의 크기가 커질 수록,
자원을 해제하는 위치가 애매한 경우가 많아져서 숙련된 프로그래머라도
자원 할당 해제를 놓치기 쉽습니다. 다음과 같은 상황을 생각해 봅시다.

#include <iostream>

class A 
{
    int* data;

public:
    A() 
    {
        data = new int[100];
        std::cout << "자원을 획득함!" <<std::endl;
    }
    
    ~A()
    {
        std::cout << "소멸자 호출!" << std::endl;
        delete[] data;
    }
};

void thrower()
{
    throw 1;        // 예외를 발생 시킴
}

void do_something() { A* pa = new A(); }

int main()
{
    try
    {
        do_something();
    }
    catch(int i
    {
        std::cout << "예외 발생!" << std::endl;
    }
}

성공적으로 컴파일 했다면,

자원을 획득함!
예외 발생!

과 같이 나옵니다.
thrower()로 발생된 예외로 인해, 밑에 있는 delete pa가 실행되지 않고 넘어 갔습니다.
물론 예외 처리는 정상이지만, 이로 인한 메모리 누수는 피할 수가 없게 됩니다.

그렇다면 이 상황을 어떻게 해결해야 할까요??


Resource Acquisition Is Initialization - RAII

RAII는 자원의 획득은 초기화 라는 C++ 의 디자인 패턴입니다.
이는 자원 관리를 스택에 할당한 객체를 통해 수행하는 것입니다.

예외가 발생해서 함수를 빠져나가더라도, 함수의 스택에 정의되어 있는
모든 객체들은 빠짐없이 소멸자가 호출됩니다.
이를 Stack Unwinding이라 합니다.
물론 예외가 발생하지 않더라도 함수가 종료되면 소멸자들이 호출 됩니다.

그렇다면 생각을 바꿔 이 소멸자들 안에 다 사용한 자원을 해제하는 루틴을 넣는다면 어떨까요?

위 코드의 pa 같은 경우 객체가 아니기 때문에 소멸자가 호출되지 않습니다.
그렇다면 그 대신, pa를 일반적인 포인터가 아닌, 포인터 객체로 만들어서 소멸 시,
자신이 가리키고 있는 데이터를 같이 delete 하게 하면 됩니다.
즉, 자원(이 경우 메모리)관리를 스택의 객체를 통해 수행하게 되는 것입니다.

이렇게 똑독하게 작동하는 포인터 객체를
스마트 포인터(Smart Pointer)라고 합니다.
C++11 이상부터는 기존에 문제가 있던, auto_ptr 대신
unique_ptrshared_ptr을 제공합니다.


객체의 유일한 소유권 - unique_ptr

사용한 메모리를 해제하지 않아서 생기는 메모리 누수 외에도 다른 문제가 있습니다.

    Data* data = new Data();
    Data* data2 = data;

    // data : 사용이 끝났으니 소멸 해야지
    delete data;

    // data2 : 나도 사용 다 했으니 소멸해야지
    delete data2;

이 경우 datadata2가 동시에 한 객체를 가리키고 있고 delete data를 통해
그 객체를 소멸 시켰습니다.
하지만, data2가 이미 소멸된 객체를 다시 소멸시키려 합니다.

보통 이런 경우, 메모리 오류가 나며 프로그램이 죽게 됩니다.
이렇게 이미 소멸된 객체를 다시 소멸시키며 발생하는 버그를 double free 버그라고 부릅니다.

위와 같은 문제는, 만들어진 객체의 소유권이 명확하지 않아서 입니다.
만약, 우리가 어떤 포인터에 객체의 유일한 소유권을 부여해서, 이 포인터 외에는
객체를 소멸 시킬 수 없다! 라고 한다면 double free 버그는 없을 것입니다.

C++에서는 이렇게, 특정 객체에 유일한 소유권을 부여하는 포인터 객체를 unique_ptr이라고 합니다.

    #include <iostream>
    #include <memory>
    
    class A 
    {
        int *data;
    
    public:
        A() 
        {
            std::cout << "자원을 획득함!" << std::endl;
            data = new int[100];
        }
    
      void some() 
      { 
        std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; 
      }
    
      ~A() 
      {
        std::cout << "자원을 해제함!" << std::endl;
        delete[] data;
      }
    };
    
    void do_something() 
    {
      std::unique_ptr<A> pa(new A());
      pa->some();
    }

    int main() 
    {
        do_something(); 
    }

위 코드에서 pa는 스택에 정의된 객체이기 때문에,
do_something() 함수가 종료될 때,
자동으로 소멸자가 호출됩니다.

그리고 이 unique_ptr은 소멸자 안에서 자신이 가리키고 있는 자원을
해제해 주기 때문에, 자원이 잘 해제될 수 있었습니다.

만약에 unique_ptr을 복사하려고 한다면 어떨까요?

    void do_something() 
{
    std::unique_ptr<A> pa(new A());

    // pb 도 객체를 가리키게 할 수 있을까?
    std::unique_ptr<A> pb = pa;
}

만약 위 코드를 컴파일하려 했다면,
삭제된 함수를 사용하려 했다는 오류가 나오게 됩니다.


삭제된 함수

사용을 원치 않는 함수를 삭제시키는 방법은 C++11에 추가된 기능입니다.

#include <iostream>

class A 
{
public:
    A(int a){};
    A(const A& a) = delete;
};

int main() 
{
    A a(3);  // 가능
    A b(a);  // 불가능 (복사 생성자는 삭제됨)
}

이를 컴파일 한다면, 복사 생성자를 호출하는 부분에서 오류가 발생합니다. 왜냐하면,

A(const A& a) = delete;

와 같이 복사 생성자를 명시적으로 삭제하였기 때문이죠. unique_ptr 도 마찬가지로 unique_ptr 의 복사 생성자가 명시적으로 삭제되었습니다.

그 이유는 unique_ptr 는 어떠한 객체를 유일하게 소유해야 하기 때문이지요.
만일 unique_ptr 를 복사 생성할 수 있게 된다면, 특정 객체를 여러 개의 unique_ptr 들이 소유하게 되는 문제가 발생합니다.

따라서, 각각의 unique_ptr 들이 소멸될 때 전부 객체를 delete 하려 해서 앞서 말한 double free 버그가 발생하게 됩니다.


unique_ptr 소유권 이전

unique_ptr 의 소유권 이전은 가능합니다.

std::unique_ptr<A> pb = std::move(pa);

위와 같이 papb에 강제로 이동시켜버립니다.

이제 pbnew A로 생성된 객체의 소유권을 갖고,
pa는 아무것도 가리키고 있지 않게 됩니다.

따라서 소유권을 이동 시킨 후의 기존 unique_ptr에 접근하지 않도록 조심해야 합니다.

소유권이 이전된 unique_ptr
댕글링 포인터( Dangling Pointer ) 라고 하며,
이를 재참조 시, 런타임 오류가 발생하도록 합니다.
따라서 소유권 이전은, 댕글링 포인터를
절대 다시 참조하지 않겠다는 확신 하에 이동해야 합니다.


unique_ptr을 함수 인자로 전달하기

어떤 unique_ptr 을 함수 인자로 전달하고 싶다면 어떨까요?
unique_ptr은 복사 생성자가 없기 때문에 함수에 레퍼런스 전달을 하면 될까요?

#include <iostream>
#include <memory>

class A {
  int* data;

 public:
  A() {
    std::cout << "자원을 획득함!" << std::endl;
    data = new int[100];
  }

  void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }

  void do_sth(int a) {
    std::cout << "무언가를 한다!" << std::endl;
    data[0] = a;
  }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

// 올바르지 않은 전달 방식
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa);
}

일단, 함수 내부로 unique_ptr이 잘 전달 되었음을 알 수 있습니다.
하지만 위와 같이 unique_ptr을 전달하는 것이 문맥 상 맞는 코드 일까요?

만일 위와 같이 레퍼런스로 unique_ptr을 전달했다면,
do_something 함수 내부에서는
ptr이 유일한 소유권을 의미하지 않습니다.

물론, ptr은 레퍼런스이기 때문에, do_something 함수가 종료되며 pa가 가리키고 있는 객체를 파괴하지는 않습니다.

하지만 pa유일하게 소유하고 있던 객체는 이제 적어도 do_something
내부에서는 ptr을 통해서도 소유할 수가 있게 되는 것입니다.

따라서 unique_ptr을 레퍼런스로 사용하는 것은 unique_ptr을 소유권이라는 중요한 의미를 망각한 채 단순히 포인터의
단순한 Wrapper로 사용하는 것에 불과합니다.

고로, 함수에 올바르게 unique_ptr을 전달하기 위해선,
원래의 포인터 주소값을 전달해 주면 됩니다.

void do_something(A* ptr) { ptr->do_sth(3); }

int main() {
std::unique_ptr<A> pa(new A());
do_something(pa.get());
}

unique_ptr의 get 함수를 호출하면, 실제 객체의 주소값을 리턴합니다.
위 경우 do_something 함수가 일반적인 포인터를 받으며, 소유권이라는 의미는 버린 채, do_something 함수 내부에서 객체에 접근할 수 있는 권한을 주는 것입니다.

정리하자면,

  • unique_ptr은 어떤 객체의 유일한 소유권을 나타내는 포인터이며, 소멸될 때, 가리키던 객체도 소멸된다.
  • 다른 함수에서 unique_ptr이 소유한 객체에 접근하고 싶다면, get을 통해 객체 포인터를 전달한다.
  • 소유권을 이동하고 싶다면, unique_ptrmove 하면 된다.

unique_ptr을 쉽게 생성하기

C++14부터는 unique_ptr을 간단히 만들 수 있는,
std::make_unique 함수를 제공합니다.

#include <iostream>
#include <memory>

class Foo {
  int a, b;

 public:
  Foo(int a, int b) : a(a), b(b) { std::cout << "생성자 호출!" << std::endl; }
  void print() { std::cout << "a : " << a << ", b : " << b << std::endl; }
  ~Foo() { std::cout << "소멸자 호출!" << std::endl; }
};

int main() {
  auto ptr = std::make_unique<Foo>(3, 5);
  ptr->print();
}

이를 컴파일 하면

생성자 호출!
a : 3, b : 5
소멸자 호출!

와 같이 잘 작동합니다.


unique_ptr을 원소로 가지는 컨테이너

#include <iostream>
#include <memory>
#include <vector>

class A {
  int *data;

 public:
  A(int i) {
    std::cout << "자원을 획득함!" << std::endl;
    data = new int[100];
    data[0] = i;
  }

  void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

int main() {
  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(pa);  // ??
}

위 코드를 컴파일 하면 무시무시한 컴파일 오류가 발생합니다.

오류의 이유는 삭제된 unique_ptr의 복사 생성자에 접근하였기 때문입니다.

기본적으로 vector의 push_back 함수는 전달된 인자를 복사해서 집어 넣기 때문에
위와 같은 문제가 발생하게 되는 것입니다.

이의 방지를 위해 명시적으로 pavector안으로 이동 시켜주어야 합니다.
즉, push_back의 우측값 레퍼런스를 받는 버전이 오버로딩 될 수 있도록 말이죠.

int main() {
std::vector<std::unique_ptr<A>> vec;
std::unique_ptr<A> pa(new A(1));

vec.push_back(std::move(pa));  // 잘 실행됨
}

하지만 재미있게도, emplace_back 함수를 이용하면, vector 안에
unique_ptr을 직접 생성하며 집어넣을 수 있습니다.
즉, 불필요한 이동 과정을 생략할 수 있다는 것입니다.

emplace_back 함수는 전달된 인자를 완벽한 전달(Perfect Forwarding)을 통해,
직접 unique_ptr<A>의 생성자에 전달해서, vector 맨 뒤에 unique_ptr<A> 객체를 생성해버리게 됩니다.
따라서, 위에서처럼 불필요한 이동 연산이 필요 없게 됩니다.


참조 : 모두의코드


profile
Enthusiastic Game Developer

0개의 댓글