dev/C++

Effective Modern C++ #27 보편참조 에 대한 중복적재(overload) 대신 사용할 수 있는 기법들을 알아두라

dev_dev 2025. 6. 25. 08:18

27. 보편참조에 대한 중복적재(overload) 대신 사용할 수 있는 기법들을 알아두라

동일한 이름의 보편 참조 함수와 overload 함수를 같이 사용했을 때, 문제가 될 수 있다는 점을 이전 시간에 배웠다.

예상보다 더 많은 호출자가 보편 참조 함수 로직을 타게 된다. (더 부합하기 때문)

오버로드 포기

간단하게 overload 를 포기하고 함수 이름을 나누면 된다. (ex. logAndAdd -> logAndAddName, logAndAddNameIdx)

유지보수에 복잡성이 커지는 단점이 있다.

또한 자동으로 작성되는 클래스 생성자로 인한 overload 는 대처가 불가능하다.

생성자 이름은 클래스 이름으로 고정되기 때문이다.

 

const T& 매개변수 사용

보편 참조 대신 l-value 에 대한 참조 매개변수를 사용하는 것이다.

이는 보편 참조를 포기하는 것이므로 l-value, r-value 인자 모두에 대응이 되지않아 효율적이지 않다.

 

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

참조 매개변수 대신 값 매개변수를 사용한다.

각 매개변수 형태에 맞춰서 실제로 전달되는 인자는 유사한 형식으로 묶인다.

ex) int 는 short, long 과 매칭, std::string 은 “string” 매칭 등

 

Tag Dispatch(꼬리표 배분) 사용

보편 참조 함수와 동일한 이름의 오버로드 함수를 사용하면서, 보편 참조에 대부분 호출의 제어권을 빼앗기는 문제를 해소하는 방법이다.

해소하는 비결은 보편 참조 용도가 아닌 매개변수를 사용하는 것이다.

오버로드 함수가 보편 참조 함수보다 부합하지 않더라도, 다른 매개변수를 기반으로 제어 가능하다.

 

예시)

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

먼저 보편 참조 형태로 공통적 함수를 작성한다.

이 안에서 각각의 오버로드 함수를 작성한다.

 

둘째 매개변수를 통해, 전달된 인자가 정수인지 아닌지 확인하고 이를 기반으로 어떤 오버로드를 호출할지 제어한다.

위 코드는 r-value 정수에는 문제가 없으나, l-value 정수에 대해서는 제대로 동작하지 않는다.

l-value 정수는 int& 로 연역되므로 std::is_integral<T> 에서 항상 거짓이 되기 때문이다.

따라서 std::remove_reference 형식 특질( type trait )도 사용해야한다.

 

예시)

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

이제 overload 함수를 각각 작성하자.

 

예시)

 

template<typename T>
void logAndAddImpl(T&& name, std::false_type) { ... }

void logAndAddImpl(int idx, std::true_type) { ... }

특이하게도 매개변수에 변수가 아니라, 특정 형식이 사용된다.

std::false_type, std::true_type 형식은 일종의 tag(꼬리표) 이다.

 

이를 사용하는 이유는 호출자가 어떤 overload 함수를 선택할지는 컴파일 시간에 결정되기 때문이다.

변수 (true, false) 는 실행 시점의 값이므로 컴파일 시점의 형식인 std::false_type, std::true_type 등 사용한다.

 

이 형식 인자를 기반으로, 컴파일 시점 오버로드 함수를 적절하게 매칭해야한다.

실행시점에 std::false_type 등 해당 매개변수는 사용되지 않으므로, 컴파일러에 따라 tag 매개변수를 삭제하는 케이스도 존재한다.

logAndAdd 안에서 오버로드한 구현함수를 적절히 호출하는 것은 작업을 tag에 따라 배분하는 Tag Dispatch 라고 불린다.

 

이러한 Tag dispatch의 의의는, 앞서 배운 보편참조-오버로드 동시 사용 시의 문제를 겪지 않는다는 것이다.

이제 어떤 오버로드 함수를 호출할지는 tag 매개변수에 의존한다.

 

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

Tag dispatch는 overload 가 아닌 단일 함수 내에서 overload 구현 함수를 적절히 호출해서 구현 가능했다.

하지만 생성자가 자동 작성되는 클래스의 경우, Tag dispatch가 의도대로 적용되지 않을 수 있다.

진짜 문제는 컴파일러 생성 함수는 Tag dispatch 설계와 독립적으로 동작한다는 것이다.

 

  • 보편 참조 함수에 많은 인자가 대응하여 Tag Dispatch 필요
  • Tag Dispatch 용도 (단일한 배분 함수) 외에 다른 overload 존재 (자동 작성 생성자 등)

위 두가지 케이스에서 overload 후보를 적절히 제한할 수 있는 std::enable_if 를 사용한다.

std::enable_if 는 컴파일러가 특정 템플릿이 존재하지 않는 것처럼 행동하게 만들 수 있다.

기본적으로 모든 템플릿은 활성화 상태이나, std::enable_if 사용하는 템플릿은 그 조건에서만 활성화된다.

 

예시)

template<typename T, typename = typename std::enable_if<조건>::type>

Person 이라는 클래스에서 복사, 이동 생성자가 자동 작성되는 상황을 가정하자.

여기서 T 가 Person 이 아닐 때 템플릿을 enable 하게 코드를 구성해야한다.

이 때 유용한 형식 특질인 std::is_same 이 있다.

template<typename T, typename = typename std::enable_if<!std::is_same<Person, T>::value>::type>

 

하지만 보편 참조에 l-value 인자가 전달되면, T는 Person& 으로 연역된다.

따라서 is_same 은 늘 false 가 되므로, 형식 T 의 아래 두가지 사항을 무시해야한다.

  • 참조
  • const, volatile

이 때 사용할 수 있는 적절한 형식 특질인 std::decay 가 있다.

std::decay<T>::type 은 T에서 참조와 cv-qualifier (const, volatile) 를 제거한다.

예시)

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

 

문법이 복잡하기 때문에, 해당 방법 이외의 방법으로 보편 참조-오버로드 문제를 개선할 수 있다면 해당 방법 쓰지 않는 것이 좋다.

하지만 해당 설계는 개발자가 의도한 행동을 제공한다.

  1. Person 복사 연산 진행
  2. T 에 Person 연관된 인자 전달됨
  3. std::decay로 인자의 특징과 상관없이 해당 템플릿 disable 됨
    • *인자의 특징 : l-value/r-value, const/non-const, volatile/non-volatile 여부

거의 완벽하지만, 한 개의 문제가 해결되지 않았다.

바로 derived class에서 호출한 base class 의 생성자 호출이다.

예시)

class SpecialPerson: public Person {
    public:
        SpecialPerson(const SpecialPerson& rhs):Person(rhs) {...} // 기반 클래스 완벽 전달 생성자 호출
        SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)) {...} // 기반 클래스 완벽 전달 생성자 호출
        ...
}

복사 연산으로 예시를 확인해보자.

  1. SpecialPerson 복사 연산 진행
  2. Person 복사 연산 진행되나, T 에 SpecialPerson 연관된 인자 전달됨
  3. std::is_same 으로 비교하는 것은 Person 이나 T는 SpecialPerson 이므로 해당 템플릿 enable

방법은 base class 의 보편 참조 생성자가 활성화되는 조건을 추가로 수정하는 것이다.

std::is_same 으로 Person 외에 Person 에서 파생된 형식도 체크해야한다.

std::is_base_of 는 주어진 형식이 다른 형식에서 파생된 것인지 확인 가능한 형식 특질이다.

  • std::is_base_of<T1, T2>::value : T2 가 T1 에서 파생된 것이면 true
  • std::is_base_of<T, T>::value : T 가 사용자 정의 형식 (ex. Person) 이면 true, 내장 형식이면 false

이전 보편 참조 생성자 코드에서 std::is_same 를 std::is_base_of 로 수정하면 된다.

C++11 버전 예시)

template<typename T, typename = typename std::enable_if<!std::is_base_of<Person, typename std::decay<T>::type>::value>::type>

 

C++14 버전 예시)

template<typename T, typename = std::enable_if_t<!std::is_base_of<Person, std::decay_t<T>>::value>>

C++14 에서는 "typename" 과 "::type" 을 제거해서 좀 더 깔끔하게 코드를 작성할 수 있다.

 

거의 최종 버전이지만 해당 코드는 생성자와 충돌하는 보편 참조 생성자의 케이스에서, 보편 참조 생성자를 disable 하는 방법만 다루고 있다.

이제는 최초의 논의 (int 인자를 받는 overload 함수)도 같이 가져오자.

 

  1. 정수 인자들을 처리하는 Person overload 함수 추가
  2. 정수 인자 전달되는 경우 보편 참조 생성자 비활성화 처리

예시)

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>
>

앞서 추가한 std::is_base_of 와 && 연산으로 std::is_integral 을 사용하면 된다.

해당 방식이 최적이다.

 

절충점들

이번 시간에 배운, 보편 참조 상황에서 overload 문제가 발생하지 않게 하는 방법은 2가지로 나눌 수 있다.

  • 매개변수 형식을 지정함 : overload 포기, const T& 전달, 값으로 전달 방식
  • 매개변수 형식을 지정하지 않음 : Tag Dispatch 사용, 템플릿 활성화 제한

매개변수 형식을 지정하지 않는, Perfect forwarding 방식이 더욱 효율적이다.

선언된 매개변수 형식에 맞추기 위해 임시 객체를 생성하지 않기 때문이다.

 

Perfect forwarding 의 단점은 다음과 같다.

  1. 완벽 전달이 불가능한 인자가 존재함
  2. 클라이언트가 유요하지 않은 인자 전달했을 때, 발생하는 오류 메시지가 복잡함

1번 단점은 #30 에서 다룰 것이므로 2번 단점에 대해 더 자세히 확인해보자.

 

예시)

Person p(u"Konrad Zuse"); // const char16_t 형식

Perfect forwarding 에서는 컴파일러에 의해 const char16_t 형식도 보편 참조 생성자를 선택한다. (int 아니므로 템플릿 enable)

해당 생성자 내부에서 std::string 자료 멤버에 const char16_t 인자가 전달되면서 형식 불일치가 확인된다.

이 때 매우 많은 오류 메시지가 출력된다.

보편 참조가 여러 층의 함수 호출을 거칠 경우, 더 많은 에러 메시지가 표출된다.

 

이러한 오류 메시지를 개선하는 방법으로, static_assert, std::is_constructible 로 점검하는 방법이 있다.

std::is_constructible 형식 특질은 한 형식을 다른 형식으로 생성할 수 있는지 컴파일 시점에 결정한다.

예시)

explicit Person(T&& n):name(std::forward<T>(n)) {
	static_assert(std::is_constructible<std::string, T>::value, "Parameter n can't be used to construct a std::string");
	...
}

하지만 위 예시는 멤버 초기화 단계 (`:name(std::forward<T>(n))`)가 static_assert 보다 먼저 진행된다.

따라서 복잡한 오류 메시지 뒤에 명료한 오류 메시지를 확인할 수 있다.