Effective Modern C++ 정리

July 4, 2024, 1:21 p.m. · 48 min read · 🌐︎ ko

C++

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


Chapter 1. 타입 추론

01. 템플릿 타입 추론 규칙을 숙지하라

이 항목에서는 함수는 템플릿, 즉 제너릭으로 정의된 함수가 파라미터의 타입을 어떻게 추론하는지를 자세히 살펴본다. 이것이 중요한 이유는 모던 C++의 핵심 기능 중 하나인, 다름아닌 auto가 템플릿과 같은 방식으로 타입을 추론하기 때문이다.

다음과 같이 정의된 템플릿이 있다고 하자.

template<typename T>
void f(ParamType param);

f(expr);    // 특정 타입을 가지는 expr이라는 변수를 대입해 호출

여기서 ParamType의 자리에는 T 자신이 들어갈 수도 있지만, const TT&, const T&, 심지어는 보편참조(universal reference, a.k.a. forwarding reference) T&&가 들어갈 수도 있을 것이다. 모던 C++에서는 이렇듯 T에서 파생되어 나오는 다양한 타입들이 ParamType의 자리에 들어갔을 때 어떻게 타입을 추론하는지 규칙이 정해져 있는데, 세 가지 경우가 있다.

각각의 경우를 살펴보자.

  1. ParamType보편참조 이외의 참조 타입이거나 포인터 타입일 경우

    • 함수에 들어온 표현식 expr에서 참조 부분(&)은 무시된다.
    • 그 다음 expr의 타입을 ParamType에 대해 pattern-matching 방식으로 대응시켜서 T의 타입을 알아낸다

    예시는 다음과 같다.

int x1 = 3;
const int x2 = 5;
const int& x3 = x2;

template<typename T>
void f(const T& param);    // ParamType 자리에 const T&

f(x1);    // T는 int로 매칭되고, param은 const int & 타입이 된다.
f(x2);    // T는 int로 매칭되고, param은 const int & 타입이 된다.
f(x3);    // T는 int로 매칭되고, param은 const int & 타입이 된다.

template<typename T>
void g(T& param);    // ParamType 자리에 T&

g(x1);    // T는 int로 매칭되고, param은 int & 타입이 된다.
g(x2);    // T는 const int로 매칭되고, param은 const int & 타입이 된다.
g(x3);    // T는 const int로 매칭되고, param은 const int & 타입이 된다.
  1. ParamType보편참조 T&&인 경우

    • expr이 좌측값(lvalue)인 경우 Tparam 모두 좌측값 참조자로 타입이 추론된다. 이는 특히 T 자체가 좌측값 참조자가 되는 유일한 상황인데, 보편참조가 우측값 참조자와 똑같이 생겼다는 점을 고려하면 이는 특이하다.
    • expr이 우측값(rvalue)인 경우 경우 1에 대한 규칙들이 비슷하게 적용된다.
      예시는 다음과 같다.
int x1 = 3;
const int x2 = 5;
const int& x3 = x2;

template<typename T>
void f(T&& param);    // ParamType 자리에 T&&

f(x1);    // x1이 좌측값이니 T는 int&로 매칭되고, param도 int& 타입이 된다.
f(x2);    // x2가 좌측값이니 T는 const int&로 매칭되고, param은 const int& 타입이 된다.
f(x3);    // x3가 좌측값이니 T는 const int&로 매칭되고, param은 const int & 타입이 된다.
f(42);    // 27이 우측값이니 T는 int로 매칭되고, param은 int&& 타입이 된다.
  1. ParamType이 참조도 포인터도 아닌 경우

    • 파라미터가 함수에 값으로 전달(pass-by-value)되는 상황이라고 할 수 있다. 이 경우는 참조(&)와 const성이 모두 무시된다.
int x1 = 3;
const int x2 = 5;
const int& x3 = x2;

template<typename T>
void f(T param);    // ParamType 자리에 T

f(x1);    // T는 int로 매칭되고, param도 int 타입이 된다.
f(x2);    // T는 int로 매칭되고, param도 int 타입이 된다. (const가 무시)
f(x3);    // T는 int로 매칭되고, param도 int 타입이 된다. (const와 &가 모두 무시)

배열 타입의 템플릿 추론
한편 템플릿의 타입 추론 과정에서, 배열 타입은 포인터 타입으로 자동 붕괴한다. 배열 타입의 매개변수라는 것은 없기 때문이다. 예를 들어 다음과 같다.

void f(int arr[]);    // void f(int *arr)과 동일하다.

따라서 템플릿 함수에서도 배열이 매개변수로 들어왔을 때는 포인터로 추론될 것이다. 그러나 책에서 제안하는 한 가지 교묘한 요령이 있는데, 바로 매개변수 타입을 배열을 가리키는 참조자로 선언하는 것이다.

const char string[] ="Effective Modern C++";
template<typename T>
void f(T& param);

f(name);    // 앞서 말한 1번 케이스에 해당. T는 const char[21]으로, 
            // param의 타입은 const char(&)[21]으로 추론된다

이렇게 배열의 참조 타입을 매칭하는 것을 이용해, 다음과 같이 해당 배열의 길이를 리턴하는 함수를 만들 수도 있다.

template <typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept  {                           
    return N;                               
}

02. auto의 타입 추론 규칙을 숙지하라

앞 절에서 언급했듯이, auto는 템플릿의 타입 추론 규칙을 거의 똑같이 사용한다. 다음 코드를 보자.

const auto& x1 = 42;    // 경우 1(보편참조가 아닌 참조 or 포인터)에 의해 auto는 int로,
                        // x1의 타입은 const int&로 추론

auto&& x2 = x1;         // 경우 2(보편참조)에 해당. x1은 왼값 const int&이므로, 
                        // x2의 타입은 const int&로 추론

auto&& x3 = 42;         // 경우 2이나 오른값. 따라서 우측값 참조자 int&&로 추론

auto x4 = 42;           //  경우 3(참조도 포인터도 아닌 경우)에 의해 x1은 int로 추론

유일한 차이점은 중괄호 초기화(또는 균일 초기화)에서 발생한다. auto는 중괄호 초기화 구문을 std::initializer_list로 간주하지만, 템플릿은 그 대신 타입추론을 거부하고 컴파일에 실패한다는 점이다.

auto arr = {1, 2, 3, 4};    // arr의 타입은 std::initializer_list<int>로 추론됨

template<typename T>
void f(T param);

f({1, 2, 3, 4});            // std::initializer_list<int>로 추론되지 않고 타입 추론에 실패함

03. decltype의 작동 방식을 숙지하라

decltype(declared type)은 주어진 이름이나 표현식의 형식을 알려주는 키워드이다. 대부분의 상황에서는 그 작동방식을 몰라도 될만큼 직관적으로 동작한다. 하지만 decltype을 취했을 때 뜻밖의 결과가 나오는 경우도 있다. 이 항목에서는 그러한 경우 decltype의 행동 방식을 살펴본다.

선언된 타입을 알려주는 decltype
우선 decltype의 기본 사용법을 알아보자. 다음과 같은 대부분의 사용례에서는 decltype이 예상대로 동작한다. 즉, decltype을 이름에 대해 사용할 때에는 선언된 타입을 그대로 알려준다.

const int i = 0;            // decltype(i)는 const int
bool f(const Widget& w);    // decltype(f)는 bool(const Widget&)
struct Point{               // decltype(Point::x)는 int
    int x, y;
};
Widget w;                   // decltype(w)는 Widget

한편, decltype은 이러한 경우 외에도 제너릭 라이브러리를 작성할 때, 함수의 반환 타입이 매개변수들에 의존하도록 하기 위해 많이 사용된다. 예를 들어 다음과 같이 함수의 반환 타입을 매개변수들을 사용해 쉽게 표현할 수 있는데, 이를 후행 반환 타입(trailing return type)이라고 한다.

// Container c에서 i번째 원소를 접근한다
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i]) {   
    authenticateUser();
    return c[i];
}

특히 C++14에서는 이러한 후행 반환 타입까지도 생략해 다음과 같이 쓸 수 있다.

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) {   
    authenticateUser();
    return c[i];
}

그러나 이 경우 문제가 생긴다. 다음의 코드를 보자.

std::deque<int> d;
authAndAccess(d, 5) = 10;    // 컴파일 에러

위와 같은 경우, std::deque<int>에 정의된 operator []는 결과로 int&를 반환한다. 따라서 대입 연산이 가능해야 할 것처럼 보이지만, auto가 타입 추론을 하는 과정에서 항목 01에서 살펴본 바와 같이 참조자가 무시되기 때문에 이 함수는 int를 반환하게 되고, 대입 연산이 불가능해지는 것이다. 이를 해결하기 위해서는 auto 대신 decltype(auto)를 사용하면 된다.

decltype(auto): decltype의 규칙을 사용해 타입을 추론하자

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i) {   
    authenticateUser();
    return c[i];
}

함수 반환 타입을 decltype(auto)로 지정하면 auto처럼 타입을 추론해야 한다는 것을 컴파일러에게 알리게 되지만, 이 과정에서 auto의 규칙들이 아닌 decltype의 규칙을 이용하라고 알려준다. 따라서 함수에 등장하는 return문들의 타입을 auto가 아닌 decltype을 통해 확인하고, 이들이 모두 일치하는 경우에 함수의 반환 타입을 이들과 같도록 결정하는 것이다. 즉 decltype(auto)auto와 비슷하지만, auto의 비직관적인 규칙들을 모두 배제하고 해당 이름의 선언된 타입을 그대로 알려준다고 생각하면 된다!

Widget w;
const Widget &cw = w;             // cw의 타입은 const Widget&
auto myWidget1 = cw;              // myWidget1의 타입은 Widget (&와 const를 제거)
decltype(auto) myWidget2 = cw;    // myWidget2의 타입은 cw 그대로 const Widget&

위와 같이 함수의 반환 타입을 결정할 때뿐만 아니라 변수를 선언할 때에도 decltype(auto)를 이용할 수 있다.

decltype의 특이한 행동
decltype을 이름에 대해 사용할 때에는 항상 그 선언된 타입을 그대로 알려준다. 그러나 표현식에 대해서 사용할 경우에는 그렇지 않을 수 있다. 이름이 아닌, 타입 T인 좌측값 표현식에 대해서 decltype을 사용하면 T&가 결과로 나오는 것이다. 예를 들어 int x = 0;으로 선언되었을 때 decltype(x)int를 보고하지만, decltype((x))int&를 보고하게 된다. 따라서 decltype을 사용할 때에는 이러한 측면에 유의하여야 한다.

04. 추론된 타입을 파악하는 방법을 알아두라

이 항목은 직접적으로 코딩을 하는 방법이라기보다는, 디버깅을 하기 위한 방법에 가깝다. 위에서 살펴봤다시피 autodecltype이 결정해주는 타입 추론은 비직관적일 때도 있으니, 이들을 확인할 수 있는 방법들을 알아놓으라는 것이다. 크게 네 가지가 나온다.

template<typename T> class TD;

와 같이 더미 클래스를 하나 만들어놓고, 타입을 확인하고 싶을 때는

TD<decltype(x)> xType;
TD<decltype(y)> yType;

와 같이 코드를 작성한다. 그러면 TD가 제대로 정의되어 있지 않으므로

error: aggregate ‘TD<int> xType’ has incomplete type and cannot be defined
error: aggregate ‘TD<const int *> yType’ has incomplete type and cannot be defined

와 같이 에러 메시지가 변수 x, y의 타입을 알려주게 된다.

#include <boost/type_index.hpp>

template<typename T>
void printType(const T& param){
    using std::cout;
    using boost::typeindex::type_id_with_cvr;
    cout << "T =  " 
         << type_id_with_cvr<T>().pretty_name() << "\n";
    cout << "param =  " 
         << type_id_with_cvr<decltype(param)>().pretty_name() << "\n";
}

위의 함수를 사용하면 아래와 같이 T에 매칭된 타입과 param으로 전달되는 타입을 정확히 출력해준다.

std::vector<Widget> createVec();

const auto vw = createVec();
printType(&vw[0]);   // 위에서 정의한 printType을 호출 
// 결과
T = Widget const*
param = class Widget const * const &

Chapter 2. auto

05. 명시적 타입 선언보다는 auto를 선호하라

auto는 개념적으로는 매우 간단하고 편리한 기능이지만, 앞 장에서 살펴봤듯이 생각보다는 미묘한 구석이 있다. 그럼에도 저자는 명시적으로 타입을 선언하지 말고, 최대한 auto를 써서 코드를 작성하라고 권고하고 있다.

06. auto가 원치 않은 타입으로 추론될 때에는 명시적 타입의 초기치를 사용하라

여러 라이브러리들을 사용해 개발을 하다보면, 대리자 클래스(proxy class) 등이 방해가 되는 경우가 많다. 대표적인 예시로 std::vector<bool>이 있다.

std::vector<bool> w = {true, false, true, true};
auto bit = w[3];

직관적으로 생각하면 위의 코드에서 bit는 (당연히) bool&의 타입을 가져야 할 것이다. 그러나 실제로는 그렇지 않고 std::vector<bool>::reference 타입을 가진다. std::vector는 효율성을 위해 bool일 때에는 특별한 구현을 하기 때문이다. 이와 같이 다른 어떤 타입의 행동을 흉내내는 것이 목적인 클래스를 대리자 클래스라고 한다. 이렇듯 대리자 클래스가 있는 경우에는 auto를 사용한 타입 추론에 방해가 된다.

하지만 저자는 이와 같은 경우에도 auto를 버리지 말라고 말고 static_cast를 이용하는 해결책을 제시한다. 위와 같은 경우에는 다음과 같이 쓸 수 있다.

auto bit = static_cast<bool>(w[3]);

이렇게 하면 w[3]의 타입이 bool로 강제로 캐스팅되면서, auto는 의도한대로 bool 타입을 가질 수 있게 된다. 이와 같은 패턴을 타입을 명시적으로 지정한 초기치 관용구(explicitly typed initializer idiom)라고 한다.


Chapter 3. 모던 C++에 적응하기

07. 객체 생성 시 괄호(())와 중괄호({})를 구분하라

C++11부터는 객체의 생성을 위한 구문이 매우 다양해졌다. 새로운 객체를 생성할 때, 괄호, 등호, 그리고 중괄호가 모두 가능하다.

int x(0);
int y = 0;
int z{ 0 };
int w = { 0 };     // int w{ 0 };과 똑같이 처리됨

균일 초기화의 장점
그런데 이렇게 초기화를 위한 문법이 다양해질수록 오히려 혼란이 가중되기 쉽다. 예시로 다음 상황을 보자

Widget w1;
Widget w2 = w1;    // 대입이 아닌 복사 생성자가 호출
w1 = w2;           // 대입이 호출

Widget w2 = w1;는 등호만 보고 대입(assignment)이 일어난다고 생각하기 쉽지만, 실제로는 복사 생성자(copy constructor)가 호출되어 Widget w2(w1)과 같은 효과를 가져온다. 이 때문에 중괄호를 사용한 균일 초기화(Uniform initialization)만을 사용하여 코드를 작성해야 한다고 주장하는 사람들이 많다. 균일 초기화는 어느 상황에서나 사용할 수 있고, 모든 것을 표현할 수 있다.

std::vector<int> v{ 1, 3, 5 };
int x{ 0 };
std::atomic<int> ai{ 0 };

이렇게 모든 경우에서 중괄호만으로 균일하게 초기화 구문을 작성할 수 있다는 점은 균일 초기화의 큰 장점이다. 균일 초기화는 축소 변환(narrowing conversion)을 방지해준다. 예를 들어 다음 코드를 보자.

double x, y, z;
int sum1 = x + y + z;    // x + y + z가 int의 표현범위로 잘려나감
int sum2(x + y + z);     // 마찬가지
int sum3 { x + y + z };  // double들의 합을 int로 나타내지 못할 수 있음을 감지하고 에러 발생

균일 초기화를 사용하지 않은 sum1sum2에서는 x+y+zint의 표현가능 범위에 맞게 잘려나가는 축소 변환이 이루어졌다. 프로그래머가 이것을 의도한 경우라면 상관이 없겠지만, 이는 예상하지 못한 결과를 야기할 수도 있다. 반면 균일 초기화를 사용한 경우 컴파일러는 int 타입의 sum1이 덧셈 결과를 표현하지 못함을 감지하고 코드를 거부한다.

마지막으로, 균일 초기화는 가장 성가신 파싱(Most Vexing Parse)에서 자유롭다. 이는 C++의 "함수로 해석이 가능한 것들은 반드시 함수로 해석한다"는 규칙 때문에 나타나는 번거로움을 의미한다. 예를 들어

Widget w();     // C++은 이것을 함수 선언으로 해석한다

와 같은 코드를 보면 C++은 Widget 타입의 w를 파라미터 없는 생성자로 초기화한다고 생각하지 않고, "입력이 없고 출력이 Widget 타입인, w라는 이름의 함수를 선언한다"고 해석해버린다. 반면 균일 초기화를 사용하면 다음과 같이 이러한 문제를 피해갈 수 있다.

Widget w {};    // 함수 선언으로 해석될 여지가 없다

균일 초기화 사용 시 유의할 점
그러나 이렇게 편리한 균일 초기화도 문제점이 있는데, 종종 예상하지 못한 결과를 낳는다는 점이다. 항목 02에서 살펴봤듯, 균일 초기화를 사용하면 타입은 std::initializer_list로 추론된다. 여기서 문제점은 생성자 오버로딩을 해소할 때, 컴파일러는 std::initializer_list 타입을 받는 생성자를 매우 강하게 선호한다는 점이다. 예시를 보자.

class Widget{
public:
    Widget(int i, bool b);
    Widget(int i, double b);
}

Widget w1(10, true);      // 첫 번째 생성자 호출
Widget w2(10, 3.5);       // 두 번째 생성자 호출

Widget w1{ 10, true };    // 첫 번째 생성자 호출
Widget w2{ 10, 3.5 };     // 두 번째 생성자 호출

이렇게 std::initializer_list 형식의 매개변수를 가진 생성자가 없는, 일반적인 상황에서 이는 전혀 문제가 되지 않는다. 그러나 그러한 생성자를 하나 추가해버리면 이야기가 달라진다.

class Widget{
public:
    Widget(int i, bool b);
    Widget(int i, double b);
    Widget(std::initializer_list<long double> il);    // 새로운 생성자 추가
}

Widget w1(10, true);      // 첫 번째 생성자 호출
Widget w2(10, 3.5);       // 두 번째 생성자 호출

Widget w1{ 10, true };    // 세 번째 생성자가 호출됨!!
Widget w2{ 10, 3.5 };     // 세 번째 생성자가 호출됨!!

이렇게 컴파일러는 중괄호 초기화 구문을 std::initializer_list 타입의 매개변수로 해석할 여지가 조금이라도 있다면 무조건 std::initializer_list 타입의 생성자를 호출해버린다. 컴파일러의 std::initializer_list 선호가 어느 정도로 강하냐면, 다음 예시에는 아예 컴파일을 거부해버린다.

class Widget{
public:
    Widget(int i, bool b);
    Widget(int i, double b);
    Widget(std::initializer_list<bool> il);  // std::initializer_list의 요소를 bool 타입으로 바꿈
}

Widget w1{ 10, true };    // 그냥 첫 번째/두 번째 생성자를 사용하면 될텐데도
Widget w2{ 10, 3.5 };     // 포기하지 않고 세 번째 생성자를 쓰려 하다 축소 변환 때문에 컴파일 에러 발생

이는 일상적인 사용과는 관련이 없는 edge case로 보일 수도 있겠지만, 여기에 직접 영향을 받는 예시 중 하나가 다름아닌 std::vector이기 때문에 알아둘 필요가 있다.

std::vector<int> v1(10, 20);    // 비std::initializer_list 생성자 사용, 
                                // 20이 10개 들어있는 vector 생성

std::vector<int> v2{ 10, 20 };  // std::initializer_list 생성자 사용, 
                                // 20과 10의 두 원소를 가진 vector 생성

즉, std::initializer_list 생성자가 하나라도 있으면 나머지 생성자들은 아예 경쟁 상대에서 제외될 정도로 std::initializer_list 생성자는 강력하게 선호된다. 따라서 애초에 클래스를 작성할 때 이러한 메서드는 꼭 필요할 때만 사용해야 한다.

균일 초기화와 기존의 괄호 초기화는 모두 일장일단이 있으므로 세심한 선택을 필요로 하며, 커뮤니티의 공감대가 형성되어 있지 않다. 저자는 둘 중 하나를 선택해 일관되게 사용하는 것을 추천한다.

08. 0과 NULL보다 nullptr을 선호하라

null 포인터를 표현하기 위해 기존 C/C++ 개발자들은 0이나 NULL을 사용해왔다. 그러나 이들은 기본적으로는 포인터 타입이 아니라는 데서 문제가 있다. 따라서 컴파일러가 오버로딩된 함수들 중 어떤 것을 호출할지 정할 때, 0이나 NULL을 함수에 전달하면 이는 포인터 타입으로 해석되지 않을 수 있다.

void f(int);
void f(bool);
void f(void*);

f(0);        // 반드시 f(int)를 호출
f(NULL);     // 컴파일 에러가 나거나 f(int)를 호출

따라서 애초에 정수 타입과 포인터 타입 간의 오버로딩을 피하는 것이 가장 좋지만, 이러한 문제를 해결하기 위해 C++11부터는 nullptr의 개념이 추가되었다. 이는 모든 포인터 타입으로 캐스팅될 수 있는 std::nullptr_t이라는 타입을 가진다. 따라서 위와 같은 코드에서,

f(nullptr);  // f(void*)를 호출

을 사용했더라면 더 좋았을 것이다. 특히 템플릿을 사용할 때 nullptr의 장점이 돋보이게 된다. 또 nullptr은 코드의 명확성도 높여준다.

auto result = f(...);
if (result == 0) {...}        // 0을 사용한 경우
if (result == nullptr) {...}  // nullptr을 사용한 경우

둘째 줄과 셋째 줄 중 어느 것이 result가 포인터 타입임을 더 잘 드러내는지는 명백하다.

09. typedef보다 별칭 선언을 선호하라

특히 템플릿을 많이 사용하는 경우, C++의 타입 이름은 복잡해지기 쉽다. 이런 경우를 위해 C++에서는 typedef 키워드를 제공한다.

typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;

그런데 모던 C++에서는 typedef 이외에도 별칭 선언(alias declaration)이라는 방법을 사용할 수도 있다. 이 경우 using 키워드를 사용하면 된다.

using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;

별칭 선언이 typedef에 비해 가지는 장점은 크게 두 가지이다. 먼저, 함수 포인터 타입을 더 직관적으로 다룰 수 있다 intconst std::string 타입의 두 매개변수를 받아 아무것도 돌려주지 않는 함수에 대한 포인터 타입을 나타낸다고 하자. 이를 typedefusing에서는 각각 다음과 같이 나타내야 한다.

typedef void (*FP)(int cosnt std::string&);    // FP의 위치가 매우 비직관적이다
using FP = void (*)(int, const std::string);

한눈에 보아도 using을 사용한 구문이 훨씬 간편하고 이해하기 쉬운 것을 알 수 있다. 하지만 이보다 훨씬 강력한 장점이 있는데, 바로 별칭 선언은 템플릿화를 할 수 있다는 점이다. std::list<T, MyAlloc<T> >를 임의의 타입 T에 대해
별명을 짓고 싶다고 하자. typedef를 이용하면 다음과 같이 수행해야 한다.

template<typename T>
struct MyAllocList {
    typedef std::list<T, MyAlloc<T> > type;
}

MyAllocList<Widget>::type lw;    // MyAllocList<T>::type가 std::list<T, MyAlloc<T> >와 같다 

반면 using을 이용해 별칭 선언을 해주면 번거롭게 ::type을 붙일 필요가 없어진다.

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T> >;

MyAllocList<Widget>lw;          // MyAllocList<T>::type가 std::list<T, MyAlloc<T> >와 같다 

한편, C++11에서는 그럼에도 불구하고 typedef를 이용한 것과 같은 방식으로 정의된 템플릿 타입들이 존재한다. 이들은 C++14에서 별칭 선언으로 다시 정의되어 표준에 포함되었다. 그 덕분에 번거롭게 ::type을 붙일 필요가 없어졌다.

// C++11
std::remove_const<T>::type         // const T를 T로 변환
std::remove_reference<T>::type     // T&나 T&&를 T로 변환
std::add_lvalue_reference<T>::type // T를 T&로 변환

// C++14
std::remove_const_t<T>             // const T를 T로 변환
std::remove_reference_t<T>         // T&나 T&&를 T로 변환
std::add_lvalue_reference_t<T>     // T를 T&로 변환

10. 범위 없는 enum보다 범위 있는 enum을 선호하라

기존 C++에서 사용되던 enum은 모던 C++에서는 범위 없는(unscoped) enum이라고 불린다. enum이 유효한 스코프 내에서는 enum 안에서 정의된 열거자의 이름들도 전부 유효하기 때문이다. 이는 불편한 점이 많다.

enum Color { black, white, red };

auto white = red;    // 이미 Color에서 red를 정의했으니 오류가 발생

따라서 모던 C++에서는 범위 있는(scoped) enum이 도입되었다. 이는 기존과 달리 enum이 아닌 enum class로 선언되며, 이 때문에 enum 클래스라고 불리기도 한다. 실제로도 클래스와 비슷하게 행동하는 부분이 있다.

enum class Color { black, white, red };

auto white = red;        // OK
Color c1 = Color::white; // OK
auto c2 = Color::red;    // OK

이뿐만 아니라 암묵적으로 정수 타입으로 변환되는 enum의 열거자들과는 달리, 범위 있는 enum은 다른 타입으로 변환되지 않는다. 따라서 다음과 같이 비정상적인 사용을 막을 수 있다.

enum Color = { black, white, red };       // 범위 없는 enum으로 선언
Color c = Color::red;
if (c < 14.5) {...}                       // c가 자동으로 정수 2로 변환되어 비교. 비정상적인 사용
enum class Color = { black, white, red };  // 범위 있는 enum으로 선언
Color c = Color::red;
if (c < 14.5) {...}                        // 타입이 서로 다르니 비교 불가. 컴파일 에러
if (dynamic_cast<int>(c) < 14.5) {...}     // 꼭 비교를 하고 싶다면 이렇게 작성

마지막으로, 범위 있는 enum의 경우 전방 선언(forward declaration)이 가능하다. 즉, 열거자들을 지정하지 않고 enum의 이름만 미리 선언할 수 있다.

enum Color;          // 오류
enum class Color;    // 가능!

이 덕분에 enum의 선언은 헤더 파일에서 하고, 그 정의는 다른 파일에 두는 식으로 하여 컴파일 시 의존 관계의 복잡성을 줄일 수 있다.

범위 없는 enum이 유용한 경우
이렇게 보면 범위 없는 enum을 사용해야 할 일은 아예 없는 것처럼 보인다. 그럼에도 범위 없는 enum이 유용한 경우가 있는데, 바로 std::tuple의 필드들을 지칭할 때이다.

using UserInfo = std::tuple<std::string, std::string, std::size_t>;    // 사용자 이름, 이메일 주소, 레이팅을 담은 tuple
UserInfo uInfo;
auto val = std::get<1>(uInfo);                          // UserInfo의 필드 1이 이메일 주소라는 사실을 기억해야 이해 가능

enum UserInfoFields { uiName, uiEmail, uiReputation };  // 각 필드의 인덱스를 enum으로 저장
auto val = std::get<uiEmail>(uInfo);                    // userInfo의 이메일 정보를 가져왔음을 바로 알 수 있다

// 반면 범위 있는 enum을 사용하는 경우 다음과 같이 코드가 복잡해진다.
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);    

11. 정의되지 않은 private 함수보다 삭제된 함수를 선호하라

코드를 다른 개발자에게 배포할 때, 코드에 포함된 특정 함수의 사용을 막아야 하는 경우가 종종 있다. 특히 C++이 자동으로 생성하는 클래스의 멤버 함수에서 이런 일이 생기는데, C++이 자동으로 만들어주는 복사 생성자나 대입 연산자를 제거해야 할 경우 등이 있다.

예를 들기 위해서 iostream에 있는 basic_ios라는 클래스 템플릿을 가져오자. 이는 모든 입출력 스트림(istream, ostream 등등)의 조상 역할을 하는 클래스이다. 그런데, 이러한 스트림 객체는 복사하지 않는 것이 좋기 때문에 복사 생성자와 대입 연산자를 삭제하고 싶다고 하자. C++98 표준에서는 이 문제를 이렇게 해결한다:

// C++98에서 함수의 사용을 막는 방법
template <class charT, class traits = char_traits<charT> >
class basic_ios: public ios_base{
public:
    ...
private:
    basic_ios(const basic_ios&);              // 복사 생성자
    basic_ios& operator = (const basic_ios&); // 대입 연산자
}

이렇게 복사 연산자와 대입연산자를 private으로 선언하고 정의는 하지 않아 외부에서 호출하지 못하게 하는 것이다. 그런데 C++11에서는 더 나은 방법이 선언되는데, 바로 =delete를 사용해 해당 함수를 아예 삭제하는 것이다.

template <class charT, class traits = char_traits<charT> >
class basic_ios: public ios_base{
public:
    ...
    basic_ios(const basic_ios&) = delete;              // 복사 생성자
    basic_ios& operator = (const basic_ios&) = delete; // 대입 연산자
}

앞선 C++98의 관례에서 private으로 선언된 함수의 잘못된 사용은 링킹 단계에 가서야 발견되는 반면, delete를 사용하면 컴파일 자체가 실패하므로 더 효율적이다. 한편, 이렇게 delete를 사용해 삭제될 함수는 public으로 선언하는 것이 관례인데 이는 디버깅의 용이성 때문이다. 많은 컴파일러들이 함수의 사용 가능 여부를 체크할 때 private/public 여부를 먼저 따진 후 삭제되었는지를 따지기 때문에, 이렇게 선언하는 경우에 더 도움되는 에러 메시지들을 얻을 수 있다.

delete의 다양한 용례
클래스에서만 사용가능한 private과 달리, delete는 어떤 함수도 삭제할 수 있다. 이것이 유용하게 사용되는 첫 번째 예시는 자동으로 만들어지는 함수의 오버로딩 버전들을 삭제하는 것이다. 예시로 어떤 숫자가 행운의 숫자인지 알려주는 bool isLucky(int number)가 있다고 하자. 작성자는 숫자만 받을 것을 의도했다고 하더라도, C++은 bool이나 char, double등의 자료형도 isLucky에 들어올 수 있도록 자동으로 캐스팅해버린다. isLucky('a'), isLucky(3.5), isLucky(false)등도 컴파일에 성공하는 것이다.

이런 경우, isLuckybool, char, double 등을 받지 못하게 오버로딩된 버전들을 삭제해버릴 수 있다.

bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

delete템플릿의 특정 인스턴스들을 삭제할 때도 사용이 가능하다. 예를 들어 임의의 포인터를 인수로 받을 수 있는 다음 템플릿 함수가 있다고 하자.

template<typename T>
void processPointer(T * ptr);

그런데, ptr의 타입으로 void*(임의의 포인터를 의미)나 char*(C스타일 문자열을 의미)와 같은 특별한 포인터가 들어와서는 곤란하다. 따라서 이러한 용례로 사용되지 않도록 삭제할 수 있다.

template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;

위와 같이 void*char*을 입력으로 받는 버전들을 삭제해버리면 원하는 타입의 포인터들만 템플릿 함수에 들어오도록 강제할 수 있다.

12. 오버라이딩 함수들을 override로 선언하라

C++에서 객체 지향 프로그래밍은 클래스와 상속, 그리고 가상 함수(virtual function)가 주도한다. 클래스의 상속에서 가장 기본적인 개념 중 하나는 자식 클래스가 부모 클래스의 가상 함수를 오버라이딩(override)하는 것이다. 그런데 가상 함수의 오버라이딩은 잘못 작성하기가 매우 쉽다. 다음 조건을 모두 만족해야 오버라이딩이 일어나기 때문이다.

class Widget{
public:
    void doWork() &;     // *this가 좌측값일 때만 사용 가능
    void doWork() &&;    // *this가 우측값일 때만 사용 가능
}

Widget w;
Widget makeWidget();    // 새 Widget 객체를 만들어주는 함수. 우측값을 반환

w.doWork();    // 좌측값용 doWork()가 호출
makeWidget().doWork()    // 우측값용 doWork()가 호출

이렇게 많은 조건들 중 하나라도 잘못된다면, 오버라이딩은 일어나지 않는다. 문제는 이렇게 프로그래머의 실수로 오버라이딩 조건이 충족되지 않아도 컴파일러는 아무 문제도 일으키지 않는다는 것이다. 예를 들어서 다음 코드는 그 오버라이딩이 단 한번도 일어나지 않는데도 불구하고 컴파일러는 아무런 에러를 발생시키지 않고, 심지어는 경고조차 해주지 않을 수도 있다.

class Base{
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;
}

class Derived: public Base{
public:
    virtual void mf1();                  // const성이 다름
    virtual void mf2(unsigned int x);    // 매개변수 타입이 int와 unsigned int로 불일치
    virtual void mf3() &&;               // 참조한정사가 좌측값과 우측값으로 다름
    void mf4() const;                    // 가상 함수가 아님
}

override 키워드를 사용하면 이와 같이 오버라이딩이 일어나지 않는 경우 컴파일 에러를 발생시킨다. 따라서 프로그래머는 자신이 오버라이딩을 잘못 작성했다는 것을 바로 알 수 있다. 사용법도 간단한데, 위의 Derived 클래스의 각 함수들에 override를 붙여 다음과 같이 선언하도록 바꾸면 된다.

class Derived: public Base{
public:
    virtual void mf1() override;
    virtual void mf2(unsigned int x) override;
    virtual void mf3() && override;
    void mf4() const override;
}

위의 코드는 컴파일 에러를 발생시키며, 오버라이딩이 전혀 의도대로 되지 않고 있다는 것을 경고해줄 것이다.

참조 한정사(reference qualifier)
앞서 잠깐 언급했던 참조 한정사에 대해 좀 더 자세히 알아보자. 일반 함수가 좌측값 또는 우측값만 인수로 받고 싶다면, 다음과 같이 작성하면 된다.

void doSomething(Widget &w);     // 좌측값만 받음
void doSomething(Widget &&w);    // 우측값만 받음

클래스 멤버 함수의 참조 한정사는 이러한 제한을 *this에도 적용할 수 있게 해줄 뿐이다. 마치 *this가 바뀌지 않음을 알려주기 위한 const가 멤버 함수에서는 함수 선언 뒤에 붙는 것과도 같다.

class Widget {
public:
    void doSomething() &;     // Widget 객체가 좌측값일 때만 가능
    void doSomething() &&;    // Widget 객체가 우측값일 때만 가능

    void changeNothing() const;    // Widget 객체가 바뀌지 않음을 알려주는 후행 const 키워드와 유사
}

참조 한정사를 사용해 유용한 상황 중 하나를 제시해보자.

class Widget{
public:
    using DataType = std::vector<double>;
    DataType& data() { return values; }
private:
    DataType values;
}

Widget makeWidget(){
    ...    // 새 Widget 객체를 만들어주는 함수. 우측값을 반환
}    

makeWidget().data();    // makeWidget()이 반환한 우측값에서 values가 복사생성되어 비효율적

위의 코드를 보면, makeWidget()이 반환하는 우측값으로부터 std::vector<double>타입의 values가 복사생성된다. data()가 좌측값 참조를 반환하도록 선언되어 있기 때문에 makeWidget()이 만든 객체에서 values를 좌측값으로 바꿔주려면 복사 연산이 반드시 필요한 것이다.

이러한 문제를 해결하려면 다음과 같이 우측값 참조한정사를 이용해 data()를 하나 더 정의해주면 된다.

DataType&& data() && { return std::move(values); }

13. iterator보다 const_iterator를 선호하라

const_iteratorconst 값을 가리키는 포인터의 STL 버전이라고 할 수 있다. 따라서 이터레이터가 가리키는 것을 수정할 필요가 없을 때는 const_iterator를 쓰는 것이 좋다. 이는 C++98에서도 존재하는 개념이긴 했으나, 실용적으로는 잘 사용되지 않았다. 다음 코드를 보자. 벡터 values에서 1993이라는 값을 찾아 거기에 1998을 삽입하는 일을 const_iterator로 수행하는 코드이다.

typedef std::vector<int>::iterator IterT;
typedef std::vector<int>::const_iterator ConstIterT;

std::vector<int> values;

ConstIterT ci = 
    std::find(static_cast<ConstIterT>(values.begin()),     // const_iterator로 캐스팅 필요
              static_cast<ConstIterT>(values.end()),       // 마찬가지로 캐스팅 필요
              1983);

values.insert(static_cast<IterT>(ci), 1998);               // 다시 일반 iterator로 캐스팅 필요

하지만 C++11에서는 이러한 번거로움이 모두 없어졌다. 이제 다음과 같이 const_iterator를 쉽게 생성하고 사용할 수 있게 되었다.

std::vector<int> values;
auto it = std::find(values.cbegin(), values.cend(), 1983); // cbegin, cend로 const_iterator 생성
values.insert(it, 1998);                                   // const_iterator 객체를 삽입에도 사용 가능

따라서 모던 C++을 사용할 때에는 가능한 한 const_iterator를 쓰는 것이 좋다.

14. 예외를 방출하지 않을 함수는 noexcept로 선언하라

C++98에서는 예외 명세(exception specification)라는 기능이 제공되었다. 프로그래머는 throw 구문을 사용하여 함수가 방출할 예외들의 타입을 명시해줄 수 있었다. 그러나 함수의 구현이 바뀌면 예외도 바뀔 수 있고, 함수의 사용자가 이러한 예외 타입에 의존하는 경우 불편함이 있을 수 있다는 지적이 생겼다. 이에 따라 C++11에서는 함수가 예외를 방출하는지/방출하지 않는지만을 이분법적으로 구분하기로 했다. 바로 noexcept 키워드를 이용해서이다. 이는 클라이언트에 노출되는 함수의 인터페이스의 일부이다.

예외의 방출(emit)은 예외가 함수 바깥으로 전파되는 것을 의미하며, 예외의 발생 또는 던지기(throw)와는 다른 개념이다. 예외가 발생하였지만 함수 바깥으로 전파되지는 않을 수도 있다.

int f(int x) throw();        // f가 예외를 방출하지 않음. C++98 스타일
int f(int x) noexcept;       // f가 예외를 방출하지 않음. C++11 스타일

기존 C++98에서는 throw()를 이용해 예외 방출이 없음을 명시해주었다. throw()로 예외 명세를 한 함수는 예외 명세가 위반되면 호출 스택에서 f가 호출된 지점까지를 비우고 몇 가지 예외처리를 한 후 프로그램이 종료된다. 반면 noexcept는 스택을 비워야 한다는 것이 규정되어 있지 않다. 따라서 컴파일러가 최적화할 수 있는 여지가 더 많아진다.
특히 “이동 연산이 안전함이 확실하면 이동하고, 아니면 복사한다”는 전략으로 작동하는 STL의 많은 함수들에서는 noexcept가 최적화의 여지를 더 많이 줄 수 있다. 예외가 발생하지 않는다는 여지가 없는데도 이동 연산을 써버리면, 예외가 발생했을 때 원래의 상태로 복구할 수 없기 떄문이다.
그럼에도 불구하고 noexcept로 선언하기 위해 함수의 구현을 억지로 바꾸는 바람직하지 않다. 따라서 대부분의 함수는 예외에 중립적(exception-neutral)이다. 즉 자기 자신은 예외를 던지지 않지만, 예외를 던지는 다른 함수들을 호출할 수는 있다.

15. 가능하면 항상 constexpr을 사용하라

constexpr은 C++11에서 가장 헷갈리는 개념 중 하나이다. 개념적으로 constexpr어떠한 값이 상수일 뿐만 아니라, 컴파일 시점에서 알려진다는 것을 의미한다. 그런데 이는 객체에 적용될 때는 항상 맞는 말이지만, 함수에 constexpr이 적용될 때에는 그렇지 않을 수 있다. 여기에 대해 좀 더 알아보자.

constexpr 객체
constexpr객체에 적용된 경우, 객체는 실제로 const이며 컴파일 시점에서 알려진다. 따라서 읽기전용 메모리에 배치되거나, 배열의 크기나 정수 템플릿 인수 등 정수 상수 표현식이 요구되는 상황에서 사용할 수 있다.

int sz;
constexpr auto arraySize1 = sz;    // sz의 값이 컴파일 시점에 알려지지 않으니, 오류
std::array<int, sz> data1;         // sz이 정수 템플릿 인수에 들어감. 같은 이유로 오류

constexpr auto arraySize2 = 10;    // 10은 컴파일시점 상수가 맞으니 OK
std::array<int, arraySize2> data2; // OK

반면 그냥 const는 반드시 컴파일 시점에서 알려진 값일 필요는 없다. 따라서 모든 constexprconst이나, 역은 성립하지 않는다고 말할 수 있다.

constexpr 함수
반면 함수를 constexpr으로 선언하는 것도 가능하다. constexpr 함수들은 컴파일시점 상수가 인수로 들어온 경우, 컴파일시점 상수를 반환한다. 즉, 함수의 결과가 컴파일 도중에 계산되는 것이다. 반면 그렇지 않은(실행시점에야 알려지는) 값이 인수로 들어오면 보통의 함수처럼 행동한다.

constexpr 함수는 반드시 입력/반환 타입이 모두 리터럴 타입, 즉 컴파일 시 값을 결정할 수 있는 타입을 사용해야만 한다. C++11에서는 void를 제외한 모든 타입이 여기에 해당하고, 생성자 및 적절한 멤버 함수들이 constexpr인 커스텀 타입도 리터럴 타입이 될 수 있다.

class Point{
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept
    : x(xVal), y(yVal) {}

    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }

    void setX(double newX) noexcept { x = newX; }
    void setY(double newY) noexcept { y = newY; }

private:
    double x, y;
}

위의 클래스에서 생성자는 constexpr으로 선언될 수 있다. 주어진 인수들이 전부 컴파일시에 알려진다면 Point 객체의 자료값들도 컴파일시에 결정되기 때문이다. Getter 메서드인 xValue, yValue 또한 Point가 컴파일 시에 알려지면 그 반환값을 컴파일시에 결정할 수 있으니, constexpr로 선언하는 것이 가능하다.

반면 setXsetY는 C++11에서는 constexpr로 선언할 수 없다. 여기에는 두 가지 이유가 있다.

C++14에서는 이 두 제약조건이 모두 사라졌기 때문에, 두 setter 함수도 constexpr로 작성할 수 있다.

constexpr void setX(double newX) noexcept { x = newX; }
constexpr void setY(double newY) noexcept { y = newY; }

16. const 멤버 함수를 스레드에 안전하게 작성하라

다항식을 저장할 수 있는 클래스와 그 내부함수로 다항식의 근들을 계산하는 함수를 만들었다고 가정하자. 근을 계산하는 함수는 다항식 자체를 바꾸지는 않으므로 const로 선언하는게 적절하다.

class Polynomial{
public:
    using RootsType = std::vector<double>;

    RootsType roots() const;
    ...
}

그런데 다항식 계산의 비용이 클 수 있으니 캐싱을 적용한다고 가정하자. 그러면 다음과 같이 될 것이다.

class Polynomial{
public:
    using RootsType = std::vector<double>;

    RootsType roots() const{
        if(!rootsAreValid){
            ...                             // 캐시가 유효하지 않으면 다시 계산
            rootsAreValid = true;
        }
        return rootVals;
    }
private:
    mutable bool rootsAreValid{ false };  // mutable 적용시, const 함수도
    mutable RootsType rootVals{};         // 이들 변수를 변경할 수 있음
}

그런데, 만약 두 스레드가 같은 Polynomial에 대해서 roots()를 동시에 호출한다면 동기화 없이 같은 메모리에 접근하는 레이스 컨디션이 발생할 수 있다. 이는 위험한 상황으로, 정의되지 않은 행동을 유발할 수 있다. 이 책에서는 이를 해결하는 방법으로 두 가지를 제시한다.

뮤텍스
뮤텍스는 가장 보편적인 동기화 방법이다. C++에서 이는 std::mutex로 사용 가능하다.

class Polynomial{
public:
    using RootsType = std::vector<double>;

    RootsType roots() const{
        std::lock_guard<std::mutex> g(m); // 뮤텍스를 잠근다
        if(!rootsAreValid){
            ...                             
            rootsAreValid = true;
        }
        return rootVals;
    }                                     // 뮤텍스를 푼다
private:
    mutable std::mutex m;                 // 뮤텍스 변수 정의
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
}

이렇게 하면 roots() 실행을 위해 뮤텍스 m을 획득해야 하기 때문에 두 스레드에서 동시에 실행되는 것을 막을 수 있다. 이때 std::mutex는 복사나 이동이 불가능하기 때문에, Polynomial 자체도 복사나 이동이 불가능해짐에 유의해야 한다.

std::atomic 카운터
뮤텍스 사용이 너무 과하고 계산 자원을 많이 소모한다고 생각되는 경우가 있다. 예를 들어서, 아래 예시와 같이 멤버 함수가 호출되는 횟수를 세고 싶다면 std::atomic을 이용해 멤버 함수의 호출 횟수를 셀 수 있다.

class Point{
public:
    ...
    doubleDistanceFromOrigin() const noexcept {
        ++callCount;              // 원자적(atomic) 연산
        return std::hypot(x, y);  // sqrt(x^2+y^2)을 계산하는 함수
    }
private:
    mutable std::atomic<unsigned> callCount{ 0 };
    double x, y;
}

이 경우 callCount의 증가 연산은 원자적(atomic)으로, 즉 하나의 기계어 명령으로 취급되어 이루어지기 때문에, 중간에 다른 스레드가 끼어드는 일 없이 안전하게 값을 증가시킬 수 있다.

이렇듯,

17. 특수 멤버 함수들의 자동 작성 조건을 숙지하라

특수 멤버 함수(special member function)란 C++이 자동으로 작성하는 멤버 함수들을 말한다. C++98에서 특수 멤버 함수들로는 다음이 있었다.

이들은 모두 디폴트로 public이고, inline이며, virtual으로 생성된다는 (단, 부모 클래스의 소멸자가 virtual이면 자식 클래스의 소멸자도 virtual) 공통점이 있다. C++11에는 여기에 더해 두 개의 특수 멤버 함수가 추가되었다:

이를 코드로 표현하면 다음과 같다.

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

즉, 이 두 멤버 함수는 각각 복사 생성자와 복사 대입 연산자의 인수를 좌측값 참조자에서 우측값 참조자로 바꾸고, 복사 대신 이동(즉 std::move)을 수행하는 것과 같다고 할 수 있다.

이들 특수 멤버 함수들이 어떤 경우에 자동으로 작성되는지를 잘 숙지하고 있어야 클래스가 관리하는 자원을 알맞게 다루도록 특수 멤버 함수들을 구성할 수 있다. C++98에서는 Rule of Three라는 것이 있어서, 복사 생성자와 복사 대입 연산자, 소멸자 중 하나라도 선언하면 나머지 둘도 선언해야 한다는 법칙이 있었다. 모던 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에서 다룬다.

25. 오른값 참조에는 std::move를, 보편참조에는 std::forward를 사용하라

오른값 참조는 이동가능한 객체에만 바인딩된다. 그런데 이렇게 이동가능한 객체를 다른 함수에 넘겨주되, 객체의 오른값 성질을 활용할 수 있도록 넘겨주어야 하는 경우도 있다. 이것이 사실상 std::move의 존재이유이다.

class Widget{
public:
    Widget(Widget&& rhs): // rhs의 타입은 Widget&&이지만, rhs는 함수 안에서는 왼값이다.
      : name(std::move(rhs.name)),   // rhs가 가리키는 객체의 오른값 성질을 활용할 수 있도록
        p(std::move(rhs.p)) {        // std::move를 이용해 오른값으로 캐스팅해준다
        ...
    }
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
}

반면 보편참조가 바인딩되는 대상은 이동이 가능할 수도, 아닐 수도 있다. 따라서 보편참조는 오른값이 들어오는 경우에만 오른값으로 캐스팅되어야 하는데, 이는 바로 std::forward가 하는 일이다. 보편참조(universal reference)를 다른 말로 전달 참조(forwarding reference)라고 부르는 이유도 보편참조를 일반적으로 std::forward하여 사용하기 때문이다.

class Widget{
public:
    template<typename T>
    void setName(T&& newName){           // newName은 보편참조
        name = std::forward<T>(newName); // 오른값이 들어오면 이동대입, 왼값이면 복사대입
    }
}

정리하자면, 오른값 참조는 std::move와 함께, 보편참조는 std::forward와 함께 사용하여야 한다. 오른값 참조를 std::forward와 함께 사용하는 것은 가능하나 불필요하게 코드가 복잡해지며, 보편참조를 std::move로 사용하는 경우 의도치 않은 이동이 발생할 수 있으니 피해야 한다.

std::movestd::forward는 마지막 한 번에만 적용해줘야 한다
한편, 오른값 참조나 보편참조에 바인딩된 객체가 한 함수에서 여러 번 사용되는 경우도 있을 수 있다. 이러한 경우에는 std::movestd::forward는 해당 참조를 마지막으로 사용할 때만 적용해줘야 한다. 그러지 않으면 해당 변수를 사용하는 도중에 값이 이동하여 그 자리를 미정의 값이 채우게 될 수도 있다. 예를 들어 다음의 코드를 보자.

template<typename T>
void setSignText(T&& text){
    sign.setText(text);
    auto now = std::chrono::system_clock::now();
    signHistory.add(now, std::forward<T>(text)); // text 마지막 사용때만 forward 적용
}

Return-by-value 함수가 오른값/보편참조에 바인딩된 객체를 반환한다면 std::move/std::forward를 적용하자
다음과 같은 행렬 덧셈 함수를 보자. lhs에는 오른값이 들어온다는 사실이 보장되어 있는 경우에 사용할 수 있는 함수이다.

Matrix operator+(Matrix&& lhs, const Matrix& rhs){
    lhs += rhs;
    return std::move(lhs); // lhs에 std::move 적용
}

위와 같은 경우에는 return 값이 반환하는 lhs가 오른값 참조에 바인딩되어 있기 때문에, std::move로 반환값을 한번 감싸주어 오른값으로 캐스팅해주면 복사 대신 이동을 하게 되고, 더 효율적인 코드가 만들어진다. 만약에 Matrix가 이동을 지원하지 않는 클래스라고 해도 문제가 생기지는 않는다. 반환된 오른값이 복사 생성사에 의해 이동 대신 복사될 뿐이기 때문이다.

오른값 참조 대신 보편참조가 사용될 때도 마찬가지이다.

template<typename T>
Fraction reduceAndCopy(T&& frac){
    frac.reduce();
    return std::forward<T>(frac);  // frac에 std::forward 적용
}

위 코드에서 std::forward가 없다면 frac은 무조건 reduceAndCopy의 반환값으로 복사되어야 했을 것이다. std::forward로 처리해줌으로써, 적어도 frac이 오른값일 때에는 복사 연산을 피할 수 있게 되었다.

그런데 이러한 테크닉을 과도하게 적용해서, 함수가 지역변수를 반환할 때에도 이러한 최적화를 적용하려 시도하는 경우가 있다. 예를 들어서,

// makeWidget의 '복사' 버전 (실제로 복사는 일어나지 않음)
Widget makeWidget(){
    Widget w;
    ...
    return w;   // std::move(w);는 불필요
}

와 같은 코드에서 return w;return std::move(w);로 바꾸는 것을 시도할 수 있다. 위와 같은 경우는 복사가 일어나지 않도록 컴파일러가 이미 최적화되어 있기 때문이다. 위의 코드처럼 지역변수를 반환값으로 사용하는 경우, 컴파일러는 w를 애초에 함수 반환을 위한 메모리 주소에 할당해놓는다. 이를 반환값 최적화(return value optimization, RVO)라고 한다. 반환값 최적화가 적용되는 조건은 다음과 같다.

  1. 그 지역 객체의 타입이 함수의 반환 타입과 같아야 하고
  2. 그 지역 객체 자체가 바로 함수의 반환값이어야 한다.

위의 코드에서 나오는 w는 둘 모두를 만족하므로 반환값 최적화가 적용되어 w의 복사를 피할 수 있게 된다. 반면 사용자가 과도하게 최적화를 시도해 코드를

// makeWidget의 '이동' 버전
Widget makeWidget(){
    Widget w;
    ...
    return std::move(w);
}

와 같이 바꾸었다면, 위의 2번 조건을 만족하지 못하게 된다. 지역 객체 w를 돌려주는게 아니라 std::move(w)의 결과를 돌려주는 것이 되기 때문이다. 즉 컴파일러가 할 수 있는 최적화를 오히려 방해하게 된다.

혹자는 컴파일러가 최적화를 수행하지 않을 수도 있기 때문에, 그런 가능성에 대비해 '이동' 버전의 코드를 작성해야 한다고 주장할 수도 있을 것이다. 그러나 C++ 표준을 따르는 컴파일러는 반환값 최적화가 가능한 경우라면 반드시 그 지역객체를 오른값으로 취급하도록 되어 있다. 즉, 따로 써주지 않아도 코드가 위의 '이동' 버전으로 바뀌어 처리되는 것이다. 따라서 반환값 최적화의 대상이 될 수 있는 지역 객체를 반환할 때에는 std::movestd::forward를 적용하지 말아야 한다.

26. 보편참조에 대한 오버로딩을 피하라

보편참조가 도움을 줄 수 있는 예시를 들기 위해, 다음과 같은 함수를 보자.

std::multiset<std::string> names; // 전역 변수

void logAndAdd(const std::string& name){
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");        // 시간을 로그에 기록
    names.emplace(name);           // 이름을 전역 자료구조에 추가
}

위의 코드는 잘 작동하지만, 비효율적이다. name에 왼값이 들어가는 경우 어차피 복사를 해야 하니 상관이 없으나 오른값이 들어가는 경우 이동을 할 수 있는데도 복사가 일어나게 된다. 오른값 참조를 받는 함수를 따로 만들어 오버로딩 할 수도 있겠지만, 보편참조를 이용해서 한번에 해결해주자.

template<typename T>
void logAndAdd(T&& name){
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");                // 시간을 로그에 기록
    names.emplace(std::forward<T>(name));  // 이름을 전역 자료구조에 추가
}

std::string petName("Darla");
logAndAdd(petName);               // T가 std::string&로 추론, 복사 발생
logAndAdd(std::string("Darla"));  // T가 std::string&&로 추론, 이동 발생

여기까지는 아무런 문제가 없다. 그런데, logAndAdd의 다른 오버로딩 버전을 만들고 싶다고 하자. 이번에는 이름을 logAndAdd에 직접 넣어주는게 아니라, 이름의 인덱스에 해당하는 정수를 logAndAdd에 전달하면 색인에서 이름을 찾아 names에 추가해주도록 구현해주고 싶다고 하자.

void logAndAdd(int idx){   // int 타입을 받는 오버로딩 버전
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

logAndAdd(std::string("Darla")); // 보편참조 버전 호출
logAndAdd(42);                   // int 버전 호출

int 타입을 넣었을 때는 위 코드가 잘 작동한다. 문제는 int가 아닌 다른 정수 타입이 전달되었을 때 발생한다.

short nameIdx = 42;
logAndAdd(nameIdx); // 오류!

nameIdx는 그 자체로는 int가 아니며, int승격(promotion)되어야 logAndAdd(int idx)에 들어갈 수 있게 된다. 그런데 오버로딩 해소 규칙에 의해, 정확한 매칭이 승격을 통한 매칭보다 우선시되므로 위의 경우에는 int 버전이 아닌 보편참조 버전의 logAndAdd가 호출된다. 결국 names.emplace의 호출에서 short를 인수로 받는 오버로딩 함수가 없으니 에러가 발생한다.

책의 표현을 빌리자면, 보편참조를 받는 템플릿 함수는 C++에서 가장 욕심이 많은 함수이다. 거의 모든 타입의 인수와 정확히 매칭되기 때문이다. 이는 프로그래머의 예상보다 훨씬 많은 타입의 인수들을 빨아들이는 결과를 낳게 된다. 따라서 보편참조와 오버로딩을 결합하는 것은 피해야 한다.

달 생성자의 오버로딩
완벽전달 생성자, 즉 클래스의 생성자를 보편참조를 이용하여 선언한 경우는 더 나쁜 결과가 초래된 수 있다. 다음과 같은 클래스가 있다고 하자.

class Person{
public:
    template<typename T>
    explicit Person(T&& n): name(std::forward<T>(n)) {} // 완벽전달 생성자
    explicit Person(int idx): name(nameFromIdx(idx)) {}
private:
    std::string name;
}

위 클래스 Person 의 경우 생성자가 보편참조 버전(즉 완벽전달 생성자)과 int 타입을 받는 버전의 두 가지가 오버로딩되어 존재한다. 그런데, 문제는 C++ 컴파일러가 복사생성자와 이동생성자를 자동으로 작성한다는 점이다. 이는 심지어 템플릿화된 생성자가 복사 생성자나 이동 생성자에 해당하는 모습으로 인스턴스화될 수 있는 경우에도 마찬가지이다. 따라서, 실제 Person 클래스에는 위에서 선언해준 두 생성자 외에도 컴파일러가 자동작성한

Person(const Person& rhs);    // 복사 생성자
Person(Person&& rhs);         // 이동 생성자

의 두 생성자가 공존하게 된다. 이는 다음과 같은 코드에서 문제를 일으킨다.

Person p("Nancy");
auto cloneOfP(p);    // 복사 생성자가 아닌 완벽전달 생성자가 호출

p를 복사해 cloneOfP를 만드려고 했는데, 컴파일 에러가 발생하게 되는 것이다. 그 이유는 이러하다. p의 타입은 const Person이 아니라 Person이므로, (컴파일러가 작성한) 복사 생성자 Person(const Person& rhs);와 타입이 정확하게 부합하지 않는다. 따라서 복사 생성자가 아닌, "TPerson으로 추론된 완벽전달 생성자"에 매칭되어 완벽전달 생성자가 호출되고, std::string 타입인 멤버 변수 namePerson 타입으로 복사생성될 수 없으니 컴파일 에러가 나는 것이다.

한편, 위 코드의 첫 줄을 const Person p("Nancy");로 바꾸면 정상적으로 복사 생성자가 호출된다. 오버로딩 해소 규칙 중에는 템플릿 함수와 비템플릿 함수 중 비템플릿 함수를 우선시한다는 규칙도 포함되어 있기 때문이다.

클래스에 상속이 개입될 때는 이러한 문제가 더욱 심각해진다. Person을 상속받는 SpecialPerson 클래스가 있다고 하자. 복사, 이동생성자를 다음과 같이 통상적인 방식으로 구현하였다.

class SpecialPerson: public Person {
public:
    SpecialPerson(const SpecialPerson& rhs) : Person(rhs)
    { ... } // 복사생성자

    SpecialPerson(SpecialPerson&& rhs) : Person(std::move(rhs))
    { ... } // 이동생성자
};

위의 코드에서 SpecialPerson의 복사, 이동생성자들은 Person의 복사, 이동생성자가 아닌 완벽전달 생성자를 호출하게 된다. 결과적으로 SpecialPerson 타입을 받는 std::string의 생성자는 없으니 컴파일 에러가 발생한다.

요약하자면, 보편 참조를 받는 함수가 있다면 그 함수에 대한 오버로딩은 피하는 것이 좋다. 그렇다면 보편 참조로 대부분의 인수 타입들을 처리하고, 나머지는 특별한 방법으로 처리하고 싶은 경우에는 어떻게 해야 할까? 이에 대해서는 바로 다음 항목에서 다룬다.

27. 보편참조에 대한 오버로딩 대신 사용할 수 있는 기법들을 알아두라

오버로딩 포기하기
애초에 오버로딩을 포기하고, 보편참조를 인수로 가지는 함수와 같은 역할을 하더라도 함수의 이름을 다르게 짓는 것이 가장 간단한 해결방법이다. 하지만 생성자의 경우에는 이름이 고정되어 있기 때문에 오버로딩이 불가피하다.

const T& 매개변수 사용하기
함수가 보편참조 대신에 const T&를 받도록 하고 오버로딩을 하는 것 또한 방법이 될 수 있다. 다만 이 경우, 함수 내부에서 인수를 수정해야 하는 경우 인수가 오른값이었다고 하더라도 복사 연산이 필요하기 때문에 비효율성이 발생한다(반면 보편참조였더라면 std::forward를 통해 복사 없이 이동시킬 수 있다). 효율성을 조금 타협하더라도 예상치 못한 상황을 피할 수 있게 된다는 데에 의의가 있다.

값 전달(call-by-value) 방식의 매개변수 사용하기
뒤의 항목 41에 나오겠지만, 애초에 복사될 것이 확실한 객체는 값으로 전달하는 것을 고려하는 것이 좋다. 예를 들어 다음 코드를 보자.

class Person {
public:
    explicit Person(std::string n)  // T&& 생성자 대신 사용
    : name(std::move(n)) {} 
    explicit Person(int idx) : name(nameFromIdx(idx)) {}
private:
    std::string name;
}

코드를 위와 같이 작성하는 경우,

꼬리표 배분
꼬리표 배분(tag dispatch)라는 테크닉을 사용하는 방법도 있다. 꼬리표 배분이란 std::remove_reference<T>::type(C++14에서는 std::remove_reference_t<T>)을 이용해 참조 한정사를 모두 떼고 오버로딩 함수에 true, false를 받는 두 번째 매개변수를 만드는 것이다.

앞선 항목 26의 예시를 다시 가져오자. logAndAdd라는 이름에 (1) 보편참조를 인수로 받는 버전과 (2) int형의 index를 인수로 받는 버전을 오버로딩하고싶다고 할 때, 다음과 같이 꼬리표 배분을 적용할 수 있다.

// logAndAddImpl: logAndAdd에서 실제로 작업을 실행하는 부분
template<typename T>
void logAndAddImpl(T&& name, std::false_type){
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type){
    logAndAdd(nameFromIdx(idx));
}

void logAndAdd(T&& name){
    // T에서 참조한정사를 다 뗀게 정수형인지를 판별해 logAndAddImpl에 인자로 넘겨줌
    logAndAddImpl(
        std::forward<T>(name), 
        std::is_integral<typename std::remove_reference_t<T>>()
    );
}

이 때 컴파일 타임 오버로딩 버전들이 선택될 수 있도록 하기 위해서, std::true_typestd::false_type을 이용하였다.

std::enable_if를 이용한 템플릿의 인스턴스화 제한
std::enable_if를 이용하면 템플릿이 특정 조건을 만족하는 타입에 대해서만 인스턴스화되도록 할 수 있다. 코드는 다음과 같다.

class Person {
public:
    template <typename T,
    typename = typename std::enable_if<조건>::type>
    explicit Person(T&& n);
    ...
};

따라서, 완벽전달 생성자에 넘겨진 타입이 Person인 경우에는 템플릿을 비활성화시키면 문제가 해결된다. 우선은 다음과 같이 코드를 작성하면 된다.

class Person {
public:
    template <
        typename T,
        typename = typename std::enable_if<
                    !is_same<Person,
                            typename std::decay<T>::type
                            >::value
                    >::type
    >
    explicit Person(T&& n);

    ...
};

이 때 std::decayconst, volatile 및 참조 한정사를 모두 제거하는 역할을 한다. std::is_same을 이용해 한정사를 모두 제거한 TPerson과 동일한지 비교해주는 것이다. 그런데, 아직은 문제가 있는데 바로 Person을 상속한 클래스가 존재하는 경우이다. 상속을 고려한 최종적인 코드는 다음과 같다.

class Person {
public:
    template <
        typename T,
        typename = typename std::enable_if<
                    !std::is_base_of<Person,
                                    typename std::decay<T>::type
                                    >::value
                    >::type
    >
    explicit Person(T&& n);

    ...
};

is_same 대신에 is_base_of를 사용함으로써 T(에 참조 한정사를 제거한 것)이 Person의 자식 클래스일 가능성 또한 고려해주었다.

28. 참조 축약을 숙지하라

다음과 같이 보편참조를 인수로 받는 함수가 있다고 하자

template<typename T>
void func(T&& param);

param에 왼값이 들어가면 T는 왼값 참조로 추론되지만, 오른값이 전달되면 비참조 타입으로 추론된다. 그런데, C++에서 참조에 대한 참조는 위법이다. paramWidget의 왼값이 들어간다고 가정시 템플릿은

void func(Widget& && param);

으로 인스턴스화되어야 한다. 어떻게 된 일일까? 이는 참조 축약(reference collapsing)이 있기 때문에 가능하다. 참조에 대한 참조는 원래는 위법이지만, 특별한 몇몇 상황에서는 참조에 대한 참조를 산출하는 것이 가능하고, 이 때는 참조 축약의 규칙이 적용된다. 그 규칙은 바로 다음과 같다.

두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이다. 둘 다 오른값 참조이면 결과는 오른값 참조이다.

템플릿 인스턴스화는 참조 축약이 적용되는 "특별한 몇몇 상황" 중 하나이다. 따라서 위의 예시에서 Widget& &&Widget&로 축약되고, 평범한 왼값 참조가 얻어지는 것이다.

std::forward가 작동할 수 있는 것도 바로 이 참조 축약 때문이다. std::forward의 (C++14 버전) 간단한 구현을 보면 다음과 같다.

template<typename T>
T&& forward(remove_reference_t<T>& param){
    return static_cast<T&&>(param);
}

f라는 템플릿 함수가

template<typename T>
void f(T&& fParam){
    ...
    someFunc(std::forward<T>(fParam));
}

와 같은 식으로 std::forward를 사용하고 있다고 하자.

Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }

fParam이 오른값이라면 오른값이 someFunc에 전달된다.

템플릿 인스턴스화 이외에도 참조 축약이 적용되는 문맥은 세 가지가 있다.

1. auto 변수에 대한 타입 추론

Widget w;
auto&& w1 = w;

위 예시에서 Widget& && w1 = w;와 같이 참조 축약의 문맥이 나타난다(항목 02 참고). 따라서 w1Widget& 타입이 된다. 반면,

auto&& w2 = widgetFactory(); // Widget 객체를 반환하는 함수

와 같이 오른값으로 초기화가 일어나는 경우에는 autoWidget으로 추론된다. 따라서 w2의 타입은 Widget&&가 되는 것이다.

2. typedef와 별칭 선언
typedef가 지정 또는 평가되는 도중에 참조에 대한 참조가 발생한다면 참조 축약이 개입하게 된다. 예시를 보자.

template<typename T>
class Widget{
public:
    typedef T&& RvalueRefToT;
    ...
}

위 상황에서, Widget<int&>와 같이 왼값 참조 형식으로 인스턴스화를 한다면, T=int&이므로 RvalueRefToTint& && 타입이 된다. 이 경우 참조 축약이 발생하여 int&로 바뀌게 된다.

3. decltype 사용
마지막으로, decltype이 사용될 때도 참조 축약이 일어난다. 컴파일러는 decltype이 등장하는 형식을 분석하는 도중에 참조가 발생하면 참조 축약이 등장해 이를 제거한다.

29. 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라

이동 의미론은 C++11에서 등장한 가장 중요한 기능이라고 할 수 있다. 그 덕분에 비싼 복사 연산 대신에, (상대적으로) 저렴한 이동 연산을 사용할 수 있기 때문이다. 그러나 이 항목의 조언은 이동 의미론에 대해 너무 환상을 가지지는 말라는 것이다.

따라서 C++11을 이용한 코드를 작성할 때, 이 항목의 제목과 같이 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하고 코드를 작성하는 것이 좋다.

30. 완벽 전달이 실패하는 경우들을 잘 알아두라

(작성중)