네이키드 포인터 (naked pointer)

우리가 흔히 말하는 *를 이용하여 생성된 그 포인터를 말함 (plain pointer).

#include <memory>

struct X { int a,b,c; };

int main()
{
    X* np = new X;
    delete np;
    return 0;
}

여기서 np 변수가 네이키드 포인터를 말한다. np는 동적으로 할당되어 힙메모리 안에 저장이 되는데, 자원의 활용이 끝나면 delete를 활용하여 객체에 할당된 메모리를 반드시 해제해 줘야만 메모리 누수를 막을 수 있다. 해제하지 않으면 힙메모리가 자원을 계속 들고 있어서 성능상 문제가 발생하기 때문이다.

이렇게 개발자가 직접 메모리 해제에 관여해줘야 하는 네이키드 포인터와 달리, 개발자가 직접 메모리 해제에 관여하지 않아도 되도록 설계된 포인터가 스마트 포인터.

스마트 포인터

C++11부터 등장한 새로운 포인터 선언 방식.

스마트 포인터 사용을 위해서는 위의 코드에서 보이듯 #include <memory> 선언을 해줘야 한다.

[예제 1-1] 스마트 포인터를 활용한 예제 코드

#include <iostream>
#include <memory>

using namespace std;

class Blablabla {

    public:
        Blablabla() {
            cout << "자원 획득" << endl;
        }

        void call() {
            cout << "did you call me?" << endl;
        }

        ~Blablabla() {
            cout << "자원 해제" << endl;
        }
};

int main() {
    unique_ptr<Blablabla> mB1(new Blablabla());
    mB1->call();
    return 0;
}

여기서 unique_ptr이 스마트 포인터인데, 스마트 포인터 종류로는 unique_ptr, shared_ptr, weak_ptr 세가지가 있다. 우선 이 문서에서는 unique_ptr, shared_ptr 둘만 다루도록 하겠다.

(1) unique_ptr

여기서 unique는 유일하다는 의미로 쓰여서 '리소스에 대한 유일한 소유권'을 갖는 포인터라는 뜻. 달리 말해서 다른 객체가 unique_ptr로 선언된 리소스에 대한 소유권을 가질 수 없다. 여기서 말하는 소유권을 가질 수 없다는 것은 "타 객체가 이 객체로의 접근을 할 수 없다"라는 뜻 정도 되는것 같다.

[예제 1-2] unique_ptr로 선언된 리소스에 대하여 다른 객체로부터 접근 시도

unique_ptr<Blablabla> mB2 = mB1;
mB2->call();

위의 예제에서는 mB2 객체를 선언한 후, 이 객체가 mB1에 접근하도록 선언해 주고 있는데, 이를 컴파일 하면 에러가 발생한다.

marshall@marshall-IdeaPad-5-15ITL05:~/learn-cpp$ g++ smart_pointer.cpp 
smart_pointer.cpp: In function ‘int main()’:
smart_pointer.cpp:26:30: error: use of deleted function ‘std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = Blablabla; _Dp = std::default_delete<Blablabla>]’
   26 |  unique_ptr<Blablabla> mB2 = mB1;
      |                              ^~~
In file included from /usr/include/c++/9/memory:80,
                 from smart_pointer.cpp:2:
/usr/include/c++/9/bits/unique_ptr.h:414:7: note: declared here
  414 |       unique_ptr(const unique_ptr&) = delete;
      |       ^~~~~~~~~~

에러 메시지에서 말하길 삭제된 함수를 사용하려 한다고 하는데, 이 메시지의 내용을 좀 더 들여다볼 필요가 있다.

(2) shared_ptr (공유 포인터)

unique_ptr과 다르게 shared_ptr을 이용하면 객체 공유가 가능해진다. 예제 1-1의 메인함수 내용을 다음과 같이 고친다.

shared_ptr<Blablabla> mB1(new Blablabla());
mB1->call();

shared_ptr<Blablabla> mB2 = mB1;
mB2->call();

이를 실행하면 다음과 같이 출력한다.

자원 획득
did you call me?
did you call me?
자원 해제

mB1, mB2가 동일한 객체를 소유하고 있다 보니, 함수 종료 후 소멸자 호출도 하나의 객체에서만 이루어짐으로써 '자원 해제'라는 메시지가 한번만 나오는 것을 확인할 수 있다.

shared_ptr를 쓸 때 유의할 점

공유 포인터의 특징은 앞에서 말했듯 한 객체에 여러 공유 포인터가 접근할 수 있다는 점이다.

ClassA* a = new ClassA();
shared_ptr<A> pA1(a);
shared_ptr<A> pA2(a);

이렇게 선언을 하면 pA1, pA2 둘 다 a를 바라보게 된다. 각 공유 포인터 pA1, pA2는 어떤 다른 공유 포인터가 같은 객체를 공유하고 있는지를 알 수 없다. 다시 말해서 각 공유 포인터는 각자의 고유 레퍼런스 카운트를 갖고 있게 된다. 만일 pA1의 레퍼런스 카운트가 0이 된다면 프로그램에서는 pA1이 가리키고 있는 객체 a를 소멸시켜 버릴텐데, 그러면 pA2가 아직 레퍼런스 카운트가 0이 아닌 상태인데, 여기서 a에 대한 접근을 시도하게 될 경우 참조 에러가 발생한다.

[예제 2] 두 공유 포인터

#include <iostream>
#include <memory>

using namespace std;

class Blablabla {

    public:
        Blablabla() {
            cout << "자원 획득" << endl;
        }

        void call() {
            cout << "did you call me?" << endl;
        }

        ~Blablabla() {
            cout << "자원 해제" << endl;
        }
};

int main() {
    Blablabla *b = new Blablabla();
    shared_ptr<Blablabla> mB1(b);
    shared_ptr<Blablabla> mB2(b);

    cout << mB1.use_count() << endl;
    cout << mB2.use_count() << endl;
    return 0;
}

위 코드 자체는 문제없이 컴파일 된다. use_count 함수는 동일하게 참조되는 객체를 참조하는 공유포인터의 수를 리턴한다. [https://en.cppreference.com/w/cpp/memory/shared_ptr/use_count](여기 참조). 다만 실행해 보면 다음과 같이 정상적으로 실행되지 않는 것을 볼 수 있다.

marshall@marshall-IdeaPad-5-15ITL05:~/learn-cpp$ ./a.out 
자원 획득
1
1
자원 해제
자원 해제
free(): double free detected in tcache 2
Aborted (core dumped)

같은 객체에 대해 메모리 해제를 두번 해주려 하다가 문제가 발생했다는 메시지다.

다음 시간에는 weak_ptr 개념에 대한 소개, shared_ptr 포인터 사용시 발생하는 문제점을 해결하는 방법 등에 대한 내용을 싣겠다.

반응형

+ Recent posts