-
[Effective Modern C++] Universal reference와 Overloading (2)C++/Effective Modern C++ 2022. 8. 1. 14:29
이전 글 (Universal reference와 Overloading)에서 보편참조 함수를 오버로딩 할 경우 발생하는 문제를 살펴보았습니다.
함수를 호출할 때, 의도하지 않게 보편참조 매개변수를 받는 함수를 호출하게 되는 상황에서 발생하는 문제였습니다.
하지만, 글에서 살펴본 예제들의 경우 개발자가 의도한 대로 작동했을 경우 긍정적으로 사용되었을 수 있습니다.
이번 글에서는 그러한 긍정적인 결과를 달성하기 위해, 보편참조 함수에 대한 오버로딩 대신 사용할 수 있는 기법들을 살펴보도록 하겠습니다.
1. 함수의 이름을 나누기
더보기오버로딩을 하지 않고, 매개변수에 따라 함수의 이름을 나누는 방법입니다.
예제를 살펴보도록 하겠습니다.
// Before template<typename T> void logAndAdd(T&&); void logAndAdd(int); // After template<typename T> void logAndAddName(T&&); void logAndAddIdx(int);
호출하는 함수의 이름을 나누면 의도치 않은 함수가 호출되는 상황이 발생하지 않습니다.
하지만 오버로딩은 함수 뿐만 아니라 생성자에도 사용될 수 있고, 생성자에는 위와 같은 방법을 사용하지 못합니다.
또한 오버로딩이 가지는 장점들도 이용할 수 없게 됩니다
2. R-value 참조 사용하기
더보기이전 글은 보편참조를 이용해서 예시로 소개한 함수를 최적화 하는 것으로 시작되었습니다.
보편참조를 이용한 것이 최적화 라는 것에 주목해서, 최적화를 포기하는 방법을 선택할 수 있습니다.
// Before template<typename T> void logAndAdd(T&&); void logAndAdd(int); // After void logAndAdd(const std::string&); void logAndAdd(int);
이 경우 함수가 잘못 호출되는 상황을 막을 수는 있으나, 그만큼 효율적이지 못하다는 단점이 있습니다.
3. Pass by value 사용하기
더보기Pass by reference(참조 전달)대신, Pass by value(값 전달)를 사용하는 방법이 있습니다.
예제를 살펴보도록 하겠습니다.
// Before template<typename T> void logAndAdd(T&&); void logAndAdd(int); // After void logAndAdd(std::string); void logAndAdd(int);
이 경우 정수 형식(int, long, short, std::size_t... etc)는 int를 매개변수로 받는 logAndAdd가 선택되고, 문자열(리터럴 포함)은 std::string을 매개변수로 받는 logAndAdd가 선택됩니다.
이전처럼 잘못 된 함수가 선택되는 일은 발생하지 않습니다.
Pass by value는 값이 전달될 때 복사 생성자가 호출되어, 비효율적이라는 생각이 들 수 있습니다.
하지만 그렇게까지 비효율적이지 않습니다.
C++11에서부터, 위와 같은 상황 (Pass by value)에서 L-value는 복사 생성되지만, R-value는 이동 생성됩니다.
이에 관해서는 차후 다른 글에서 다룰 수 있도록 하겠습니다.
4. Tag dispatching 사용하기
더보기이번 글에서 다루는 주제는 보편참조 매개변수를 사용하는 함수에서 잘못된 호출을 해결하는 방법 입니다.
이 잘못된 호출은 대부분 컴파일러의 판단에 의해서 일어납니다.
그런데, 오버로딩을 하면서 호출을 개발자가 바꾸는 방법이 존재합니다.
template<typename T> void logAndAdd(T&& name) { logAndAddImpl(std::forward<T>(name), std::is_integral<T>()); }
이 방법에서는 함수가 두 가지로 나뉘어있습니다.
- logAndAdd는 보편참조를 받는 기존의 함수입니다. 이 함수는 logAndAddImpl을 호출합니다.
- logAndAddImpl은 실제로 작업을 수행하는 함수입니다. 이 함수가 오버로딩됩니다.
logAndAddImpl의 두 번째 인수는 주어진 T가 정수인지 여부를 판단합니다.
그런데 위와 같은 구현의 경우, L-value형식의 int가 전달될 경우 T가 int&로 연역되고, 이것은 정수가 아니라고 판단됩니다.
따라서 정확한 구현은 아래와 같습니다.
// C++ 11 template<typename T> void logAndAdd(T&& name) { logAndAddImpl(std::forward<T>(name), std::is_integral<typename std::remove_reference<T>::type>()); } // C++ 14 template<typename T> void logAndAdd(T&& name) { logAndAddImpl(std::forward<T>(name), std::is_integral<std::remove_reference_t<T>>()); }
이렇게 logAndAddImpl을 호출하는 logAndAdd가 완성되었습니다.
이제 logAndAddImpl을 살펴보도록 하겠습니다.
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)); }
자세히 살펴보도록 하겠습니다.
- 개념적으로 logAndAddImpl의 두 번째 값은 주어진 인수의 정수 여부에 대한 부울 값 입니다.
- 그런데 부울 값은 실행 시점 값이기 때문에, 컴파일 시점으로 이를 옮겨올 방법이 필요합니다.
- std::true_type, std::false_type이 바로 그것으로 std::is_integral<T>의 결과가 저 두 객체를 상속하는 어떤 형식의 객체입니다.
- 또한, 두 번째 인수인 부울 값에는 이름이 없습니다. 이는 실행 시점에서 쓰이지 않기 때문에 최적화 한 결과입니다.
결과적으로, 두 번째 인수를 이용해서 호출되는 함수를 제어할 수 있게 되었습니다.
이와 같이 꼬리표(Tag)를 이용해서 함수 호출을 분배(Dispatching)하는 기법을 꼬리표 배분(Tag dispatching)이라고 합니다.
5. 템플릿 제한하기
더보기이전 글의 예제에서 살펴본 클래스의 경우, 컴파일러가 이동 및 복사 생성자를 자동으로 생성합니다.
이 때 일부 생성자의 호출에 대하여 컴파일러가 생성한 함수를 호출하게 될 수 있습니다.
이 경우 위 문단의 꼬리표 배분으로는 해결되지 않을 수 있습니다.
이러한 경우는 템플릿의 생성을 제한하는 방법으로 해결할 수 있습니다.
이 제한은 std::enable_if를 통해 이루어집니다.
예제를 살펴보도록 하겠습니다.
class Name { public: template<typename T, typename = typename std::enable_if<[condition]>::type> explicit Name(T&& n); ... };
템플릿 매개변수 부분인 [condition]에 조건을 지정할 수 있습니다.
이 예제에서 지정해야 할 조건은 두 가지 입니다.
- T가 Name이 아닐 경우 (Name에 대하여는 복사 및 이동 생성자를 호출해야 합니다.)
- T가 정수가 아닐 경우 (정수에 대하여는 다른 오버로딩 생성자를 호출해야 합니다.)
위의 두 가지 조건은 아래의 템플릿들을 통해 정의할 수 있습니다.
- std::is_same<T1, T2>는 두 형식이 같은지 판별하는 형식 특질입니다.
- std::is_integral<T>는 주어진 형식이 정수 형식인지 판별하는 형식 특질입니다.
- 두 경우 모두, 위 문단에서 발생한 L-value 참조에 대한 예외를 방지하기 위해, 참조성을 제거해야 합니다.
- 추가적으로 is_same의 경우는 const 및 volatile과 같은 한정사 또한 제거해야 합니다.
- 이 때 필요한 형식 특질은 std::decay<T>입니다. 이 형식 특질은 참조 및 cv한정사를 모두 제거합니다.
위 사항을 준수하여 Name의 생성자를 정의하면 아래와 같습니다.
class Name { public: template<typename T, typename = typename std::enable_if< !std::is_same<Name, typename std::decay<T>::type>::value && !std::is_integral<typename std::remove_reference<T>::type>::value >::type > explicit Name(T&& n); ... };
위와 같이 구성된 Name생성자는 의도한 대로 작동합니다.
하지만, 상속이 섞여있다면 복사, 이동 생성자가 아닌 보편참조 생성자가 호출되는 일이 남아있습니다.
Name클래스를 상속한 SpecialName이라는 클래스가 있다고 가정했을 때, Name과 SpecialName은 서로 다른 클래스이기 때문입니다.(std::is_same<>에서 같다고 판별되지 않습니다.)
이 경우, 파생 여부를 포함한 다른 형식 특질을 사용해야 합니다.
std::is_base_of<T1, T2>가 그것으로, T2가 T1에서 파생된 형식일 경우 참입니다. 만약 사용자 정의 형식일 때, T1과 T2가 같은 클래스라면 이 또한 참입니다.
추가적으로, C++14는 별칭 템플릿(참조)을 이용하여 코드의 길이를 조금 줄일 수 있습니다.
위 내용을 적용해서 코드를 수정할 경우 아래와 같이 됩니다.
class Name { public: template<typename T, typename = std::enable_if_t< !std::is_base_of<Name, std::decay_t<T>>::value && !std::is_integral<std::remove_reference_t<T>>::value > > explicit Name(T&& n); ... };
6. 완벽 전달의 단점
더보기위에서 살펴본 방법 중, 꼬리표 배분과 템플릿 제한은 완벽 전달을 사용합니다.
이는 전달을 위한 임시 객체를 생성할 필요가 없다는 점에서 더 효율적입니다.
하지만, 완벽 전달이 실패하는 경우가 있고, 오류 메시지가 난해하다는 단점을 가지고 있습니다.
이것을 근본적으로 해결하는 것은 어렵지만, 전달되는 매개변수가 유효한지 점검하는 방법은 존재합니다.
예제를 살펴보도록 하겠습니다.
static_assert( std::is_constructible<std::string, T>::value, "Error message" );
위와 같이 static_assert와 std::is_constructible<T1, T2>형식 특질을 사용하면 됩니다.
std::constructible<T1, T2>는 T2로 T1을 생성할 수 있는지를 컴파일 시점에서 판정합니다.
이 방법은 오류를 근본적으로 해결할 수는 없지만, 난해한 오류 메시지를 조금 더 읽기 수월하게 만들어줍니다.
보편참조와 오버로딩은 둘 다 매우 유용한 기능입니다.
하지만 이전 글과 이번 글을 통해, 그것을 함께 사용할 때는 고려해야 할 점이 많다는 것을 살펴보았습니다.
이번 글의 후미에 작성된 내용들 (꼬리표 분배 등)은 이번 글에서는 단편적인 소개만 해 드렸습니다.
차후 더 자세히 다룰 기회를 만들어 보도록 하겠습니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 이동 시맨틱의 맹점 (0) 2022.08.18 [Effective Modern C++] 참조 축약 (Reference collapsing) (0) 2022.08.10 [Effective Modern C++] Universal reference와 Overloading (0) 2022.07.28 [Effective Modern C++] std::move와 std::forward (2) (0) 2022.07.25 [Effective Modern C++] R-value reference와 Universal reference (0) 2022.07.13