참조 캡쳐를 사용하는 경우에 클로저가 지역 변수나 매개변수의 수명보다 오래 지속되면 클로저 안의 참조는 대상을 잃게된다.

 

using FilterContainer = std::vector<std::function<bool(int)>>;

FilterContainer filters;

void addDivisorFilter() {
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    
    auto divisor = computeDivisor(calc1, calc2);
    filters.emplace_back([&](int value) { return value % divisor == 0; });
} // divisor 소멸

지역변수 divisor를 참조하여 클로저를 filters에 추가해주었는데 addDivisorFilter가 반환되면 divisor 역시 소멸하기 때문에 미정의 행동을 유발하게 된다.

 

별도로 저장하지 않고 해당 함수 내에서 즉시 사용하고 클로저가 소멸된다면 참조가 대상을 잃는 일은 발생하지 않는다.

그렇다고 하더라도 다른 사람이 코드가 유용해보여서 복사해서 가져다 쓰는 경우에는 참조를 잃는 일이 발생할 가능성이 다시 생긴다. 명시적인 캡쳐가 아니라 아닌 기본 캡쳐이기 때문에 않기 때문에 divisor의 수명을 한눈에 알아보기 어렵기 때문이다.

 

이를 해결하는 한 가지 방법은 기본 캡쳐모드를 참조(&)가 아닌 값(=)으로 사용하는 것이다.

그런데 또 완벽하지도 않은게 값으로 캡쳐한 대상이 포인터라면 외부에서 삭제할 가능성이 존재하기 때문이다.

 

class Widget {
public:
    void addFilter() const;
    
private:
    int divisor;
};

void Widget::addFilter() const {
    filters.emplace_back([=](int value) { return value % divisor == 0; });
}

우선은 각설하고 다시 본론으로 돌아와서 이번에는 참조가 아닌 값으로 캡쳐한다.

divisor의 값이 클로저 안으로 복사되기 때문에 안전하지 않을까? 싶겠지만 전혀 아니다.

 

캡쳐는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수에만 적용된다. divisor는 멤버 변수이기 때문에 캡쳐가 되지 않는다. 기본 캡쳐이든 명시적 캡쳐이든 전혀 상관이 없다. 오히려 기본 캡쳐를 빼버리면 컴파일 에러가 발생한다.

 

캡쳐도 되지않고 그렇다고 캡쳐를 빼버리면 컴파일 에러가 발생하는데 그 이유는 컴파일러가 클로저 안의 divisor를 this->divisor로 취급하기 때문이다.

분명히 값으로 캡쳐했지만 내부적으로 this포인터로 참조하기 때문에 divisor는 객체의 수명에 의해 제한되어버린다.

 

해결법은 없을까?

 

void Widget::adFilter() const {
    auto divisorCopy = divisor;
    
    filters.emplace_back([divisorCopy](int value){ return value % divisorCopy == 0; });
    // [divisor = divisor](int value){ return value % divisorCopy == 0; } C++14
    // divisor를 클로저에 복사
}

의외로 간단하다. 지역 복사본을 만들어서 그걸 넘겨주면 되는 것이다. C++14는 초기화 캡쳐가 가능하다.

 

기본 값 캡쳐 모드는 클로저 밖에서 일어나는 자료의 변화로부터 격리되어 있을거라는 오해를 부를 수 있는데 전혀 아니다. 전역 또는 이름공간에서 정의된 클래스나 객체, static으로 선언된 객체는 캡쳐 모드와 무관하게 클로저 안에서도 사용할 수 있다.

 

◾ 기본 참조 캡쳐모드는 참조가 대상을 잃을 위험이 있다.

◾ 기본 값 캡쳐모드는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.

완벽 전달은 단순히 객체들을 전달하는 것만이 아니라 왼값 또는 오른값 여부, const, volatile 여부까지도 전달하는 것을 의미한다. 그리고 이를 위해서는 보편 참조 매개변수가 필요하다.

 

template<typename T>
void fwd(T&& param) {
    f(std::forward<T>(param));
}

 

위와 같이 f에 인수를 전달하는 함수 템플릿이 있을 때, 가변인수 템플릿으로 개념을 확장시켜도 논리가 성립된다.

 

template<typename... Ts>
void fwd(Ts&&... params) {
    f(std::forward<Ts>(params)...);
}

이런 형태는 표준 컨테이너의 emplace류 함수들이나 std::make_shared, std::make_unique에서 볼 수 있다.

 

위의 예제처럼 함수 템플릿이 만들어진 상황에서

 

f(/*표현식*/);
fwd(/*표현식*/);

동일한 인수로 두 함수를 호출했을 때, 일어나는 일이 다르다면 완벽 전달은 실패한 것이다.

 

이번 항목에서 완벽 전달이 실패하는 인수의 종류를 알아보도록 하자.

 

 

중괄호 초기치

void f(const std::vector<int>& v);

f({1, 2, 3}); // 암묵적으로 std::vector<int>로 변환
fwd({1, 2, 3}); // 컴파일 에러

f를 직접 호출하는 경우 컴파일러가 호출 지점에서 전달된 인수들의 타입과 f에 선언된 매개변수의 타입을 비교하여 호환성을 파악하고 필요에 따라 적절한 암묵적 변환을 수행하여 호출을 성사시킨다.

 

그런데 전달 함수 템플릿 fwd를 통해 f가 간접적으로 호출된다면 fwd의 호출 지점에서 전달된 인수들과 f에 선언된 매개변수를 직접 비교할 수 없게 된다.

대신 fwd에 전달되는 인수들의 타입을 추론하고 그것들을 f의 매개변수의 타입과 비교한다.

이 때, 다음 두 조건 중 하나라도 만족되면 완벽 전달이 실패한다.

 

◾ fwd이 매개변수들 중 하나 이상에 대해 컴파일러가 타입을 추론하지 못한다. 이 경우 코드는 컴파일되지 못한다.

◾ fwd의 매개변수들 중 하나 이상에 대해 컴파일러가 타입을 잘못 추론한다. 이 경우 코드가 컴파일되지 못하거나 의도와는 다르게 행동한다.

 

이번에 언급된 상황은 auto 지역 변수를 선언하여 std::initializer_list 객체로 만든 뒤 fwd에 전달하면 문제를 우회할 수 있다.

 

 

널 포인터를 뜻하는 0 또는 NULL

0이나 NULL을 널 포인터로 템플릿에 넘겨주려고 하면 포인터 타입이 아니라 정수 타입으로 추론하기 때문에 문제가 생긴다. nullptr을 사용함으로써 간단하게 해결할 수 있다.

 

 

선언만 된 정수 static const 및 constexpr 멤버 변수

일반적으로 정수 static const 멤버 변수와 static constexpr 멤버 변수는 클래스 안에서 정의할 필요 없이 선언만 하면 된다. 컴파일러가 const 전파(constant propagation)를 적용해서 별도의 메모리를 따로 마련하지 않고 상수를 직접 사용하도록 하기 때문이다.

 

class Widget {
public:
    static constexpr std::size_t MinVals = 28; // 선언만 한다
};

MinVals가 적용된 모든 곳에 상수값을 배치하게 된다.

그런데 MinVals의 주소를 참조하려는 코드를 작성하게 되면 MinVals는 메모리에 존재하지 않기 때문에 컴파일은 되지만 정의가 없어서 링크에러가 발생한다.

 

f(Widget::MinVals); // f(28)로 처리된다
fwd(Widget::MinVals); // 링크 에러 발생

이 값으로 f를 직접 호출하는 경우 f(28)로 처리되어 문제가 없지만 전달함수를 거쳐서 호출하려고 하면 링크 에러가 발생한다.

 

포인터와 참조는 본질적으로 같은 것이기 때문에 주소가 없는 MinVals를 전달 함수를 통해 참조로 전달하려고 하면 링크 에러가 발생하는 것이다.

 

근데 무조건 링크 에러가 발생하는 것은 아니고 컴파일러에 따라 다를 수도 있다.

이러한 문제를 확실히 회피하려면 정의를 제공하면 된다.

 

 

오버로딩된 함수 이름과 템플릿 이름

void f(int (*pf)(int)); // void f(int pf(int));

int processVal(int value);
int processVal(int value, int priority);

함수 포인터를 매개변수로 받는 함수와 오버로딩된 함수가 있다고 할 때,

 

f(processVal);

위의 코드는 문제가 없다. 컴파일러가 f의 선언을 보고 일치하는 함수를 골라서 f에 넘겨주게 된다.

 

fwd(processVal);

근데 전달 함수를 통해 참조 전달을 하게되면 문제가 생긴다.

f와 달리 fwd에는 필요한 타입에 대한 정보가 전혀 없기때문에 컴파일러가 어떤 함수를 선택해야 할 지 결정하지 못한다.

processVal 자체에는 타입이 없고 타입이 없으면 타입 추론도 없다. 그리고 타입 추론이 없다는 것이 바로 완벽 전달이 실패하는 또 다른 경우이다.

 

template<typename T>
T workOnVal(T param) { ... }

fwd(workOnVal); // 어떤 인스턴스인지?

함수 템플릿을 전달할때도 workOnVal의 어떤 인스턴스인지 알 수 없기 때문에 동일한 문제가 발생한다.

 

using ProcessFuncType = int(*)(int);
ProcessFuncType processValPtr = processVal;

fwd(processValPtr); // ok
fwd(static_cast<ProcessFuncType>(workOnVal)); // ok

모호함에서 발생하는 문제이기 때문에 오버로딩된 함수나 템플릿 인스턴스를 명시적으로 지정해서 넘겨주면 해결된다.

 

 

비트필드

마지막으로 비트필드가 함수 인수로 쓰일 때이다.

C++의 표준에 비const 참조는 절대로 비트필드에 묶이지 않아야 한다고 명확하게 지정되어있다.

임의의 비트들을 가리키는 포인터를 생성하는 방법이 존재하지 않기 때문이다.

 

다만 비트필드의 완벽 전달을 가능하게 하는 우회방법이 있다.

비트필드를 인수로 받는 함수는 해당 비트필드 값의 복사본을 받게 된다는 점을 알면 쉽게 이해할 수 있다.

값으로 전달하거나 const에 대한 참조로 전달하면 된다. 비트필드를 매개변수에 전달하는 단 두가지의 방법이다.

 

값으로 전달하는 것은 복사본을 받게된다는것이 확실하니 넘어가고 const 참조는 비트필드 자체에 묶이는 것이 아니라 비트필드 값이 복사된 보통 객체에 묶이게 되서 매개변수로 전달할 수 있게 된다.

 

f(h.totalLength); // 값에 의한 전달이 이루어져 복사가 됨

fwd(h.totalLength); // 에러

auto length = static_cast<std::uint16_t>(h.totalLength); // 복사
fwd(length); // 복사본 전달. ok

 

 

결론

대부분의 경우 완벽 전달은 그대로 작동한다. 완벽 전달 여부를 고민해야 하는 경우는 거의 없지만 이번 항목처럼 완벽 전달이 작동하지 않는 경우가 분명히 존재하기 때문에 알아두어야만 문제가 발생시 파악할 수 있다.

 

◾ 완벽 전달은 템플릿 타입 추론이 실패하거나 틀린 타입을 추론했을 때 실패한다.

◾ 인수가 중괄호 초기치이거나 0 또는 NULL로 표현된 널 포인터, 선언만 된 정수 static const 및 constexpr 멤버 변수, 템플릿 및 오버로딩된 함수 이름, 비트필드이면 완벽 전달이 실패한다.

복사보다 이동이 상대적으로 저렴하다는 것은 이제 알고 있다.

이동에 대해 좀 더 근거있는 기대를 가지게 하기 위해 몇 가지 사항을 짚고 갈 필요가 있다.

 

C++11에 들어서며 C++98을 개선하여 이동이 복사보다 연산이 빠른 타입에 대해서는 이동 연산을 제공해주게 되었다.

그런데 사용자의 프로그램이 C++11에 맞춰서 완벽히 개정되지 않았다면 그러한 혜택을 받지 못할 수도 있다.

 

 

이동을 명시적으로 지원하는 타입에서도 의외로 성능상 이점이 크지 않을 수도 있다.

예를 들어 C++11 STL의 모든 컨테이너는 이동을 지원하지만 모든 컨테이너의 이동이 저렴하다고 가정해서는 안된다.

 

std::array를 제외한 다른 컨테이너들은 내용을 힙에 저장하고 그 힙 메모리를 가리키는 포인터만 담고 있기 때문에 컨테이너 전체 내용을 상수 시간으로 이동할 수 있을 뿐만 아니라 원본 컨테이너의 힙 메모리를 가리키는 포인터만 대상 컨테이너로 복사하고 원본 컨테이너의 포인터를 널로 설정하기만 하면 컨테이너 내용 전체가 옮겨진것과 같은 결과가 된다.

 

그에 비해 std::array는 내장 배열에 STL 인터페이스를 씌운 것에 불과하기 때문에 내용이 std::array 객체 자체에 직접 저장된다. std::array에 저장된 타입의 이동 연산이 복사 연산보다 빠르다면 std::array를 복사하는 것보다 빠른것은 사실이지만 std::array는 이동이나 복사나 둘다 계산 복잡도가 선형이다. 포인터를 복사하는 것만큼 저렴하지는 않은 것이다.

 

 

string은 상수 시간 이동과 선형 시간 복사를 제공한다. 당연히 상수 시간에 이루어지는 이동이 항상 빠를 것 같지만 아주 작은 문자열의 경우 작은 문자열 최적화(SSO)를 사용하는 경우가 많기 때문에 '항상' 성립하지는 않는다.

 

 

항목 14에서 다뤘듯이 이동 연산이 예외를 던지지 않는다는 것이 확실한 경우에만 복사 연산을 이동 연산으로 대체한다. 이동 연산이 noexcept로 선언되어있지 않으면 컴파일러는 계속 복사 연산을 호출할 수 있다.

 

C++11의 이동 의미론이 도움되지 않는 시나리오

◾ 이동 연산이 없다 : 복사 요청으로 대체된다.

◾ 이동이 더 빠르지 않다 : 복사 연산보다 빠르지 않다.

◾ 이동을 사용할 수 없다 : 이동 연산이 noexcept로 선언되어 있지 않다.

◾ 원본 객체가 왼값이다 : 아주 드문 경우(항목 25)이지만 오른값만 이동 연산의 원본이 될 수 있는 경우도 있다.

 

 

핵심은 항목의 이름처럼 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라는 것이다.

템플릿을 작성할 때에는 코드에 쓰이는 모든 타입을 알 수 없기 때문에 대체적으로 그런 가정이 사실이다.

그런 경우에는 이동 의미론이 존재하기 전처럼 객체의 복사를 보수적으로 다루어야 한다.

 

◾ 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않을 것이라고 가정하라.

◾ 타입들과 이동 의미론 지원 여부를 미리 알 수 있는 경우에는 그런 가정을 둘 필요가 없다.

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

템플릿 매개변수 T에는 param으로 전달된 인수가 왼값이었는지 오른값이었는지에 대한 정보가 부호화되어 저장되어있다.

부호화 매커니즘은 간단하다. 왼값 인수가 전달되면 T는 왼값 참조로 추론되고 오른값 인수가 전달되면 T는 비참조 타입으로 추론된다.

 

Widget widgetFactory(); // 오른값을 돌려주는 함수
Widget w;

func(w); // 왼값으로 호출. Widget&으로 추론
func(widgetFactory()); // 오른값으로 호출. Widget으로 추론

 

int x;
auto& & rx = x; // 참조에 대한 참조는 선언할 수 없다

C++에서 참조에 대한 참조는 위법이다.

 

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

func(w);

그런데 이런 경우를 한번 보자. 보편 참조를 받는 함수 템플릿에 왼값을 넘겨주면 아마 아래와 같은 형태일 것이다.

 

void func(Widget& && param);

// void func(Widget& param);
// 실제 만들어지는 함수 형태

참조에 대한 참조는 위법이라고 했으나, 실제로는 참조 축약에 의해 특정 문맥에서는 컴파일러가 참조에 대한 참조를 산출하는 것을 허용한다. 템플릿 인스턴스화는 그런 문맥 중 하나이다.

참조는 왼값과 오른값이 존재하므로 총 4가지이며 아래 규칙에 따라 하나의 참조로 축약된다.

 

"만일 두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이다. 그렇지 않으면 결과는 오른값 참조이다."

 

위와 같은 참조 축약 덕분에 std::forward가 작동할 수 있다.

 

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

T&&는 보편 참조이고 전달된 인수가 오른값인 경우 둘 다 오른값이기 때문에 이 경우에만 std::forward가 fParam을 오른값으로 캐스팅하는 것이다.

 

template<typename T> // C++11
T&& forward(typename remove_reference<T>::type& param) {
    return static_cast<T&&>(param);
}

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

몇 가지 인터페이스가 생략되었지만 forward의 구현은 대략적으로 이렇게 되어있다.

참조 축약은 반환 타입과 캐스팅에도 적용되며 참조 축약 규칙에 의해 인수의 타입이 왼값인 경우 항상 왼값을 반환하게 된다.

 

참조 축약이 일어나는 문맥은 네가지이고 첫번째 문맥은 가장 흔한 경우이며 이미 다룬 템플릿 인스턴스화이다.

두번째 문맥은 auto 변수에 대한 타입 추론이다. 템플릿 매개변수와 auto의 타입 추론은 본질적으로 같기 때문에 첫번째 경우와 동일하게 적용된다.

 

◾ 타입 추론에서 왼값과 오른값이 구분된다

◾ 참조 축약이 적용된다

 

보편 참조는 새로운 종류의 참조가 아니라 위의 두 가지 조건이 만족되는 문맥에서는 오른쪽 참조가 되는 것이다.

 

세번째 문맥은 typedef와 using의 지정 및 사용이다.

typedef가 지정 또는 평가되는 도중에 참조에 대한 참조가 발생하면 참조 축약이 끼어들어서 참조에 대한 참조를 제거한다.

 

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

Widget<int&> w; // int& && RvalueRefToT;

참조에 대한 참조가 발생하여 참조 축약이 끼어들고 왼값 참조가 된다.

이런 경우는 오히려 이름때문에 혼란을 줄 수 있게된다.

 

마지막 문맥은 decltype 사용이다.

컴파일러가 decltype에 관여하는 타입을 분석하는 도중에 참조에 대한 참조가 발생하면 참조 축약이 끼어든다.

 

◾ 참조 축약은 템플릿 인스턴스화, auto 타입 추론, typedef와 using의 지정 및 사용, decltype의 지정 및 사용이라는 네 가지 문맥에서 일어난다.

◾ 컴파일러가 참조 축약 문맥에서 참조에 대한 참조를 만들어 내면, 그 결과는 하나의 참조가 된다. 원래의 두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이고, 그렇지 않으면 오른값 참조이다.

◾ 타입 추론이 왼값과 오른값을 구분하는 문맥과 참조 축약이 일어나는 문맥에서 보편 참조는 오른값 참조이다.

오버로딩을 포기한다

오버로딩 대신 함수에 각자 다른 이름을 붙인다. 다만 생성자에는 통하지 않는다.

 

 

const T& 매개변수를 사용한다

const한정자를 붙이면 보편 참조를 지원하지 않게 되지만 효율성을 조금 포기하더라도 예상치 못한 문제를 피한다는 점에서는 고려할 만하다.

 

 

값 전달 방식의 매개변수를 사용한다

class Person {
public:
    explicit Person(std::string n) : name(std::move(n)) {}
    explicit Person(int idx) : name(nameFromIdx(idx)) {}
    ...
private:
    std::string name;
};

복사될 것이 확실한 객체는 참조 전달 매개변수 대신 값 전달 매개변수를 사용한다.

구체적인 작동 방식과 효율성에 대한 논의는 추후 항목 41에서 다룬다.

 

 

꼬리표 배분을 사용한다

위에서 언급한 const 왼값 참조 전달이나 값 전달은 완벽 전달을 지원하지 않는다.

만약 보편 참조를 사용하려는 이유가 완벽 전달이라면 보편 참조 말고는 답이 없다.

 

보편 참조와 오버로딩을 둘 다 사용하되 보편 참조에 대한 오버로딩을 피하기 위해서는 SFINAE 기법과 동일한 꼬리표 배분을 사용하면 된다. 쉽게 이야기 하자면 오버로딩 대신 해당 구현부를 다른 함수들로 위임 시켜서 호출하는 것이다. 다른 함수를 호출하는 만큼 호출 깊이가 하나 증가하게 된다.

 

template<typename T>
void logAndAdd(T&& name) {
    logAndAddImpl(std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()
    );
}

매개변수와 매개변수의 타입이 정수인지를 묻는 인수를 같이 전달한다.

 

template<typename T>
void logAndAddImpl(T&& name, std::false_type) { // 정수타입이 아닐 때
    ...
    names.emplace(std::forward<T>(name));
}

std::string nameFromIdx(int idx);

void logAndAddImpl(int idx, std::true_type) { // 정수타입일 때
    logAndAdd(nameFromIdx(idx));
}

전달된 인수의 타입이 정수타입이 아니면 첫 번째 함수가 호출될 것이고 정수타입이면 두 번째 함수가 호출될 것이다.

 

그런데 오버로딩 해소 과정은 컴파일 타임에 이루어져야 하지만 true, false는 런타임 값이다. 즉, true, false에 해당하는 어떤 타입이 컴파일 타임에 존재해야 하는 것이고 이를 표준 라이브러리가 std::true_type, std::false_type으로 제공해준다.

해당 타입 매개변수들은 런타임에 전혀 쓰이지 않기 때문에 이름조차 붙이지 않는다.

 

위와 같은 개념(SFINAE)은 템플릿 메타 프로그래밍의 표준 설계 요소이다.

 

 

보편 참조를 받는 템플릿을 제한한다

꼬리표 배분의 필수 요소는 인터페이스 역할을 하는 단일(오버로딩 되지 않은) 함수이다.

꼬리표 배분을 통해 웬만한 문제는 해결되었을 거라 생각되지만 완벽 전달 생성자에 관련된 문제점은 아직 해결하지 못했다.

 

만약 생성자를 하나만 작성하고 그 안에서 꼬리표 배분을 사용하려고 했을 때, 컴파일러에 의해 자동으로 작성된 다른 생성자들에 의해 생성자가 오버로딩 되어 꼬리표 배분이 적용되지 않을 가능성이 존재한다.

 

전 항목에서 언급했듯이 파생 클래스의 복사, 이동 생성자의 멤버 초기화 리스트가 기반 클래스의 생성자를 호출하게 되면 각각 복사, 이동 생성자가 호출되기를 기대할 것이다. 하지만 기반 클래스에 완벽 전달 생성자가 정의되어 있는 경우라면 둘 다 완벽 전달 생성자가 호출된다.

 

꼬리표 배분 설계가 적합하지 않은 경우에는 std::enable_if를 사용하여 템플릿을 제어할 수 있다.

기본적으로 모든 템플릿은 활성화된 상태이지만 std::enable_if를 사용하는 템플릿은 오직 해당 std::enable_if가 만족될 때에만 활성화된다.

 

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

필요할 때만 완벽 전달 생성자가 호출되게 하려면 Person이 아닌 타입의 인수가 전달된 경우에만 Person의 완벽 전달 생성자가 활성화 되도록 해야한다. 이럴 때 유용한 type_traits으로 두 타입이 같은지를 판단하는 std::is_same이 있다.

이 부분을 위 코드의 조건에 넣으면 될 것이다.

 

<!std::is_same<Person, T>::value>

 

Person p("Nancy");
auto cloneOfP(p); // 왼값 초기화

보편 참조 생성자의 타입 T는 Person&으로 추론되고 Person과 Person&는 같은 타입이 아니라고 판단한다.

 

 

!std::is_same<Person, Person&>::value // 둘의 타입이 달라서 false가 되고 not 연산자에 의해 true가 된다

Person의 템플릿 생성자가 T가 Person이 아닐 때에만 활성화 되어야 하는 조건을 좀 더 구체적으로 생각해보면 다음 두가지 사항을 무시해야 한다는 점을 알 수 있다.

 

◾ 참조 여부. 보편 참조 생성자의 활성화 여부를 판단할 때, Person, Person&, Person&&은 모두 같은 타입으로 간주되어야 한다.

◾ const성과 volatile성. 마찬가지로 const Person, volatile Person, const volatile Person 모두 Person과 같은 타입으로 간주되어야 한다.

 

이게 성립되려면 모든 참조 한정사와 const, volatile 한정사를 제거하는 수단이 필요한데 이 역시 type_traits의 std::decay가 제공해준다.

 

std::decay<T>::type은 T에서 모든 참조와 모든 cv한정사를 제거한 타입을 알려준다.

결론적으로 생성자의 활성화를 제어하는 조건은 다음과 같다.

 

!std::is_same<Person, typename std::decay<T>::type>::value

즉, Person이 모든 참조와 cv한정사를 제거한 T와 같지 않아야 한다.

 

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

위의 내용을 정리해서 작성한 코드이다. 보기만 해도 어지러움을 느낀다.

그런데 아직도 모든 문제가 해결되지 않았다.

파생 클래스의 복사, 이동 생성자에서 기반 클래스의 생성자를 호출 시 전달된 인수는 당연히 기반 클래스와 파생 클래스의 타입이 다르기 때문에 템플릿이 활성화 되어 여전히 기반 클래스의 완벽 전달 생성자가 호출된다.

 

파생 클래스에서는 복사 생성자와 이동 생성자를 구현할 때 통상적인 규칙을 적용하여 구현한 것이기 때문에 기반 클래스에서 문제를 해결해주어야 한다. 고쳐야 할 부분은 보편 참조 생성자가 활성화되는 조건이다.

물론 그 조건은 type_traits가 std::is_base_of라는 것을 또 제공해준다. 이제는 놀랍지도 않다.

 

std::is_base_of<T1, T2>는 T2가 T1의 파생 클래스이면 참이다.

위의 is_same을 is_base_of로 바꿔주기만 하면 대장정이 거의 끝난다. 여기까지 했는데도 끝이 아니라 거의 끝이다.

 

class Person { // C++11
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);
    ...
};

class Person { // C++14
public:
    template<
        typename T,
        typename = std::enable_if_t<
                       !std::is_base_of<Person, std::decay_t<T>>::value
                   >
    >
    explicit Person(T&& n);
    ...
};

C++14 이상이라면 std::enable_if와 std::decay의 별칭을 이용해 뒤에 ::type을 없앰으로써 조금 더 간결하게 작성할 수 있다. 이미 이런 형태 자체가 간결한지는 잘 모르겠지만 말이다.

 

맨 처음 제기되었던 문제로 되돌아가서 정수 타입 인수와 비정수 타입 인수를 구분하는 방법까지 적용시켜야 정말 완성된다.

 

class Person { // C++14
public:
    template<
        typename T,
        typename = std::enable_if_t<
                    !std::is_base_of<Person, std::decay_t<T>>::value
                    &&
                    !std::is_integral<std::remove_reference_t<T>>::value
                   >
    >
    explicit Person(T&& n) : name(std::forward<T>(n)) {}
    explicit Person(int idx) : name(nameFromIdx(idx)) {} // 정수 인수를 위한 생성자
    ... // 기타 복사, 이동 연산자들
private:
    std::string name;
};

드디어 생성자 오버로딩을 사용하면서 완벽 전달까지 사용되는 최대 효율의 코드가 완성되었다.

보편 참조에 대한 오버로딩을 금지하는 대신 둘의 조합을 제어하므로 오버로딩을 피할 수 없는 상황에서도 사용할 수 있다.

 

 

절충점들

최초 세가지 기법들인 오버로딩 포기, const T& 전달, 값 전달은 호출되는 함수들의 각 매개변수에 대해 타입을 지정한다.

나머지 두 기법들인 꼬리표 배분과 템플릿 활성화 제한은 완벽 전달을 사용하므로 매개변수들의 타입을 지정하지 않는다.

 

완벽 전달은 임시 객체를 생성하는 비효율성이 없기 때문에 더 효율적이지만 완벽 전달이 불가능한 인수들이 존재한다. 이 내용은 항목 30에서 다루도록 하고 또 하나의 단점으로 유효하지 않은 인수들을 전달했을 때, 출력되는 에러 메시지가 난해하다는 점이다.

 

보편 참조가 전달되는 횟수가 많을수록 에러 메시지가 더 장황해진다. 그래서 보편 참조를 성능이 최우선적인 인터페이스에만 사용하는 것만으로 충분하다는 의견들도 있다.

 

◾ 보편 참조와 오버로딩의 조합에 대한 대안으로는 구별되는 함수 이름 사용, 매개변수를 const에 대한 왼값 참조로 전달, 매개변수를 값으로 전달, 꼬리표 배분 사용 등이 있다.

◾ std::enable_if를 이용해서 템플릿의 인스턴스화를 제한함으로써 보편 참조와 오버로딩을 함께 사용할 수 있다. std::enable_if는 컴파일러가 보편 참조 오버로딩을 사용하는 조건을 프로그래머가 직접 제어하는 용도로 쓰인다.

◾ 보편 참조 매개변수는 효율성 면에서 장점인 경우가 많지만, 대체로 사용성 면에서는 단점이 많다.

+ Recent posts