오버로딩을 포기한다
오버로딩 대신 함수에 각자 다른 이름을 붙인다. 다만 생성자에는 통하지 않는다.
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는 컴파일러가 보편 참조 오버로딩을 사용하는 조건을 프로그래머가 직접 제어하는 용도로 쓰인다.
◾ 보편 참조 매개변수는 효율성 면에서 장점인 경우가 많지만, 대체로 사용성 면에서는 단점이 많다.
'도서 > Effective Modern C++' 카테고리의 다른 글
[5장] 항목 29 : 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라 (0) | 2023.01.25 |
---|---|
[5장] 항목 28 : 참조 축약을 숙지하라 (0) | 2023.01.25 |
[5장] 항목 26 : 보편 참조에 대한 오버로딩을 피하라 (0) | 2023.01.25 |
[5장] 항목 25 : 오른값 참조에는 std::move를, 보편 참조에는 std::forward를 사용하라 (0) | 2023.01.25 |
[5장] 항목 24 : 보편 참조와 오른값 참조를 구별하라 (0) | 2023.01.24 |