weak_ptr은 원시 포인터와 shared_ptr사이에 위치한 스마트 포인터이다. 객체를 안전하게 참조할 수 있지만 참조 횟수를 늘리지 않는다. 정확히는 약참조 횟수를 늘리는데 이는 객체의 수명과 관련이 없다.

weak_ptr은 shared_ptr의 단점인 순환참조나 이미 소멸된 자원을 가리키는 문제를 보완하는데 사용된다.

참조횟수를 증가시키지 않기 때문에 순환참조가 발생하지 않고, 역참조가 불가능하기 때문에 반드시 shared_ptr로 변환하여 사용해야 하는데 자원이 이미 소멸된 경우에는 비어있는 shared_ptr을 반환해주므로 문제가 발생하지 않는다.

 

std::shared_ptr<Widget> sp(new Widget);

std::weak_ptr<Widget> wp(sp);
std::weak_ptr<Widget> wp2(wp);

shared_ptr의 문제점을 보완하기 위한 스마트 포인터이기 때문에 원시 포인터를 통해 직접적인 생성은 불가능하고 shared_ptr이나 weak_ptr을 통해서만 생성이 가능하다.

 

weak_ptr로부터 shared_ptr을 생성하는 방법은 두 가지가 있다. 이 때, weak_ptr의 만료여부에 따라 동작이 다르다.

 

std::shared_ptr<Widget> spw(new Widget);

std::weak_ptr<Widget> wpw(spw);
/* 이후 swp 소멸 */

/* Case 1 */
std::shared_ptr<Widget> spw1 = wpw.lock(); // 가리키는 원시 포인터는 nullptr

/* Case 2 */
std::shared_ptr<Widget> spw2(wpw); // std::bad_weak_ptr 예외 발생

 

weap_ptr의 사용이 유용한 예를 몇가지 들어보도록 하자.

첫째로 어떤 함수의 호출 비용이 꽤 크고 그 반환값이 반복되어 사용되는 경우가 많다고 하면 그 반환값들을 캐싱하여 사용하는 것이 효율적일 것이다. 하지만 계속 캐시에 담아 둔다면 그 자체로 성능문제가 또 발생하기 때문에 더 이상 쓰이지 않는 값들은 캐시에서 삭제하는 것이 또 다른 최적화 방안이 될 것이다.

 

이 때, 팩토리 함수의 반환 타입을 unique_ptr(이전에는 좋다고 했지만)로 두는 것은 그다지 좋지 못하다. 왜냐면 캐시 값들이 수시로 삭제되기 때문에 weak_ptr를 사용하는것이 좋고 weak_ptr를 사용하려면 반드시 반환 타입이 shared_ptr이어야 한다.

 

두 번째 사례로 관찰자 패턴에서 유용하게 사용된다.

관찰 대상은 관찰자의 수명에는 관심이 없고 파괴된 관찰자에 접근하는지가 중요하다. 이 때 weak_ptr를 사용하면 만료 여부를 먼저 확인하고 관찰자에게 메시지를 날릴 수 있다.

 

 

weak_ptr는 shared_ptr과 크기가 같고 shared_ptr가 사용하는 제어 블록과 같은 제어 블록에 접근하여 사용한다.

 

◾ std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.

◾ std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록, 그리고 std::shared_ptr 순환 참조 방지가 있다.

shared_ptr은 참조 횟수(reference count)에 의해 소유중인 자원의 파괴 여부가 결정된다.

shared_ptr의 생성자는 참조 횟수를 증가시키고 소멸자는 참조 횟수를 감소시킨다. 이 때 감소된 참조 횟수가 0이 되면 더 이상 해당 자원을 소유중인 객체(스마트포인터)가 없다는 의미이기 때문에 자원을 파괴시킨다.

 

이 참조횟수는 같은 shared_ptr끼리 공유되어야 하기 때문에 공유자원에 해당되는 원시 포인터, 참조횟수를 위한 원시 포인터 총 2개를 사용하여 원시 포인터 크기의 2배의 공간을 사용하게 된다.

또한 참조 횟수를 담을 공간은 반드시 동적으로 할당해야 하고 참조 횟수의 증감이 반드시 원자적 연산이어야 한다.

 

shared_ptr이 생성되면 참조 횟수가 증가되지만 이동 연산에 의해 shared_ptr이 옮겨진 경우에는 참조 횟수가 변하지 않는다.

 

unique_ptr처럼 shared_ptr역시 자원 파괴시 기본적으로 delete를 사용한다. 물론 커스텀 삭제자도 지원하지만 unique_ptr과는 약간 차이가 있다.

 

auto loggingDel = [](Widget* pw) {
    makeLogEntry(pw);
    delete pw;
};

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);
std::shared_ptr<Widget> spw(new Widget, loggingDel);

shared_ptr은 커스텀 삭제자의 지정에 템플릿 매개변수를 사용하지 않기 때문에 설계가 더 유연하다.

 

auto customDeleter1 = [](Widget* pw) {...};
auto customDeleter2 = [](Widget* pw) {...};

std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

std::vector<shared_ptr<Widget>> vpw { pw1, pw2 }; // ok

shared_ptr의 경우 커스텀 삭제자와 관계없이 공유자원의 타입만 같으면 되기 때문에 같은 컨테이너로 관리할 수 있다. 하지만 unique_ptr은 커스텀 삭제자가 다르면 타입도 다르기 때문에 같은 컨테이너로 관리할 수 없다.

 

또한 커스텀 삭제자의 상태 갯수와 무관하게 shared_ptr 객체의 크기는 변하지 않는다. 그렇다고 커스텀 삭제자의 크기가 줄어드는 것은 아니고, 커스텀 삭제자가 사용하는 메모리는 shared_ptr 객체의 일부가 아닐 뿐이다.

 

shared_ptr은 참조 횟수, 약참조 횟수, 커스텀 삭제자 등등을 관리하는 제어 블록(위에서 언급한 참조횟수 관리를 위한 원시 포인터)을 가지게 되는데 이 제어 블록이 새로 생성되는 조건은 다음과 같다.

 

◾ make_shared에 의해 shared_ptr이 생성될 때

◾ unique_ptr로부터 shared_ptr를 생성할 때

◾ 원시 포인터로 shared_ptr 생성자를 호출할 때

 

이미 제어 블록이 있는 객체로부터 shared_ptr를 생성하고 싶다면 원시 포인터가 아니라 shared_ptr 또는 weak_ptr를 생성자의 인수로 지정하면 된다.

만약 동일한 원시 포인터로 다수의 shared_ptr의 생성자를 호출하여 객체를 생성하게 되면 서로 다른 자원으로 인식하여 참조 횟수가 다르게 관리된다. 이 상태에서 어느 한쪽의 shared_ptr의 소멸자가 호출되어 자원을 파괴하게 되면 다른 shared_ptr은 파괴되어 존재하지 않는 자원을 소유하고 있는것이기 때문에 이후에 사용하거나 소멸자가 호출될 때 문제가 발생한다.

 

같은 포인터를 소유하고 있지만 참조횟수가 다르게 관리된다

참조횟수를 공유하려면 sp2의 생성자에 원시 포인터가 아니라 sp를 넣어주면 된다.

 

이런 문제를 가지고있기 때문에 shared_ptr의 생성자에 원시 포인터를 그대로 넣어주는것은 피해야 한다.

가급적 make_shared를 사용해야 하지만 make_shared는 커스텀 삭제자를 지정할 수 없기 때문에 커스텀 삭제자를 지정하거나 생성자에 원시 포인터를 넣을 수 밖에 없는 상황이라면 new의 결과를 직접 전달하는 것이 좋다.

 

만약 shared_ptr의 생성자에 this 포인터가 사용되면 위에서 언급한 문제가 발생할 가능성이 높다. 외부에서 해당 객체를 가리키는 다른 shared_ptr이 존재할 수도 있기 때문이다.

 

재미있게도 이런 상황을 위한 템플릿이 존재한다.

 

class Widget : public std::enable_shared_from_this<Widget> {
public:
    ...
    void process();
};

이름도 재밌다. 묘하게 되풀이되는 템플릿 패턴(Curiously Recurring Template Pattern, CRTP)이다. 이것도 일종의 디자인 패턴이다. 자기 자신을 템플릿 인자로 사용해서 클래스 템플릿 인스턴스화로부터 파생되게 하는 pimpl 관용구이다.

 

enable_shared_from_this는 현재 객체를 가리키는 shared_ptr를 생성하되, 제어 블록을 복제하지는 않는 멤버 함수 shared_from_this를 정의한다. 제어 블록을 복제하지 않기 때문에 클래스 멤버 함수에서 this 포인터로 shared_ptr를 만들어야 하는 경우에는 shared_from_this를 호출하면 된다.

 

void Widget::process() {
    processWidgets.emplace_back(shared_from_this());
}

 

 

자세한 내용은 넘어가도록 한다.

 

여러모로 복잡한 점들이 있지만 shared_ptr의 연산은 비싸지 않고, 설령 추가비용이 발생한다 하더라도 동적 할당된 자원의 수명이 자동으로 관리된다는 점은 매우 크나큰 이점이다.

만약 shared_ptr에 의한 추가 비용이 걱정된다면 추가 비용보다는 shared_ptr이 반드시 필요한지부터 고민해봐야 한다.

경우에 따라서는 unique_ptr이 더 나은 선택일수도 있기 때문이다.

 

덧붙여서 unique_ptr로부터 shared_ptr를 생성할 수 있지만 반대는 성립하지 않는다.

또한 shared_ptr은 단일 객체를 가리키는 포인터를 염두에 두고 설계된 것이기 때문에 배열을 사용하는것은 그다지 좋은 선택이 아니다. 애초에 array, vector 등이 제공되기 때문에 사용될 이유도 없다.

 

◾ std::shared_ptr는 임의의 공유 자원의 수명을 편리하게(가비지 컬렉션에 맡길때만큼이나) 관리할 수 있는 수단을 제공한다.

◾ 대체로 std::shared_ptr 객체는 그 크기가 std::unique_ptr 객체의 두 배이며, 제어 블록에 관련한 추가 부담을 유발하며, 원자적 참조 횟수 조작을 요구한다.

◾ 자원은 기본적으로 delete를 통해 파괴되나, 커스텀 삭제자도 지원된다. 삭제자의 타입은 std::shared_ptr의 타입에 아무런 영향도 미치지 않는다.

◾ 원시 포인터 타입의 변수로부터 std::shared_ptr를 생성하는 일은 피해야 한다.

unique_ptr은 이름에서 알수있듯이 독점적 소유권을 가지고 있다. unique_ptr은 복사가 허용되지 않고 이동만 가능하다.

소멸 시 자신이 가리키는 자원(원시 포인터)을 파괴한다. 기본적으로 소유하고 있는 원시 포인터에 delete를 적용해서 파괴하게 된다.

 

template<typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params); // C++11

unique_ptr은 흔히 팩토리 함수의 반환 타입으로 자주 쓰인다.

 

unique_ptr이 소멸하며 소유중인 원시 포인터를 파괴할 때 기본적으로 delete를 사용하지만 필요하다면 unique_ptr 객체를 생성할 때 커스텀 삭제자를 사용하도록 지정하는 것도 가능하다.

 

auto delInvmt = [](Investment* pInvestment) {
    makeLogEntry(pInvestment); // 로그 기록
    delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params) {
    std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
    ...
}

삭제하기 전에 로그를 남긴 뒤에 삭제하는 커스텀 삭제자를 만들수도 있다. 다만 타입이 파생클래스인 경우 기반클래스의 포인터를 통해 소멸자를 호출하기 때문에 반드시 가상 소멸자이어야 한다.

 

기본 삭제자를 사용하는 경우 unique_ptr의 객체 크기는 원시 포인터의 크기와 같을거라고 가정하는 것이 합당하지만 커스텀 삭제자를 사용하는 경우 크기가 증가하게 된다.

일반적으로는 함수 포인터를 삭제자로 지정한 경우 1워드만 증가하지만 상태를 많이 가지는 함수 객체라면 그 크기만큼 증가하게 된다. 그래서 가능하면 람다식으로 구현하는 것을 선호하는 것이 좋다.

 

unique_ptr에는 원시 포인터를 직접 대입하게 되면 컴파일 에러가 발생한다. 만약 이게 허용된다면 암묵적 변환이 성립하기 때문에 그렇다.

 

unique_ptr은 팩토리 함수 뿐만 아니라 pimpl 관용구의 구현에 더 잘 쓰인다. 이 내용은 추후 다루도록 한다.

 

std::shared_ptr<Investment> sp = makeInvestment(...);

또한 unique_ptr은 shared_ptr로의 변환도 매우 손쉽고 효율적이다.

팩토리 함수는 반환된 객체가 독점적인 소유인지, 소유권이 공유되는지를 미리 알 수 없다. 하지만 unique_ptr로 반환한다면 둘 중 어느것도 될 수 있는 유연성이 생긴다.

 

◾ std::unique_ptr는 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 스마트 포인터이다.

◾ 기본적으로 자원 파괴는 delete를 통해 일어나나, 커스텀 삭제자를 지정할 수도 있다. 상태 있는 삭제자나 함수 포인터를 사용하면 std::unique_ptr 객체의 크기가 커진다.

◾ std::unique_ptr을 std::shared_ptr로 손쉽게 변환할 수 있다.

특수 멤버 함수는 사용자가 작성하지 않아도 컴파일러가 자동으로 작성하는 기본 생성자, 소멸자, 복사 생성자, 복사 대입 연산자를 가리킨다.

이 함수들은 명시적으로 작성되어 있지 않은 상태에서 해당 함수를 사용하는 코드가 존재할 때만 작성된다.

컴파일러에 의해 작성된 특수 멤버 함수는 기본적으로 public, inline이고 가상 소멸자가 존재하는 기반 클래스를 상속받아서 파생 클래스 역시 가상 소멸자가 생성되는 경우를 제외하고는 모두 비가상 함수이다.

 

class Widget {
public:
    Widget(Widget&& rhs); // 이동 생성자
    Widget& operator=(Widget&& rhs); // 이동 대입 연산자
};

C++11부터는 이동 생성자와 이동 대입 연산자까지 추가되어 총 6개의 특수 멤버 함수가 있다.

 

기본적으로 특수 멤버 함수는 사용자가 명시적으로 작성하지 않았지만 해당 함수를 사용하는 코드가 존재할때만 만들어진다고 했었다. 여기서 복사 연산과 이동 연산은 약간의 차이가 있다.

 

복사 생성자와 복사 대입 연산자는 서로 독립적이다. 복사 생성자가 명시적으로 선언되어 있고 복사 대입 연산자는 구현되어 있지 않은 상태에서 복사 대입 연산자를 사용한다면 컴파일러에 의해 복사 대입 연산자가 작성된다. 반대의 경우도 마찬가지이다.

 

하지만 이동 생성자와 이동 대입 연산자는 서로 종속적이다. 이동 생성자가 명시적으로 선언되어 있고 이동 대입 연산자는 구현되어 있지 않은 상태에서 이동 대입 연산자를 사용한다면 컴파일러는 복사 대입 연산자를 자동으로 작성하지 않는다. 반대의 경우도 마찬가지로 작성하지 않는다.

왜냐하면 하나라도 명시적으로 작성했다는 의미는 컴파일러가 기본적으로 작성해주는게 적합하지 않아서 사용자가 다른 방식으로 구현한 것이기 때문이다. 다른 한쪽을 컴파일러가 자동으로 작성해봤자 마찬가지로 적합하지 않을 가능성이 크기 때문에 한쪽이 명시적으로 선언되어 있다면 다른 한쪽은 자동으로 작성되지 않는다.

 

더 나아가서 복사 연산중 하나라도 명시적으로 선언되어있으면 이동 연산자는 자동으로 작성되지 않고 반대로 이동 연산자중 하나라도 명시적으로 선언되어있으면 복사 연산들은 삭제되어 비활성화된다.

 

3의 법칙

복사 생성자, 복사 대입 연산자, 소멸자 중 하나라도 선언했다면 나머지 둘도 선언해야 한다.

 

어떤 클래스의 복사 대입 연산을 직접 구현해야 하는 경우는 거의 항상 그 클래스가 자원 관리를 수행할 필요가 있기 때문이다. 구현한 복사 대입 연산이 수행하는 자원 관리 역시 다른 복사 연산에서도 수행해야 하고 소멸자도 그 자원의 관리에 참여해야 하기 때문에 셋 중 하나라도 선언했다면 나머지 둘도 선언해야 하는 것이다.

표준 라이브러리에서 메모리를 관리하는 모든 클래스가 2개의 복사 연산과 소멸자를 제공하는 이유가 이 때문이다.

사용자 선언 소멸자가 있는 클래스에서는 복사 연산들도 선언해야 하기 때문에 이동 연산들을 작성하지 않는다는 것을 추론할 수 있다.

 

정리하자면 다음과 같은 세 조건이 모두 만족할 때, 그리고 필요할 때에만 특수 멤버 함수가 자동으로 작성된다.

◾ 클래스에 그 어떤 복사 연산도 선언되어 있지 않다.

◾ 클래스에 그 어떤 이동 연산도 선언되어 있지 않다.

◾ 클래스에 소멸자가 선언되어 있지 않다.

 

class Widget {
public:
    ~Widget(); // 소멸자 선언. 복사 연산들도 선언해야 한다
    Widget(const Widget&) = default; // 기본 복사 생성자의 사용 의사를 밝힘
    Widget& operator(const Widget&) = default // 기본 복사 대입 연산자의 사용 의사를 밝힘
};

혹시라도 복사 연산, 소멸자 중 하나라도 작성한 상황에서 컴파일러가 자동으로 작성한 함수들의 행동이 정확하다면 C++11에서는 해당 함수들을 사용하겠다는 의사를 명시적으로 밝혀서 자동으로 작성된 코드를 사용할 수 있다.

 

만약 사용자 선언 소멸자를 두면서도 이동 연산을 지원하고 싶다면 이동 연산들에도 default를 지정하면 된다. 이 경우 복사 연산이 비활성화 되는데 복사 연산 역시 지원하고 싶다면 마찬가지로 default를 지정해준다.

 

class Base {
public:
    virtual ~Base() = default; // 가상 소멸자
    
    Base(Base&&) = default; // 이동 연산
    Base& operator=(Base&&) = default;
    
    Base(const Base&) = default; // 복사 연산
    Base& operator=(const Base&) = default;
};

 

특수 멤버 함수들을 관장하는 C++11의 규칙

기본 생성자

C++98의 규칙들과 같다. 클래스에 사용자 선언 생성자가 없는 경우에만 자동으로 작성된다.

 

소멸자

C++98의 규칙들과 같지만 기본적으로 noexcept라는 차이 하나만 존재한다.

기본적으로 작성되는 소멸자는 기반 클래스 소멸자가 가상일 때에만 가상이다.

 

복사 생성자

클래스에 사용자 선언 복사 생성자가 없을 때에만 자동으로 작성된다.

클래스에 이동 연산이 하나라도 존재하면 삭제된다.

 

복사 대입 연산자

클래스에 사용자 선언 복사 대입 연산자가 없을 떄에만 자동으로 작성된다.

클래스에 이동 연산이 하나라도 존재하면 삭제된다.

 

이동 생성자와 이동 대입 연산자

클래스에 사용자 선언 복사 연산들과 이동 연산들, 소멸자가 없을 때에만 자동으로 작성된다.

 

 

멤버 함수 템플릿이 존재한다고 해서 특수 멤버 함수의 자동 작성이 비활성화되지는 않는다.

 

◾ 컴파일러가 스스로 작성할 수 있는 멤버 함수들, 즉 기본 생성자와 소멸자, 복사 연산들, 이동 연산들을 가리켜 특수 멤버 함수라고 부른다.

◾ 이동 연산들은 이동 연산들이나 복사 연산들, 소멸자가 명시적으로 선언되어 있지 않은 클래스에 대해서만 자동으로 작성된다.

◾ 복사 생성자는 복사 생성자가 명시적으로 선언되어 있지 않은 클래스에 대해서만 자동으로 작성되며, 만일 이동 연산이 하나라도 선언되어 있으면 삭제된다. 소멸자가 명시적으로 선언된 클래스에서 복사 연산들이 자동 작성되는 기능은 비권장이다.

◾ 멤버 함수 템플릿 때문에 특수 멤버 함수의 자동 작성이 금지되는 경우는 전혀 없다.

여러 스레드가 동기화 없이 const 멤버 함수를 호출하는 것은 읽기 연산을 하는 것이기 때문에 안전한 일이다.

하지만 mutable에 의해 값의 수정이 가능해지는 경우에는 더이상 스레드에 안전해지지 않는다.

이런 경우 atomic 또는 뮤텍스로 동기화를 해주어야 한다.

 

동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 atomic이 적합하지만 둘 이상의 변수나 메모리 장소를 하나의 단위로서 조작해야 하는 경우에는 뮤텍스를 사용하는 것이 바람직하다.

 

또한 const 멤버 함수가 언제라도 동시적 실행 상황에 처할 것이라고 가정하는 것이 안전하다.

 

◾ 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버 함수는 스레드에 안전하게 작성하라.

◾ std::atomic 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.

+ Recent posts