Scott Meyers의 Effective Modern C++은 모던 C++, 즉 C++11 이후에 등장한 C++의 새로운 기능들을 잘 사용하는 팁들을 담은 책이다. 책에 나온 팁들을 글로 정리해본다. 참고로 한글판에 나오는 번역어들은 좀 더 널리 사용되는 용어들로 바꾸어 사용하였다.
Chapter 4. 스마트 포인터
C/C++의 일반 포인터(raw pointer)는 분명 강력한 수단이지만, 다음과 같은 단점을 가지고 있다.
- 선언만 봐서는 객체를 가리키는지 배열을 가리키는지 알 수가 없다.
- 선언만 봐서는 포인터 사용 후에 피지칭 객체를 직접 파괴해야 하는지(i. e. 포인터가 피지칭 객체를 소유하고 있는지) 알 수 없다
- 직접 파괴해야 한다면 어떻게 파괴해야 하는지 정보를 얻을 수 없다. e. g.
delete
사용, 전용 파괴 함수에 넘겨주기 등등
delete
를 사용해야 한다면 delete
를 사용해야 하는지 delete[]
를 사용해야 하는지 알 수 없다.
- 코드의 모든 경로에서 파괴가 정확히 한 번만 일어남을 보장할 수 없다.
- 포인터가 피지칭 객체를 잃었는지 알아낼 수 있는 방법이 없다.
이렇게 (일반) 포인터는 강력한 수단이지만, 프로그래머가 실수할 수 있는 여지를 너무 많이 가지고 있다. 스마트 포인터는 이를 해결할 수 있는 방법이다. 일반 포인터가 할 수 있는 거의 모든 일을 할 수 있으면서도 적절한 시간에 파괴되도록 하여 오류의 여지가 훨씬 적기 때문이다.
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
의 파괴자 ~Investment
가 virtual
로 선언되어 있어야 한다) 이 함수를 사용할 클라이언트는 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_unique
와 std::make_shared
를 선호하라
std::make_unique
는 C++14부터, std::make_shared
는 C++11부터 표준 라이브러리에 포함된 함수이다. 이들 함수가 하는 일은 매우 단순하다.
- 임의의 개수와 타입을 가진 인수들을 받아서,
- 이들을 생성자로 완벽 전달해 객체를 동적으로 생성한 후,
- 생성된 객체를 가리키는 스마트 포인터를 돌려주는 것이다.
예를 들어서, 다음의 코드는 두 줄씩 똑같은 역할을 한다.
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);
위 경우, 컴파일러는 이를 다음과 같이 실행되도록 바꾼다.
new Widget
이 실행되어 Widget
객체가 하나 만들어진다.
- 이를 인자로 받아
std::shared_ptr
객체가 생성된다.
그런데, 컴파일러의 최적화로 이 두 과정 사이에 다른 코드가 끼어들게 될 수도 있다. 최악의 경우에, 이 코드가 예외를 일으킨다면 위의 1번 코드만 실행되고 2번 코드는 실행되지 못하게 된다. 즉, 생성된 Widget
객체는 수명 관리를 받지 못하게 되고, 이는 메모리 누수로 이어진다. 반면 make
함수를 사용하여
std::make_shared<Widget>();
라고 하는 경우 위의 1번과 2번 과정 사이에 다른 코드가 끼어들지 못하므로, 위와 같은 일은 염려할 필요가 없다. 또, new
를 직접 사용하는 경우에 메모리 할당이 두 번(Widget
객체를 위해 한 번, 항목 19에서 설명한 제어 블록에 대해 한 번) 일어나는 것에 비해 위의 경우에는 메모리 할당이 한 번만 일어나므로 더 효율적이다.
make
함수의 한계
한편, make_shared
와 make_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.h
가 include
하는 <string>
, <vector>
, gadget.h
를 include
하는 효과가 나게 된다. 이는 컴파일 시간을 증가시키고, 이러한 헤더들에 대한 불필요한 의존성을 심어준다.
이제 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 관용구의 사용
한편, 위의 코드는 new
와 delete
문, 일반 포인터를 사용하는 등에서 모던 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. 오른값 참조, 이동 의미론, 완벽 전달
이번 장을 시작하기 전에, 매개변수가 항상 왼값이라는 사실을 다시 한번 되짚고 넘어가자. 예를 들어서,
와 같은 함수가 있을 때, 매개변수 w
는 여전히 왼값이다. 다만 그 타입이 Widget
에 대한 오른값 참조일 뿐이다.
23. std::move
와 std::forward
를 숙지하라
std::move
와 std::forward
를 설명하려면 이들이 하는 것을 다루기보다 이들이 하지 않는 것을 다루는 것이 편하다.
std::move
는 아무것도 이동시키지 않는다.
std::forward
가 모든 것을 전달시키지는 않는다
- 실행 시점에서는, 둘 다 아무것도 하지 않는다.
std::move
와 std::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
는
T&&
타입의 인수 param
을 받아서
remove_reference<T>::type
을 이용해 T
에서 참조(&
)를 떼고
- 여기에 다시 오른값 참조
&&
를 붙인 타입으로 param
을 캐스팅
하는 역할을 한다는 것을 알 수 있다. 즉, std::move
는 이름과는 다르게 이동을 수행하지 않는다. 대신, 컴파일러에게 어떤 객체가 이동에 적합하다는 것을 말해주는 역할을 한다. 오른값은 곧 이동의 후보이기 때문이다.
std::move
는 const
객체에 사용하지 말아야 한다
한편, 이동을 시킬 수 없는 오른값도 있다. 바로 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
에 등장하는 T
는 vector
클래스가 인스턴스화됨과 동시에 하나로 고정되어 정해진다. 즉, T
는 어떤 값이 push_back
함수에 전달될 때 추론되는 것이 아니다. 이러한 경우에는 T&&
의 형태임에도 불구하고 타입 추론이 일어나지 않기 때문에 보편 참조가 아닌 오른값 참조가 된다.
2. auto
보편참조 auto&&
보편참조가 등장하는 다른 하나의 경우는 auto
선언이다.
과 같은 경우가 여기에 해당한다. 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에서 다룬다.