본문으로 바로가기

Smart Pointer

앞선 글에서 객체의 Life cycle / Large size / Dynamic allocation을 이유로 힙에 객체를 생성하고, 스택에 있는 포인터로 힙에 있는 객체의 주소를 저장하여 활용한다고 설명하였다. (Heap에 대한 설명 보기) 이를 위해서 C++ 언어는 포인터를 지원한다. 언어에서 지원하는 포인터는 포인터 타입의 변수에 주소만 담기 때문에 원시 포인터라고 한다. 이 원시 포인터는 항상 Memory leak이 발생할 위험이 존재하기 때문에 사용함에 있어서 많은 주의가 요구된다.

 

조금 더 안전하면서도, 원시 포인터의 우수한 성능은 활용하기 위해서는 스마트 포인터를 사용할 수 있다. 스마트 포인터는 원시 포인터를 모방한 템플릿 타입의 객체이며 포인터처럼 사용할 수 있지만, 다음과 같은 차이가 있다.

  • 스마트 포인터는 자유 공간에 할당된 메모리의 주소만 저장할 수 있다.
  • 스마트 포인터는 증가, 감소 같은 산술 연산을 사용 할 수 없다.

 

자유 공간에 생성된 객체에는 원시 포인터보단 스마트 포인터를 사용하는 것이 훨씬 더 좋다. 스마트 포인터를 사용하면 객체가 더는 필요하지 않을 때 객체에 할당된 메모리를 자동으로 해제하기 때문이다. 더 이상 개발자는 delete를 언제 사용할지 고민하지 않아도 되고, 메모리 누수를 걱정하지 않아도 되기 때문이다.

std 네임스페이스에는 다음 세 가지 스마트 포인터가 정의되어 있다.

  • unique_ptr
  • shared_ptr
  • weak_ptr

unique_ptr

unique_ptr은 Resource에 대한 접근 권한을 독점을 제공하는 포인터 이다. 즉 unique_ptr을 사용할 경우 하나의 Resource에 두 개 이상의 포인터가 접근할 수 없다. 그래서 unique_ptr을 사용하게 될 경우 개발자는 소유권에 대한 고민을 할 필요가 없어진다. 아래의 코드를 살펴보자.

#include <iostream>

class A {
public:
    A() { std::cout << "A()" << std::endl; }
    ~A() { std::cout << "~A()" << std::endl; }
};
void foo(A* Ap)
{
    A* fooAp = Ap;
    delete fooAp;
}
int main()
{
    A* Ap_1 = new A();

    foo(Ap_1);
    delete Ap_1;

    return 0;
}

개발을 하다보면 위 예제 코드처럼 하나의 객체를 두 번 해제해서 프로그램을 죽이는 실수를 할 때가 있다. 만약 규모가 큰 프로젝트내에서 특별한 경우에만 가끔 호출되는 곳에 이런 오류가 있다면, 찾아내기는 어려우면서도 치명적인 버그가 될 것이다.

 

이 코드의 원시 포인터를 아래와 같이 스마트 포인터로 수정할 수 있다. 스마트 포인터를 사용하기 위해서는 <memory> 헤더를 추가해야한다.

#include <iostream>
#include <memory>

class A {
public:
    A() { std::cout << "A()" << std::endl; }
    ~A() { std::cout << "~A()" << std::endl; }
};
void foo(std::unique_ptr<A> Ap)
{
    std::cout << "foo()" << std::endl;
}
int main()
{
    std::unique_ptr<A> Ap = std::make_unique<A>();
    foo(Ap);

    return 0;
}

이 코드를 실행하면 컴파일 에러가 발생하여 컴파일조차 되지 않는다. 이렇게 unique_ptr을 사용하게 되면 하나의 오브젝트를 여러 포인터에서 조작해서 발생할 수 있는 다양한 문제를 컴파일 단계에서 원천적으로 예방할 수 있다. 때문에 unique_ptr을 사용하면 개발자는 소유권에 대한 고민을 할 필요가 없어지기 때문에 더욱 안전한 프로그램을 작성할 수 있게 된다. 지금부터는 unique_ptr을 어떻게 사용하는지 자세히 살펴본다.

unique_ptr 객체 생성

먼저 가장 기본적인 방법으로 unique_ptr의 생성자 인수로 객체의 주소를 전달하는 방법이다.

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // unique_ptr 생성하기
    // 자유 공간에 생성된 string 객체의 주소를 unique_ptr<string> 생성자에 전달하는 방법
    std::unique_ptr<std::string> pStr1{ new std::string {"new string"} };
    std::cout << *pStr1 << std::endl;
    return 0;
}

출력 결과

new string

다음으로 보편적으로 많이 사용되는 방법인 make_unique<T>() 함수 템플릿을 사용하는 방법이다. make_unique<T>() 매개변수는 T 객체의 생성자에 전달할 매개변수를 사용한다.

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // make_unique<T>() 함수 템플릿을 사용하는 방법
    // make_unique<T>() 함수의 인수를 T 생성자의 인수로 전달해서 원시 포인터를 만든다.
    std::unique_ptr<std::string> pStr2 = std::make_unique<std::string>("new string");
    std::cout << *pStr2 << std::endl;

    std::unique_ptr<std::string> pStr3 = std::make_unique<std::string>(6, '*');
    std::cout << *pStr3 << std::endl;
    return 0;
}

출력 결과

new string
******

unique_ptr은 자유공간에 배열을 동적으로 생성할 때도 사용할 수 있다.

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // 자유공간에 생성하는 배열을 가리키는 unique_ptr 객체도 만들 수 있다.
    size_t len{ 10 };
    std::unique_ptr<int[]> pnumbers1 { new int[len] };
    for (size_t i{}; i < len; ++i)
    {
        pnumbers1[i] = i * 10;
    }

    std::cout << "pnumbers1: ";
    for (size_t i{}; i < len; ++i)
    {
        std::cout << pnumbers1[i] << " ";
    }
    std::cout << std::endl;

    std::unique_ptr<int[]> pnumbers2 = std::make_unique<int[]>(len);
    for (size_t i{}; i < len; ++i)
    {
        pnumbers2[i] = i * 20;
    }

    std::cout << "pnumbers2: ";
    for (size_t i{}; i < len; ++i)
    {
        std::cout << pnumbers2[i]<< " ";
    }
    std::cout << std::endl;
    return 0;
}

출력 결과

pnumbers1: 0 10 20 30 40 50 60 70 80 90
pnumbers2: 0 20 40 60 80 100 120 140 160 180

unique_ptr 소유권 이전 연산

unique_ptr은 여러 포인터가 하나의 오브젝트를 소유하는 것은 불가능 하지만 소유권을 다른 포인터에게 이전하는 것은 다음과 같은 방법으로 가능하다.

#include <iostream>
#include <memory>
#include <string>

void foo(std::unique_ptr<std::string> pname3)
{
    std::cout << *pname3 << std::endl;
}
int main()
{
    std::unique_ptr<std::string> pname1 = std::make_unique<std::string>("Jack");
    std::cout << *pname1 << std::endl;

    // move 함수를 통해서 소유권 이전이 가능하다.
    std::unique_ptr<std::string> pname2 = std::move(pname1);
    std::cout << *pname2 << std::endl;

    // move 함수를 사용하면 함수의 매개변수에게 소유권 이전도 가능하다.
    foo(std::move(pname2));

    return 0;
}

그리고 unique_ptr은 클래스의 멤버 변수가 포인터를 변수로 가져야 할 때 유용하게 사용된다.

unique_ptr 객체 초기화

스마트 포인터가 소멸될 때 포인터가 가리키는 객체도 소멸된다. 만약, 스마트 포인터 객체를 소멸시키지 않고 포인터가 가리키는 객체만 초기화시키고 싶다면 아래의 세 가지 멤버 함수를 사용할 수 있다.

  • rest() : 가리키는 객체 소멸, 스마트 포인터는 nullptr 혹은 다른 대상을 가리킨다.
  • release() : 가리키는 객체를 소멸하지 않고 주소 값을 반환. 스마트 포인터는 nullptr을 가리킨다.
  • swap() : 두 개의 스마트 포인터가 가리키는 대상을 서로 교환한다.

rest() 함수 사용 예시

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // reset(): 유니크 포인터가 가리키는 객체 소멸, 유니크 포인터는 nullptr을 가리킴
    std::unique_ptr<std::string> pname1 = std::make_unique<std::string>("Algernon");
    pname1.reset();

    // reset(new T()): 유니크 포인터가 가리키는 객체 소멸, 유니크 포인터는 새로운 객체를 가리킴
    std::unique_ptr<std::string> pname2 = std::make_unique<std::string>("Algernon");
    pname2.reset(new std::string{ "Fred" });

    std::unique_ptr<std::string> pname3 = std::make_unique<std::string>("Algernon");
    // 이렇게 작성하면 안됨. 컴파일 에러는 발생하지 않지만, pname3과 str이 소멸될 때 객체를 두번 소멸시켜 충돌발생 
    std::string str;
    pname3.reset(&str);  // str 변수가 소멸할때 두 번 소멸되며 충돌 발생
    return 0;
}

release() 함수 사용 예시

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // release(): 원본 객체의 메모리를 해제하지 않고 유니크 포인터의 원시 포인터를 nullptr로 설정
    // 그리고 가리키고 있던 객체의 원시 포인터를 반환.
    std::unique_ptr<std::string> pname4 = std::make_unique<std::string>("Algernon");
    std::unique_ptr<std::string> pname5{ pname4.release() };
    return 0;
}

swap() 함수 사용 예시

#include <iostream>
#include <memory>
#include <string>

int main()
{
    // swap
    std::unique_ptr<std::string> pname6 = std::make_unique<std::string>("Jack");
    std::unique_ptr<std::string> pname7 = std::make_unique<std::string>("Jill");
    // pname6 과 pname7이 가리키는 대상을 교환한다.
    pname6.swap(pname7);

    return 0;
}

unique_ptr 원시 포인터 획득과 비교

unique_ptr을 활용하면서 원시 포인터가 필요할 때가 있을 수 있다. 그럴경우 get() 함수로 원시 포인터를 획득 할 수 있다.

#include <iostream>
#include <memory>
#include <string>

int main() {
    
    std::unique_ptr<std::string> uniqStr = std::make_unique<std::string>("Algernon");
    std::string* pStr = uniqStr.get();

    std::cout << *pStr;

    return 0;
}

출력 결과

Algernon

unique_ptr 객체는 bool 타입으로 암시적으로 변환될 수 있다. 유니크 포인터가 nullptr이면 변환 결과는 false가 되기 때문에 다음과 같이 nullptr 체크를 할 수 있다.

#include <iostream>
#include <memory>
#include <string>

int main() {
    
    std::unique_ptr<std::string> uniqStr = std::make_unique<std::string>("Algernon");
    if (uniqStr)
        std::cout << *uniqStr << std::endl;

    uniqStr.reset();

    if (!uniqStr)
        std::cout << "uniqStr is nullptr! "<< std::endl;

    return 0;

출력 결과

Algernon
uniqStr is nullptr!

unique_ptr의 실질적인 응용

unique_ptr은 클래스의 멤버 변수로 포인터를 가질 때 유용하게 사용될 수 있다. 아래의 코드는 다형성 구조에서 자주 볼 수 있는 멤버 변수로 포인터를 가지는 예시 코드이다.

class Animal {
public:
    Animal(int age) { mAge = age; }
    ~Animal() {}
private:
    int mAge;
};

class Cat : public Animal
{
public:
    Cat() : Animal(1) {}
    ~Cat() {}
};

class Dog : public Animal
{
public:
    Dog() : Animal(1) {}
    ~Dog() {}
};

class Zoo
{
public:
    Zoo(int type)
    {
        if (type == 1)
        {
            mAnimal = new Cat();
        }
        else if (type == 2)
        {
            mAnimal = new Dog();
        }
    }
    // rule of three, ~/copy/move 를 만들어야 함
private:
    Animal *mAnimal;
};

멤버 변수로 포인터가 있다면 다음의 내용들을 개발자가 신경 써야 한다.

  • rule of three라는 규약에 따라서 개발자가 소멸자, 복사 생성자, 이동 생성자를 만들어야 한다.
  • 또한, mAnimal 포인터가 가리키는 객체는 클래스 외부에서도 참조할 수 있고 삭제할 수 있다는 위험이 있다.

하지만 아래와 같이 unique_ptr을 활용하면 이런 부분을 신경 쓰지 않아도 된다.

class Zoo
{
public:
    Zoo(int type)
    {
        if (type == 1)
        {
            mAnimal = std::make_unique<Cat>();
        }
        else if (type == 2)
        {
            mAnimal = std::make_unique<Dog>();
        }
    }

private:
    std::unique_ptr<Animal> mAnimal;
};

이렇게 unique_ptr을 사용하게 될 경우 포인터 변수 때문에 직접 소멸자나 복사 생성자, 이동 생성자를 구현할 필요가 없고, 외부에서 mAnimal 포인터가 가리키는 객체는 오직 mAnimal 포인터가 독점적으로 가리키고 있음을 보장하기 때문에 외부에서 객체를 소멸시킬 걱정을 하지 않아도 된다.


참조:

유튜브 채널 - 코드없는 프로그래밍: https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg

길벗 출판사 - C++14 STL 철저 입문