[작성중] Effective Modern C++ 정리 (2)

July 12, 2024, 8:12 p.m. · 16 min read · 🌐︎ ko

C++

Scott Meyers의 Effective Modern C++은 모던 C++, 즉 C++11 이후에 등장한 C++의 새로운 기능들을 잘 사용하는 팁들을 담은 책이다. 책에 나온 팁들을 글로 정리해본다. 참고로 한글판에 나오는 번역어들은 좀 더 널리 사용되는 용어들로 바꾸어 사용하였다.


Chapter 4. 스마트 포인터

C/C++의 일반 포인터(raw pointer)는 분명 강력한 수단이지만, 다음과 같은 단점을 가지고 있다.

  1. 선언만 봐서는 객체를 가리키는지 배열을 가리키는지 알 수가 없다.
  2. 선언만 봐서는 포인터 사용 후에 피지칭 객체를 직접 파괴해야 하는지(i. e. 포인터가 피지칭 객체를 소유하고 있는지) 알 수 없다
  3. 직접 파괴해야 한다면 어떻게 파괴해야 하는지 정보를 얻을 수 없다. e. g. delete 사용, 전용 파괴 함수에 넘겨주기 등등
  4. delete를 사용해야 한다면 delete를 사용해야 하는지 delete[]를 사용해야 하는지 알 수 없다.
  5. 코드의 모든 경로에서 파괴가 정확히 한 번만 일어남을 보장할 수 없다.
  6. 포인터가 피지칭 객체를 잃었는지 알아낼 수 있는 방법이 없다.

이렇게 (일반) 포인터는 강력한 수단이지만, 프로그래머가 실수할 수 있는 여지를 너무 많이 가지고 있다. 스마트 포인터는 이를 해결할 수 있는 방법이다. 일반 포인터가 할 수 있는 거의 모든 일을 할 수 있으면서도 적절한 시간에 파괴되도록 하여 오류의 여지가 훨씬 적기 때문이다.

C++11의 스마트 포인터는 std::auto_ptr, std::unique_ptr, std::shared_ptr, std::weak_ptr로 총 네 개가 있다. 이중 std::auto_ptr은 비권장(deprecated)으로 분류된 클래스로, std::unique_ptr의 기능을 이동 의미론 없이 구현하려는 시도였으나 현재는 std::unique_ptr에 의해 대체되었다. 이 장의 항목들에서는 나머지 세 개의 스마트 포인터를 효율적으로 사용하는 방법을 다룰 것이다.

18. 소유권 독점 자원의 관리에는 std::unique_ptr를 사용하라

유니크 포인터 std::unique_ptr은 일반 포인터와 거의 같은 크기를 가지며, 역참조(dereference, *) 등을 일반 포인터와 거의 똑같이 시행할 수 있다. 그 중심에는 독점적 소유권(exclusive ownership)이라는 철학이 있다. 유니크 포인터가 자신이 가리키는 객체를 “소유”하여, 다른 포인터는 이를 가리킬 수 없는 것이다. 이를 실현하기 위해서는 유니크 포인터 객체는 복사가 불가능해야 한다. 복사할 수 있다면 두 유니크 포인터 객체가 같은 자원을 가리키게 되어버리기 때문이다. 이는 복사 생성자를 =delete로 처리함으로써 가능하다(항목 11 참고). 따라서 일반 포인터를 유니크 포인터에 대입하거나, 유니크 포인터를 다른 유니크 포인터에 대입하는 등의 표현식은 허용되지 않는다.

int a = 42;
int *b = &a;
auto p1 = std::unique_ptr<int>(33);
std::unique_ptr<int> p2 = b;        // 일반 포인터를 std::unique_ptr로 대입. 컴파일 에러 발생
std::unique_ptr<int> p3 = p1;       // std::unique_ptr을 std::unique_ptr에 대입. 컴파일 에러 발생 

예를 들어서 Stock, Bond, RealEstate의 세 자식 클래스를 가지는 Investment 클래스가 있다고 하자. Investment 객체를 만드는 팩토리 함수 makeInvestment를 선언해야 한다고 가정하자. 이렇게 팩토리 함수를 만드는 상황은 유니크 포인터가 딱 알맞은 경우이다. 다음과 같이 선언하면 된다.

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

{
    auto pInvestment = makeInvestment(parameters); // std::unique_ptr<Investment> 타입
}  // 범위를 벗어나면 *pInvestment가 파괴된다

이렇게 pInvestment를 사용하면 자원의 관리를 호출자가 알아서 책임지고 범위에서 벗어날 때 파괴해준다. 이는 기본적으로는 delete를 이용해 일어나지만, std::unique_ptr 객체 생성시에 파괴자를 따로 지정해줄 수도 있다. 다음 예시를 보자. delInvmt라는 커스텀 파괴자를 적용해주었다.

auto delInvmt = [](int* pInv){             // 커스텀 파괴자 (람다 표현식)
    cout << "Investment object deleted" << endl;
    delete pInvmt;
};

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

    // std::unique_ptr의 두 번째 인수로 커스텀 파괴자를 넣어주면 된다
    auto pInv = std::unique_ptr<Investment, decltype(delInvmt)>(nullptr, delInvmt); // 우선 nullptr로 설정하고 나중에 바꿔주기
    if (/* Stock 객체를 생성해야 할 경우*/)
        pInv.reset(new Stock(std::forward(params)...));       // reset을 사용해 새로운 Stock 객체의 소유권을 부여 
    else (/*Bond 객체를 생성해야 할 경우*/)
        pInv.reset(new Bond(std::forward(params)...));
    return pInv;
}

이렇게 커스텀 파괴자를 사용해주면 유니크 포인터 객체는 메모리를 두 배로 차지하게 되는데, 파괴자 함수를 가리키는 포인터를 따로 저장해주어야 하기 때문이다.

유니크 포인터를 사용하면 여러 장점이 있다. pInv가 지칭하는 객체의 타입이 실제로는 자식 클래스인 Stock이나 Bond, RealEstate이더라도 알아서 자식 클래스의 파괴자가 호출된다. (다만, 이를 가능하게 하려면 Investment의 파괴자 ~Investmentvirtual로 선언되어 있어야 한다) 이 함수를 사용할 클라이언트는 Investment라는 자원의 관리를 전혀 신경쓰지 않아도 되니 일반 포인터를 사용하는 것보다 훨씬 편리하다.

한편, 유니크 포인터는 다음 장에서 소개할 공유 포인터로 매우 간편하게 변환된다.

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

팩토리 함수가 만든 포인터를 좀 더 유연하게 사용하고자 하는 경우 공유 포인터를 유용하게 사용할 수 있다. 여기에 대해서는 다음 항목에서 설명한다.

19. 소유권 공유 자원의 관리에는 std::shared_ptr를 사용하라

공유 포인터 std::shared_ptr은 개발자에게 메모리 관리를 일임하는 기존 C++의 방식과 쓰레기 수집기(garbage collector)의 장점을 합친 기능이라고 할 수 있다. 수동적인 메모리 관리는 실수의 여지가 있고, 쓰레기 수집은 언제 일어날지 비결정적이라는 단점을 가지지만, 공유 포인터는 공유된 소유권(shared ownership) 의미론을 통해서 이 두 단점을 모두 해결한다. 여러 개의 공유 포인터가 특정 객체를 가리킬 때, 해당 객체를 가리키는 마지막 공유 포인터가 파괴되는 순간 이 객체도 함께 파괴되는 것이다.

이러한 의미론은 참조 횟수(reference count, RC)를 통해 구현된다. 어떤 객체를 가리키고 있는 공유 포인터들이 몇 개가 있는지를 세어 0이 되는 순간 그 객체를 파괴하면 되는 것이다. std::shared_ptr의 생성자는 RC를 증가시키고, 파괴자는 감소시키는 식으로 RC를 관리한다. 따라서 RC를 담을 메모리 공간이 따로 동적 할당되어 유지된다. 한편, 이렇게 RC를 증가/감소시키는 연산은 스레드에 안전하도록 반드시 원자적(atomic) 연산으로 구현되어야 한다. 그러지 않으면 동시에 두 스레드가 RC를 감소시키는 경우 문제가 될 수 있기 때문이다.

std::shared_ptr<Widget> pw(new Widget()); // 동적으로 RC를 저장할 메모리를 할당 후 1을 저장
auto pw2 = pw;                            // RC가 1 증가
std::cout << pw.use_count() << std::endl; // RC의 값을 알 수 있음. 2가 출력

유니크 포인터와 마찬가지로, 공유 포인터에도 커스텀 파괴자를 지정해줄 수 있다. 반면 차이점은 커스텀 파괴자가 std::shared_ptr에서는 타입의 일부가 아니라는 점이다.

// customDel이 unique_ptr 타입의 일부
std::unique_ptr<Widget, decltype(customDel)> upw(new Widget, customDel); 
// customDel이 unique_ptr 타입의 일부가 아님!
std::shared_ptr<Widget> spw(new Widget, customDel); 

// 다른 파괴자를 가진 shared_ptr를 하나 더 정의
std::shared_ptr<Widget> spw2(new Widget, anotherDel); 
// spw와 spw2는 서로 다른 파괴자를 가지고 있지만 같은 타입이므로 같은 컨테이너에 넣을 수 있다. 
std::vector<std::shared_ptr<Widget> > vpw{spw, spw2}; 

제어 블록
앞서 항목 18에서 커스텀 파괴자가 적용된 경우 유니크 포인터는 메모리를 일반 포인터의 두 배로 차지한다고 언급했었다. 반면, 공유 포인터는 항상 일반 포인터의 두 배 메모리를 차지한다. 먼저 피지칭 객체를 가리키는 포인터를 저장하고, 제어 블록이라 불리는 데이터구조로의 포인터를 또 저장하기 때문이다. 그림으로 표현하자면 다음과 같다.

      std::shared_ptr<T>
+-----------------------------+        +------------+
|  Pointer to T object        | -----> |  T object  |
+-----------------------------+        +------------+
|  Pointer to control block   |  \
+-----------------------------+   \           Control Block
                                   \   +------------------------+
                                    -->|    Reference count     |
                                       +------------------------+
                                       |      Weak count        |
                                       +------------------------+
                                       |      Other data        |
                                       | e.g. custom destructor |
                                       +------------------------+

이 제어블록에는 참조 횟수(reference count)뿐만 아니라 후술할 약한 참조 횟수(Weak count) 등을 저장한다. 또, 커스텀 파괴자에 대한 정보 등의 기타 정보도 이 제어블록에 저장된다. std::shared_ptr 객체가 생성될 때 이러한 제어 블록이 메모리에 할당되어 유지되는 것이다. 따라서 일반 포인터 타입의 변수로부터 공유 포인터를 생성하는 것은 피해야 한다. 다음과 같이 작성할 경우 같은 포인터에 대한 제어 블록이 두 개 생겨 각각 따로 유지되기 때문이다.

auto pw = new Widget;                         // 일반 포인터
std::shared_ptr<Widget> spw1(pw, customDel);  // *pw에 대한 제어 블록 생성
std::shared_ptr<Widget> spw2(pw, customDel);  // *pw에 대한 제어 블록이 하나 더 생성! 

new를 사용해 Widget 객체를 직접 만들지 말고 std::make_shared를 사용하면 이러한 상황을 피할 수 있다. 만일 new를 꼭 사용하고 싶으면

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

과 같이 new의 결과를 std::shared_ptr의 생성자에 바로 전달해야 한다.

20. std::shared_ptr와 비슷하지만 객체를 잃을 수도 있어야 한다면 std::weak_ptr을 사용하라

약한 포인터 std::weak_ptr는 유니크 포인터(std::unique_ptr)나 공유 포인터(std::shared_ptr)와는 달리 독립적으로 사용될 수 없다. 대신 약한 포인터는 공유 포인터와 함께 사용하여 공유 포인터를 보강하는 역할을 한다.

먼저 약한 포인터를 어떻게 생성하는지부터 보자. 약한 포인터는 공유 포인터를 통해서만 생성할 수 있다. 그런데, 약한 포인터는 공유 포인터와는 달리 참조 횟수(RC)에 영향을 주지 않는다. 다음 코드를 보자.

auto spw = std::make_shared<Widget>();    // 공유 포인터 객체 생성. RC=1

std::weak_ptr<Widget> wpw(spw);           // 약한 포인터 생성, 여전히 RC=1

spw = nullptr;                            // RC가 0이 되어 객체 파괴. wpw는 피지칭 객체를 잃어버림
std::cout << wpw.expired() << std::endl;  // wpw는 만료됨(expired)

이처럼 약한 포인터는 RC에 미치는 영향이 없기 때문에, 만료라는 상황이 가능해지게 된다. 약한 포인터 자신에 아무런 변화가 없더라도, 같은 객체를 참조하고 있던 마지막 공유 포인터가 nullptr로 바뀌는 순간 해당 객체를 잃게 되기 때문이다.

그렇다면 (1) 약한 포인터가 만료되었는지를 확인한 후 (2) 해당 약한 포인터를 역참조(dereferencing)하는 용법을 생각할 수 있을 것이다. 하지만 약한 포인터는 역참조(dereferencing, *)가 불가능하기 때문에 이러한 방식으로는 사용이 불가능하다. 약한 포인터를 제대로 사용하는 방법은 약한 포인터 객체를 이용해 공유 포인터 객체를 생성한 후 사용하는 것이다. 여기에는 두 가지 방법이 있는데, (1) lock()을 사용하는 방법과 (2) 약한 포인터 객체를 인수로 받는 생성자를 사용하는 방법이다. 코드를 보자.

std::shared_ptr<Widget> spw1 = wpw.lock();   // (1) lock()을 사용해 공유포인터 객체 얻음
                                             // 만약 만료 상태였다면 nullptr이 반환됨

std::shared_ptr<Widget> spw2(wpw); // (2) wpw를 공유 포인터 생성자에 인수로 넘겨줌

그렇다면 약한 포인터를 왜 사용하는 것일까? 약한 포인터가 필요한 경우들을 알아보자

약한 포인터의 용도 1: 캐싱
약한 포인터가 유용하게 쓰이는 첫 번째 사례로 다음과 같은 팩토리함수 loadWidget을 가정하자. Widget의 id를 인자로 받아서, (항목 18의 조언에 따라) Widget을 가리키는 유니크 포인터를 반환하는 함수이다.

std::unique_ptr<const Widget> loadWidget(WidgetID id)

그런데 이 loadWidget을 한번 호출하는데 너무 많은 비용이 든다고 하자. 그렇다면 loadWidget의 결과를 캐싱해두는 새로운 함수 fastLoadWidget을 만들 수 있을 것이다. 그런데, loadWidget의 모든 호출 결과를 다 저장해놓는다면 너무 많은 용량이 들 것이니 더 이상 사용되지 않는 Widget은 삭제될 수도 있어야 한다.

이런 경우, 캐시가 Widget의 유니크 포인터를 저장해두는 것은 바람직하지 않다. fastLoadWidget의 호출자에게 Widget의 소유권이 전달되어야만 하니 이를 캐시에도 저장한다면 이 Widget 객체에 대한 소유권을 두 개의 변수가 가지게 되는 것이기 때문이다.

공유 포인터를 사용하는 것 또한 바람직하지 않다. 이 경우, 캐시가 모든 Widget에 대한 공유 포인터를 가지고 있을 것이므로 Widget 객체의 RC는 항상 1 이상으로 유지될 것이고, 한 번이라도 생성된 객체는 절대 소멸되지 않을 것이기 때문이다. 이럴 때 사용할 수 있는 것이 바로 약한 포인터이다.

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id){
    // 캐시는 Widget에 대한 약한 포인터를 저장해둔다 
    static std::unordered_map<WidgetID, std::weak_ptr<const Widget> > cache;
    auto objPtr = cache[id].lock(); // lock()을 이용해 공유 포인터 객체를 얻는다
    if (!objPtr){                   // 캐시에 없는 경우
        objPtr = loadWidget(id);    // loadWidget을 직접 호출하고
        cache[id] = objPtr;         // 캐시에 저장한다
    }
    return objPtr;
}

코드를 이렇게 작성하는 경우, 프로그램에서 더 이상 사용되지 않는(=파괴된) Widget 객체들에 대한 std::weak_ptr은 만료되고, 그렇지 않은 경우에만 캐시에 저장된 약한 포인터들이 사용될 것이다.

약한 포인터의 용도 2: 관찰자(Observer) 디자인 패턴

관찰자(Observer) 디자인 패턴은 관찰자(observer)관찰 대상(subject)으로 구성된다. 이 때 관찰 대상들은 자신의 관찰자들을 가리키는 포인터의 배열을 가지고 있어야 한다. 자신의 상태가 변할 경우, 관찰자들에게 이를 통지해주어야 하기 때문이다. 그런데 이 때 파괴된 관찰자에게 상태변화를 통지하는 일은 없어야 한다.

이러한 경우 약한 포인터가 유용하게 사용될 수 있다. 약한 포인터를 사용하는 경우, 관찰자의 수명을 직접 관리하지 않고도 이미 파괴된 관찰자에게 통지가 일어나는 일은 방지할 수 있다. 관찰자를 가리키는 약한 포인터가 만료되었는지를 미리 체크해주기만 하면 되기 때문이다.

약한 포인터의 용도 3: 공유 포인터의 순환고리 방지

약한 포인터가 유용하게 사용되는 마지막 경우는 공유 포인터의 순환고리를 방지하는 것이다. 이중 연결리스트(doubly linked list)와 같은 자료구조를 구현하는 경우 A가 B를 가리키는 포인터를 가지고, B는 다시 A를 가리키는 포인터를 가지는 경우가 종종 발생한다. 그런데, 공유 포인터만을 가지고 이를 구현하는 경우 객체 A와 B 모두가 파괴되지 못한다. 둘이 서로를 가리키고 있으니 RC가 영원히 1 이상으로 유지되는 것이다.

둘 중 한 방향의 포인터는 약한 포인터로 구현해준다면 이러한 문제를 해결할 수 있다. 예를 들어서, A->B 방향의 포인터는 공유 포인터로 유지하되 B->A 방향은 약한 포인터를 사용한다고 하자. 이 경우 객체 A가 파괴된다면 B는 자신이 가진 약한 포인터의 만료 여부를 판단하여, A가 파괴되었음을 인지할 수 있게 된다. 또, B는 A의 RC에 영향을 끼치지 않으므로, A를 가리키는 다른 객체들이 모두 없어지면 A는 자연스럽게 파괴될 수 있게 되는 것이다.

21. new를 직접 사용하는 것보다 std::make_uniquestd::make_shared를 선호하라

std::make_unique는 C++14부터, std::make_shared는 C++11부터 표준 라이브러리에 포함된 함수이다. 이들 함수가 하는 일은 매우 단순하다.

  1. 임의의 개수와 타입을 가진 인수들을 받아서,
  2. 이들을 생성자로 완벽 전달해 객체를 동적으로 생성한 후,
  3. 생성된 객체를 가리키는 스마트 포인터를 돌려주는 것이다.

예를 들어서, 다음의 코드는 두 줄씩 똑같은 역할을 한다.

auto upw1(std::make_unique<Widget>());    // make 함수를 사용
std::unique_ptr<Widget> upw2(new Widget); // make 함수를 사용하지 않음

auto spw1(std::make_shared<Widget>());    // make 함수를 사용
std::shared_ptr<Widget> spw2(new Widget); // make 함수를 사용하지 않음

만약 Widget의 생성자에 전달해주고 싶은 인자가 있다면 make 함수의 괄호 안에 전달하면 된다.

이렇게 make 함수를 사용하면 여러가지 이점이 있다. 먼저, 가장 단순하게는 객체의 타입이름(여기서는 Widget)을 한번씩만 입력해줘도 된다. 프로그래밍에서 중복은 항상 지양해야 할 점이기 때문이다.

두 번째 이점은 예외 안전성에 있다. std::make_shared를 사용하지 않고 다음과 같이 기존 방식대로 공유포인터를 만든다고 하자.

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

위 경우, 컴파일러는 이를 다음과 같이 실행되도록 바꾼다.

  1. new Widget이 실행되어 Widget 객체가 하나 만들어진다.
  2. 이를 인자로 받아 std::shared_ptr 객체가 생성된다.

그런데, 컴파일러의 최적화로 이 두 과정 사이에 다른 코드가 끼어들게 될 수도 있다. 최악의 경우에, 이 코드가 예외를 일으킨다면 위의 1번 코드만 실행되고 2번 코드는 실행되지 못하게 된다. 즉, 생성된 Widget 객체는 수명 관리를 받지 못하게 되고, 이는 메모리 누수로 이어진다. 반면 make 함수를 사용하여

std::make_shared<Widget>();

라고 하는 경우 위의 1번과 2번 과정 사이에 다른 코드가 끼어들지 못하므로, 위와 같은 일은 염려할 필요가 없다. 또, new를 직접 사용하는 경우에 메모리 할당이 두 번(Widget 객체를 위해 한 번, 항목 19에서 설명한 제어 블록에 대해 한 번) 일어나는 것에 비해 위의 경우에는 메모리 할당이 한 번만 일어나므로 더 효율적이다.

make 함수의 한계
한편, make_sharedmake_unique를 써서는 할 수 없는 일들도 존재한다. 이 항목의 제목이 사용하라가 아니라 선호하라인 이유가 이것이다.

먼저 make 함수로는 커스텀 파괴자를 지정해줄 수 없다. 또, 항목 07에서 설명한 균일(중괄호) 초기화를 사용할 수 없다. 항목 07에서 살펴봤듯이, 아래의 두 초기화 구문은 서로 다른 결과를 만들어낸다.

std::vector<int> v1(10, 20);  // 20이 10개 들어있는 벡터
std::vector<int> v2{10, 20};  // 두 원소 10과 20을 가지는 벡터

그런데

auto upv = std::make_unique<std::vector<int> >(10, 20);

를 실행하면 균일 초기화가 아닌 괄호 초기화가 일어나고, 20이 10개 들어있는 벡터가 생성된다. 중괄호 초기화처럼 해석하도록 하고 싶으면 따로

auto initList = {10, 20}; // std::initializer_list 객체 생성
auto upv = std::make_unique<std::vector<int> >(initList);
// 두 원소 10과 20을 가지는 벡터를 가리키는 유니크 포인터가 만들어짐

와 같이 std::initialier_list 객체를 따로 만들어 전달해주어야 한다.

22. Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라

Pimpl 관용구란
Pimpl 관용구("pointer to implementation" idiom)은 구현 클래스/구조체를 따로 만들어 자료 멤버들을 옮기고, 대신 구현 클래스를 가리키는 포인터를 갖도록 하는 것이다. 예를 들어서 다음과 같은 클래스가 있다고 하자.

// widget.h
class Widget{
public:
    Widget();
    ...
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget 클래스를 사용하고자 하는 사람은 widget.h 헤더파일을 include할 것이고, 그러면 다시 widget.hinclude하는 <string>, <vector>, gadget.hinclude하는 효과가 나게 된다. 이는 컴파일 시간을 증가시키고, 이러한 헤더들에 대한 불필요한 의존성을 심어준다.

이제 Pimpl 관용구를 적용해보면, 이 클래스를 다음과 같이 바꿀 수 있다.

// widget.h
class Widget{
public:
    Widget();
    ~Widget();
    ...
private:
    struct Impl;      // 선언만 하고 정의는 하지 않음; "불완전 타입"
    Impl *pImpl;   
};


// widget.cpp
struct Widget::Impl { // 위에서 선언한 Impl 구조체의 정의
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget(): pImpl(new Impl) { } // Widget 생성시 Impl 객체 생성

Widget::~Widget() {                   // Widget 파괴시 Impl 객체 파괴
    delete pImpl;
}

위는 C++98을 기준으로 Pimpl 관용구를 적용해본 것이다. 이제 Widget 클래스의 선언은 <string>, <vector>, gadget.h 등과 무관하므로 widget.h에서는 이들 헤더를 include해줄 필요가 없다. 이러한 include 문을 widget.cpp로 옮겨줘도 되는 것이다.

모던 C++에서 Pimpl 관용구의 사용
한편, 위의 코드는 newdelete 문, 일반 포인터를 사용하는 등에서 모던 C++의 정신에 맞지 않다. 대신 std::unique_ptr을 사용하여 다음과 같이 바꿔주자.

// widget.h
class Widget{
public:
    Widget();
    ...                          // 소멸자 선언이 불필요
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl; // 유니크 포인터로 바꿔줌
};

// widget.cpp
...
Widget::Widget(): pImpl(std::make_unique<Impl>()) {}

유니크 포인터를 사용해주었기 때문에 Impl 객체는 수명이 자동으로 관리되며, 소멸자를 따로 정의해줄 필요가 없어졌다. 그런데, 이를 다음과 같이 클라이언트가 사용하려 하는 순간 컴파일이 되지 않는다.

#include "widget.h"
Widget w;
/opt/homebrew/Cellar/gcc/14.1.0_2/include/c++/14/bits/unique_ptr.h:91:23: error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'
   91 |         static_assert(sizeof(_Tp)>0,
      |                       ^~~~~~~~~~~

이는 w가 파괴되는 시점에서 컴파일러가 생성하는 코드 때문에 생기는 문제이다. Widget의 소멸자를 직접 정의해주지 않았으니 컴파일러가 이를 대신 작성하는데, Widget의 (자동작성된) 소멸자는 다시 pImpl의 소멸자를 호출한다. 그런데 대부분의 컴파일러 구현에서 pImpl의 소멸자는 자신이 가진 Impl 객체에 대한 포인터에 delete를 적용시키기 전에, 포인터가 불완전 타입을 가리키지는 않는지 점검한다.

이 문제를 해결해주기 위해서는 컴파일러가 std::unique_ptr<Widget::Impl>을 파괴하는 코드를 생성하기 전에 Widget::Impl을 완전한 타입으로 만들어주어야 한다. 컴파일러는 타입의 정의를 보아야만 해당 타입을 완전한 타입으로 간주하므로, 우리는 컴파일러가 ~Widget의 본문을 보기 전에 (widget.cpp에 있는) Widget::Impl의 정의를 먼저 보게 해야 한다. 그러려면 다음과 같이 코드를 바꾸면 된다.

// widget.h
class Widget{
public:
    Widget();
    ~Widget();  // 소멸자 ~Widget을 명시적으로 선언하되 정의는 하지 않는다.
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
}

// widget.cpp
struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget(): pImpl(std::make_unique<Impl>()) { }

Widget::~Widget() {}   // ~Widget을 정의한다
// Widget::~Widget() = default;로 써주어도 같은 효과

이렇게 하면 소멸자 ~Widget을 확인하기 위해 컴파일러가 widget.cpp를 방문하게 되고, 그 과정에서 Widget::Impl의 정의를 보게 되어 Impl은 완전한 타입으로 다뤄지게 되는 것이다.

한편, 항목 17에서 다루었듯, 소멸자를 선언하면 컴파일러는 이동 연산을 자동으로 작성해주지 않는다. 따라서 이동 연산도 명시적으로 선언해주어야 한다. 이 또한 마찬가지로, 선언은 명시적으로 해주고 정의는 widget.cpp로 옮기면 된다.

// widget.h
class Widget{
public:
    Widget();
    ~Widget();

    Widget(Widget&& rhs);             // 이동 생성자
    Widget& operator=(Widget&& rhs);  // 이동 대입 연산자
    ...
};

// widget.cpp
...
Widget::Widget(Widget&& rhs) = default;              // 여기에서
Widget& Widget::operator =(Widget&& rhs) = default;  // 정의

Widget에 대한 복사 연산 또한 우리가 직접 선언해주어야 한다. 복사 연산의 경우 정의까지도 직접 구현해주어야 하는데, std::unique_ptr 처럼 이동 전용 타입이 있는 클래스의 경우 컴파일러가 복사 연산을 자동으로 정의해주지 못하기 때문이다. 헤더 파일 widget.h에서는 이전까지와 마찬가지로

Widget(const Widget& rhs);             // 복사 생성자
Widget& operator=(const Widget& rhs);  // 복사 대입 연산자

처럼 선언만 해주고, 구현 파일 widget.cpp에서 제대로 구현을 해주면 된다.

// widget.cpp
Widget::Widget(const Widget& rhs): pImpl(nullptr){
    if(rhs.pImpl)  // rhs.pImpl이 nullptr가 아니면 
        pImpl = std::make_unique<Impl>(*rhs.pImpl); // *rhs.pImpl을 복사생성한 것을 
                                                    // 가리키는 유니크포인터로 설정
}

Widget& Widget::operator=(const Widget& rhs){
    if(!rhs.pImpl)                                  // rhs.pImpl이 널이면 자신의 
        pImpl.reset();                              // pImpl도 널로 설정. 
    else if (!pImpl)                                // pImpl이 널이 아니면 rhs에서 
        pImpl = std::make_unique<Impl>(*rhs.pImpl); // 복사생성해 새 유니크 포인터로 지시
    else                      // pImpl이 null이면 *rhs.pImpl을 복사대입

        *pImpl = *rhs.pImpl;
    return *this;
}

위에서 다룬 내용을 한 마디로 요약하자면, 이 항목의 제목과 같다. Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일(즉 위의 widget.cpp)에서 정의하라. 한편, Impl 포인터를 유니크 포인터가 아닌 공유 포인터로 구현하면 어떻게 될지 의문이 생길 수 있을 것이다. 이 글에서는 생략하지만, 결론만 말하자면 이 경우 Widget의 소멸자를 선언할 필요가 없으며, 이 절의 조언들은 모두 적용되지 않는다.

Chapter 5. 오른값 참조, 이동 의미론, 완벽 전달

이번 장을 시작하기 전에, 매개변수가 항상 왼값이라는 사실을 다시 한번 되짚고 넘어가자. 예를 들어서,

void f(Widget&& w);

와 같은 함수가 있을 때, 매개변수 w는 여전히 왼값이다. 다만 그 타입이 Widget에 대한 오른값 참조일 뿐이다.

23. std::movestd::forward를 숙지하라

std::movestd::forward를 설명하려면 이들이 하는 것을 다루기보다 이들이 하지 않는 것을 다루는 것이 편하다.

std::movestd::forward캐스팅을 수행하는 함수, 정확히는 함수 템플릿이다. 이 때 std::move인수를 무조건 오른값으로 캐스팅하는 반면, std::forward특정 조건이 만족될 때만 오른값으로 캐스팅한다.

오른값으로의 무조건 캐스팅을 수행하는 std::move
std::move가 하는 일의 이해를 돕기 위해, C++14에서 std::move를 구현한 예를 보자.

template<typename T>
decltype(auto) move(T&& param){
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

decltype(auto)에 대해서는 항목 03을 참고하자.

코드를 한줄 한줄 뜯어보면, std::move

  1. T&& 타입의 인수 param을 받아서
  2. remove_reference<T>::type을 이용해 T에서 참조(&)를 떼고
  3. 여기에 다시 오른값 참조 &&를 붙인 타입으로 param을 캐스팅
    하는 역할을 한다는 것을 알 수 있다. 즉, std::move는 이름과는 다르게 이동을 수행하지 않는다. 대신, 컴파일러에게 어떤 객체가 이동에 적합하다는 것을 말해주는 역할을 한다. 오른값은 곧 이동의 후보이기 때문이다.

std::moveconst 객체에 사용하지 말아야 한다
한편, 이동을 시킬 수 없는 오른값도 있다. 바로 const 객체이다. 이동을 시키면 원래의 객체가 가진 자원을 빼앗아가야 하는데, 원래의 객체가 const로 선언되어 있으면 당연히 이것이 불가능하다. 그런데 이에 유의하지 않고 코드를 작성하면 원하지 않은 결과가 아무 경고 없이 발생할 수 있다. 다음과 같은 예시를 보자.

class Annotation{
public:
    explicit Annotation(const std::string text): value(std::move(text)) {
        ...
    }
private:
    std::string value;
};

위의 Annotation 클래스에서, 생성자는 const std::string 타입의 인자 text를 받아, 이를 std::move를 사용해 오른값으로 캐스팅한다. 이렇게 캐스팅된 것을 이용해 std::string 타입의 멤버 변수 value이동 생성하려 시도하고 있다. 그런데 위 코드의 결과로 실제로는 이동 생성이 아니라 복사 생성이 일어난다.

그 이유는 std::string 생성자의 오버로딩 후보로 무엇이 있는지를 살펴보면 알 수 있다. std::string의 생성자는 다음과 같이 복사 생성자이동 생성자가 선언되어 있다.

class string{
public:
    ...
    string(const string& rhs);
    string(string&& rhs);
};

Annotation의 생성자에서, std::string value는 이 두 오버로딩 후보 중 하나를 이용해 생성되어야 한다. 그런데 인자로 주어지는 std::move(text)const string&& 타입으로, string&& 타입에 바인딩될 수 없다. 앞서 설명했듯 const 타입으로부터는 이동이 불가능하기 때문이다. 그 대신 const string&& 타입은 const string&으로 변환되어 복사 생성자가 대신 호출된다. 이 과정에서 어떤 경고나 에러도 발생하지 않는다. 따라서 const 객체에 std::move를 사용하지 않도록 주의해주어야 한다.

위의 예시에서 알 수 있는 점은, 설령 std::move를 사용하여 이동을 시키고 싶다는 의도를 컴파일러에게 전달해준다고 해도 std::move는 이동시킬 수 있는 자격을 보장해주지 않는다는 것이다. 즉, std::move는 아무것도 이동시키지 않으며, 이동 자격을 갖추게 됨도 보장해주지 않는다. 다만, 그 결과가 오른값이라는 점만을 보장해준다.

오른값으로의 조건부 캐스팅을 수행하는 std::forward
std::forward 함수의 동작을 이해하기 위해, 함수의 가장 흔한 용법을 먼저 알아보자. 바로 보편 참조 매개변수를 받아서 다른 함수에 전달하는 것이다.

void process(const Widget& lvalArg);  // 왼값들을 처리
void process(Widget&& rvalArg);       // 오른값들을 처리

template<typename T>
void logAndProcess(T&& param){  // param은 보편참조
    // 로그를 처리하는 부분 (생략)
    process(std::forward<T>(param));
}

위에서 logAndProcess는 보편참조 매개변수 param을 받아 이를 process에 전달하는 역할을 한다. 그런데 process는 왼값과 오른값에 대해 오버로딩이 되어 있으니, logAndProcess에 왼값을 전달하면 왼값 버전의 process를, 오른값을 전달하면 오른값 버전의 process를 호출하도록 하고 싶을 것이다.

그런데, 챕터를 시작하면서 다시 한번 언급했듯이 함수 내부에서 함수의 매개변수는 항상 왼값이므로 param은 왼값으로 취급된다. 이런 상황에서 logAndProcess 함수에 전달된 변수가 왼값인지, 오른값인지를 어떻게 구별할까? std::forward는 구별할 수 있다. std::forward는 주어진 변수가 오른값으로 초기화된 경우에만 이를 오른값으로 캐스팅하기 때문이다.

따라서 위의 코드를 실행하면,logAndProcess에 오른값을 전달한 경우에만 오른값 버전의 process가 실행되고, 그렇지 않은 경우에는 왼값 버전의 process가 실행된다. std::forward라는 이름은 객체를 원래의 성질을 유지한 채 다른 함수에 그냥 넘겨준다(forward)는 데에서 온 것이다.

24. 보편 참조와 오른값 참조를 구별하라

코드에 T&&가 나온다면, 이것이 오른값 참조라고 생각하기 쉽다. 하지만 T&&에는 오른값 참조라는 의미 외에도 보편 참조(universal reference)라는 의미가 있다.

void f(Widget&& param);         // 오른값 참조
Widget&& var1 = Widget();       // 오른값 참조

auto&& var2 = var1;             // 보편 참조


template<typename T>
void f(std::vector<T>&& param); // 오른값 참조

template<typename T>
void f(T&& param);           // 보편참조

보편 참조는 소스 코드에서는 오른값 참조처럼 보이지만 실제로는 오른값 참조와는 다른, 매우 유연한 형태의 참조자이다. 이는 왼값에도, 오른값에도 바인딩할 수 있으며 const성이나 volatile성에도 상관 없이 거의 모든 것에 바인딩시킬 수 있다. 보편참조라는 이름이 붙은 이유이다.

&&가 등장하는 경우 중 딱 두 가지만이 보편참조에 해당한다. 각각을 살펴보자.

1. 함수 템플릿 매개변수 T&&
보편참조의 첫 번째 경우는 함수 템플릿 매개변수로 T&&와 같은 형태가 등장할 때이다. 위의 예제 코드에서

template<typename T>
void f(T&& param);              // 보편참조

와 같은 경우가 여기에 해당한다. 이때, 참조 선언은 정확하게 T&&와 같아야 한다. 예를 들어서,

template<typename T>
void f(std::vector<T>&& param); // 오른값 참조! 보편참조가 되려면 정확하게 T&&여야 함.

와 같은 경우는 보편참조가 아니다. 심지어는 T&&const 하나만 붙여도 참조 선언이 보편참조가 아닌 오른값 참조로 변해버리게 된다. 그런데 T&&의 형태이더라도 반드시 보편 참조가 아닐 수도 있다. T&&의 형태이지만 타입 추론이 일어나지 않을 수도 있기 때문이다. 예로 다음의 코드를 보자.

template<class T, class Allocator = allocator<T>>
class vector{
public:
    void push_back(T&& x);
    ...
};

위 코드에서 push_back에 등장하는 Tvector 클래스가 인스턴스화됨과 동시에 하나로 고정되어 정해진다. 즉, T는 어떤 값이 push_back 함수에 전달될 때 추론되는 것이 아니다. 이러한 경우에는 T&&의 형태임에도 불구하고 타입 추론이 일어나지 않기 때문에 보편 참조가 아닌 오른값 참조가 된다.

2. auto 보편참조 auto&&
보편참조가 등장하는 다른 하나의 경우는 auto 선언이다.

auto&& var2 = var1;

과 같은 경우가 여기에 해당한다. 1번의 경우와는 타입 추론이 일어난다는 공통점이 있다. 즉, 타입 추론이 일어나지 않는 경우는 모두 보편참조가 아닌 오른값 참조라고 간주해도 된다.

보편참조가 오른값 참조를 나타내는지 왼값 참조를 나타내는지는 보편참조에 전달되는 값(초기치)이 결정한다. 전달되는 값이 왼값이면 왼값 참조, 오른값이면 오른값 참조가 되는 것이다.

template<typename T>
void f(std::vector<T>&& param);

Widget w;
f(w);            // param의 타입은 Widget&  (왼값 참조)
f(std::move(w)); // param의 타입은 Widget&& (오른값 참조)

사실 이 항목은 전체가 추상화된 설명으로, 실제로는 참조 축약(reference collapsing)이라는 기작에 의해 위와 같은 현상이 일어나는 것이다. 여기에 대해서는 항목 28에서 다룬다.