C++11 스마트 포인터

지금은 더 이상 사용하지 않는 스마트 포인터 std::auto_ptr가 C++11 이전에도 있었다. 하지만 몇가지 문제점이 있었는데, 배열의 포인터를 해제할 때 배열 객체가 모두 제대로 해제되지 않는다는 것과 복사 대입 연산시 실제로는 복사가 되지 않는 다는 것이었다. 후자는 상식적으로 잘 이해가 되지 않을 수도 있는데 C++98 표준에서는 복사로 이동을 대신했었다. 유식한 말로 복사 시맨틱이라고 하는데 이것이 개발자들을 상당히 곤란하게 만들었었다. 아래 코드를 좀 보자.

#include <memory>

int main() {
    std::auto_ptr<int> foo(new int(3));
    
    std::auto_ptr<int> bar = foo; // 이때 foo는 null_ptr가 되어버린다.
    
    return 0;
}

std::auto_ptr의 복사 대입 연산자 함수 구현를 보았다면 이렇게 될 걸 예견할 수 있지만, 그렇지 않다면 그걸 미리 알기는 쉽지 않다.

이러한 문제점들을 보완하기 위해 C++11 표준에서는 새로운 스마트 포인터들이 포함됐다. 그리고 이동 시맨틱이 추가되었다. 이동 시맨틱은 객체를 복사하지 않고 이동시킨다. 이동 후에 객체의 소유권은 당연히 대입된 쪽이 가진다. 복사 시맨틱일 경우, STL 컨테이너 중 리스트나 벡터는 동적 배열이기 때문에 상황에 따라 그 메모리 크기가 두배까지 늘어난다. 그러고 나선 정작 원본은 해제시켜버린다. 이런 일련의 동작들이 메모리를 낭비하고 성능을 저하시키기 때문에 이동 시맨틱은 꼭 필요한 것이었다.


1. unique_ptr

C++11에선 std::unique_ptr라는 새로운 고유 포인터 타입을 도입했다. std::auto_ptr의 하위 호환이다. std::unique_ptr는 복사 생성자와 복사 대입 연산자가 아예 구현되어 있지 않다. 그렇기 때문에 복사가 애초에 불가능하고 이동만 가능하다. 이동은 std::move() 함수로만 가능하다. 포인터는 get() 멤버 함수로 얻을 수 있고 메모리 해제는 reset() 멤버 함수로 한다.

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> foo(new int(3));
    
    // auto baz = foo; // 복사할 수 없다.
    auto bar = std::move(foo); // bar로 이동
    
    std::cout << foo.get() << std::endl; // null_ptr기 때문에 0 출력
    std::cout << bar.get() << std::endl;
    
    foo.reset(); // 이미 이동되었기 때문에 아무 동작도 수행되지 않는다.
    bar.reset(); // 메모리 해제
    
    return 0;
}

포인터를 복사할 수 없는거지 포인터가 가리키는 객체를 복사할 수 없는 것은 아니다. 또 복사 대입 연산시 좌항을 참조자로 선언하고 정의하는 것도 포인터의 포인터를 통해 참조하는 것이기 때문에 가능하다.

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> foo(new int(3));
    
    auto bar = *foo;
    auto& baz = foo;
    
    // 포인터 출력
    std::cout << foo.get() << std::endl;
    std::cout << &bar << std::endl; // 컴파일러가 bar의 데이터 타입을 int로 추정
    std::cout << baz.get() << std::endl; // 참조자로 선언하고 정의했기 때문에 별 문제없이 출력된다.
    
    // 객체 출력
    std::cout << *foo << std::endl; // 3
    std::cout << bar << std::endl; // 3
    std::cout << *baz << std::endl; // 3
    
    return 0;
}

마지막으로 한가지 유의할 점이 있다. const std::unique로 선언된 포인터는 std::move()로도 이동시킬 수 없다. 비트 수준의 상수성을 가지고 있기 때문에 데이터를 다른 곳으로 이동을 시킬 수 없어서 그렇다.

#include <memory>

int main() {
    const std::unique_ptr<int> foo(new int(3));
    
    auto bar = std::move(foo); // error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'
    
    return 0;
}

2. shared_ptr

std::shared_ptr는 이름처럼 가리키는 객체의 소유권을 다른 포인터들과 공유할 수 있는 포인터다. std::unique_ptr과는 다르게 복사도 마음껏 할 수 있다. 같은 객체를 가리키는 std::shared_ptr은 레퍼런스 카운팅으로 추적된다. 참조된 횟수를 세는 것이므로 포인터가 복사될 때 마다 1씩 증가한다. 그리고 해제될 때 마다 1씩 감소한다. 포인터가 가리키는 객체의 메모리가 해제되는 시점은 레퍼런스 카운트가 0이 될 때이다. 레퍼런스 카운트는 use_count() 멤버 함수로 가져올 수 있다.

신기하게도 std::shared_ptr 객체가 복사되어도 메모리 공간은 늘어나지 않는다. 객체의 메모리 공간은 그대로 두고 앞서 이야기한대로 레퍼런스 카운트만 건드리기 때문에 그렇다.

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> foo(new int(3)); // reference count = 1
    auto bar = foo; // reference count = 2
    
    std::cout << "reference count: " << bar.use_count() << std::endl;
    
    foo.reset(); // reference count = 1
    
    std::cout << "reference count: " << bar.use_count() << std::endl;
    
    bar.reset(); // reference count = 0, 이떄 객체가 완전히 해제된다.
    
    return 0;
}

레퍼런스 카운트가 0이 되어 참조하는 객체를 해제할 때 std::shared_ptrdelete 연산자를 사용한다. 여기에 문제가 있다. 뭐가 문제냐고? delete[]는 사용하지 않는 것이 문제다.

#include <memory>
#include <vector>

int main() {
    std::shared_ptr<int> foo(new int[1024]); // 이렇게 배열의 포인터를 선언하지 말고
    
    foo.reset();
    
    std::vector<std::shared_ptr<int>> bars; // 포인터 벡터를 만들면 해결된다.
    bars.push_back(std::shared_ptr<int>(new int(3)));
    
    for(auto& bar : bars) {
        bar.reset();
    }
}

이렇게 해결할 수도 있지만 전혀 섹시하지 않다. std::shared_ptr의 기본 생성자를 이용해서 해결하면 조금 더 섹시해질 수 있다. std::shared_ptr의 기본 생성자는 아래와 같이 정의되어 있다.

constexpr shared_ptr() noexcept; // (1)

constexpr shared_ptr( std::nullptr_t ) noexcept; // (2)

template< class Y >
explicit shared_ptr( Y* ptr ); // (3)

template< class Y, class Deleter >
shared_ptr( Y* ptr, Deleter d ); // (4)

template< class Deleter >
shared_ptr( std::nullptr_t ptr, Deleter d ); // (5)

template< class Y, class Deleter, class Alloc >
shared_ptr( Y* ptr, Deleter d, Alloc alloc ); // (6)

4번째 기본 생성자를 보면 두번째 인자로 Deleter 함수 객체를 받고 있다. 여기에 우리가 원하는 Deleter 함수 객체를 넣어주면 std::shared_ptr가 그것으로 객체를 해제할 것이다.

#include <memory>

template<typename T>
struct ArrayDeleter {
    void operator() (T* ptr) {
        delete[] ptr;
    }
};

int main() {
    std::shared_ptr<int> foo(new int[1024], ArrayDeleter<int>());
    
    foo.reset(); // 이제 배열도 제대로 해제될 것이다.
    
    return 0;
}

Deleter로 함수 객체가 아닌 lambda를 넘겨주어도 된다.

#include <functional>
#include <memory>

template<typename T>
std::function<void (T*)> array_deleter() {
    return [](T* ptr) { delete[] ptr; };
}

int main()
{
    std::shared_ptr<int> foo(new int[1024], array_deleter<int>());
    
    foo.reset();
    
    return 0;
}

C++11 lambda는 template을 지원하지 않기 때문에 lambda를 반환하는 템플릿 함수를 정의하는 트릭을 썼다.


참조 객체의 형변환

static_pointer_cast<>() 함수로 std::shared_ptr가 가리키는 객체의 형을 변환할 수 있다.

#include <iostream>
#include <memory>

class Car {
};

class Sedan : public Car {
public:
    Sedan(int price) : price(price) {};
    void set_price(int price) { this->price = price; };
    int get_price() { return this->price; };

private:
    int price;
};

int main() {
    std::shared_ptr<Sedan> camry(new Sedan(3000));
    std::static_pointer_cast<Car>(camry);

    std::cout << camry.use_count() << std::endl; // 1

    auto _camry = std::static_pointer_cast<Sedan>(camry);

    std::cout << camry.use_count() << std::endl; // 2
}

shared_ptr의 문제점
  • 순환 참조

순환 참조는 보통 그룹 객체와 소속 객체 사이에 발생한다. 서로가 서로를 참조하고 있는 상황이기 때문에 메모리 해제가 제대로 안되는 상황이 발생한다.

  • 멀티쓰레드 안정성

다수의 쓰레드에서 같은 각체를 참조하는 경우가 있을 수 있다. 이때 읽기는 안전하지만 쓰기는 안전하지 않다.


3. weak_ptr

std::shared_ptr가 참조하는 객체는 레퍼런스 카운트가 0이 되는 시점에 같이 해제된다고 했었다. 이러한 특징때문에 발생하는 심각한 문제가 있다. 두 클래스가 서로의 클래스 인스턴스 즉, 서로의 객체를 std::shared_ptr로 참조하고 있으면 두 객체 모두 영원히 reset() 멤버 함수로 해제될 수 없다. 이런 문제는 그룹 객체와 소속 객체 사이에서 자주 나타난다. 이럴 때 어느 한쪽에서 std::shared_ptr가 아닌 std::weak_ptr로 다른 객체를 참조하면 문제를 해결할 수 있다. std::weak_ptr은 객체의 레퍼런스 카운트에 포함되지 않기 때문에 가능한 일이다. 사실 객체의 참조는 강한 참조와 약한 참조로 나뉘는데 객체의 생명 주기에 관여하는 참조가 강한 참조이다. std::weak_ptr는 이름에서 알 수 있듯이 약한 참조이다. 따라서 std::weak_ptr을 통해서 멤버 변수나 함수에 접근할 수 없고 포인터에도 직접 접근할 수 없다.

std::weak_ptr의 기본 생성자, 복사 생성자, 복사 대입 연산자는 아래와 같다.

// constructor
constexpr weak_ptr() noexcept;

weak_ptr( const weak_ptr& r ) noexcept;

template< class Y >
weak_ptr( const weak_ptr<Y>& r ) noexcept;

template< class Y >
weak_ptr( const std::shared_ptr<Y>& r ) noexcept;

weak_ptr( weak_ptr&& r ) noexcept;

template< class Y >
weak_ptr( weak_ptr<Y>&& r ) noexcept;

// operator=
weak_ptr& operator=( const weak_ptr& r ) noexcept;

template< class Y >
weak_ptr& operator=( const weak_ptr<Y>& r ) noexcept;

template< class Y >
weak_ptr& operator=( const shared_ptr<Y>& r ) noexcept;

weak_ptr& operator=( weak_ptr&& r ) noexcept;

template< class Y >
weak_ptr& operator=( weak_ptr<Y>&& r ) noexcept;

한번 쭉 보면 알 수 있듯이 같은 형 또는 std::shared_ptr의 객체만 그 인자로 받고 있다. 형 변환도 std::shared_ptr로만 가능하다. 그리고 std::weak_ptr은 포인터에 직접 접근을 할 수 없기 때문에 lock() 멤버 함수로 std::shared_ptr 객체를 생성한 다음 그 객체를 통해서 포인터에 접근해야 한다.

#include <memory>

int main() {
    std::shared_ptr<int> foo(new int(3)); // reference count = 1
    std::weak_ptr<int> bar = foo; // reference count = 1
    
    {
        auto baz = bar.lock(); // reference count = 2
    } // 이 closure를 벗어나면서 baz는 해제된다. reference count = 1
    
    foo.reset(); // reference count = 0
    
    return 0;
}

아래는 std::weak_ptr를 이용해서 그룹 객체와 소속 객체의 순환 참조 문제를 해결한 예시이다.

#include <memory>

class Tenant;

class Room {
public:
    Room() {}
    ~Room() { release_tenant(); }

    void set_tenant(const std::shared_ptr<Tenant>& tenant) {
        this->tenant = tenant;
    }

    void release_tenant() {
        this->tenant.reset();
    }

private:
    std::shared_ptr<Tenant> tenant;
};

class Tenant {
public:
    void set_room(const std::shared_ptr<Room>& room) {
        this->room = room;
    }

    void leave_room() {
        if(!this->room.expired()) {
            std::shared_ptr<Room> room = this->room.lock();

            if(room) {
                room->release_tenant();
            }
        }
    }
private:
    std::weak_ptr<Room> room;
};

int main() {
    /*
     * 아래 표시된 숫자들은 해당 객체의 레퍼런스 카운트
     */

    std::shared_ptr<Room> room(new Room()); // room: 1

    {
        std::shared_ptr<Tenant> tenant(new Tenant()); // room:1, tenant: 1

        room->set_tenant(tenant); // room: 1, tenant: 2
        tenant->set_room(room); // room: 1, tenant: 2
    } // room: 1, tenant: 1

    room.reset(); // room: 0, tenant: 0
}
You've successfully subscribed to devkoriel
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.