Item 27. universal reference에 대한 overloading 대신 사용할 수 있는 기법들을 알아둬라

universal reference에 대한 overloading 대신 사용할 수 있는 기법들에 대해 알아보자.


overloading을 포기하는 방법

template <typename T>
void logAndAddName(T&& name) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}

void logAndAddNameIdx(int idx) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(nameFromIdx(idx));
}

간단하면서 무식한 방법이다.

Item 26에서 구현했던 logAndAdd overloading 함수를 universal reference를 인자로 받는 버전은 logAndAddName 함수로 변경하고 int를 인자로 받는 버전은 logAndAddNameIdx 함수로 변경하여 universal reference에 대한 overloading 문제를 해결할 수 있다.

하지만 이 방법은 생성자에 적용할 수 없고 overloading 함수의 장점인 다형성을 포기해야 하는 문제가 있다.


const T& 매개변수를 사용하는 방법

void logAndAdd(const std::string& name) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(name);
}

universal reference를 사용하지 않고 const T& 매개변수를 사용해서 universal reference에 대한 overloading 문제를 해결할 수 있다.

하지만 이동 연산이 가능한 상황에서도 복사 연산을 수행하는 등 효율이 떨어질 수 있다.


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

universal reference 대신 값 전달 방식(call by value)의 매개변수를 사용하면 universal reference에 대한 overloading 문제를 해결할 수 있다.

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

private:
  std::string name;
};

이 방식을 사용하면 정수 타입(int, size_t, short, long 등)의 인자들은 int 버전의 생성자가 호출되고 std::string 타입의 인자들은 std::string 버전의 생성자가 호출된다.

만약 std::string 버전의 생성자에게 null pointer를 전달하기 위해서 0, NULL을 사용할 경우 int 버전의 생성자가 호출되는 문제가 발생할 수 있지만 nullptr를 쓰면 아무런 문제가 없다.


tag dispatch 접근 방식을 사용하는 방법

앞서 이야기한 방법들은 perfect forwarding을 지원하지 않는다.

만약 perfect forwarding이 목적이라면 반드시 universal reference를 사용해야 한다.

그럼 universal reference에 대한 overloading 문제를 어떻게 해결해야 할까?

// 인덱스에 해당하는 이름을 돌려주는 함수
std::string nameFromIdx(int idx);

// int 버전
void logAndAdd(int idx) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(nameFromIdx(idx));
}

// universal reference 버전
template <typename T>
void logAndAdd(T&& name) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}

logAndAdd(22); // 정상 동작, int 버전이 호출

short nameIdx;
logAndAdd(nameIdx); // 에러 발생, universal reference 버전이 호출

꼬리표 배분(tag dispatch) 접근 방식을 사용하면 universal reference에 대한 overloading 문제를 해결할 수 있다.

tag dispatch 접근 방식은 클라이언트 API는 overloading 하지 않은 하나의 함수만 제공하고, 그 내부에서 적절한 구현 함수를 호출해서 동작하는 방식이다.

tag dispatch 접근 방식을 사용하는 코드는 다음과 같다.

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) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}

template <typename T>
void logAndAddImpl(T&& name, std::true_type) {
  logAndAdd(nameFromIdx(idx));
}

위 예시처럼 logAndAdd 함수를 overloading 하지 않고 내부에서 실제 작업을 수행하는 logAndAddImpl 함수를 overloading 해서 사용한다.

컴파일 과정에서 std::is_integral 함수는 T 타입을 추론하여 참일 경우 std::true_type, 거짓일 경우 std::false_type 인자가 되어 이에 부합하는 overloading 함수를 호출함으로써 universal reference에 대한 overloading 문제를 해결할 수 있다.

추가로 universal reference의 인자로 lvalue가 들어오면 T 타입이 T&로 추론되는데 정수 타입 여부를 확인하기 위해서는 reference를 제거해야 한다. (int&는 정수 타입으로 인식하지 못함)

reference를 제거하기 위해서 typename std::remove_reference<T>::type를 사용한다.

C++14에서는 std::remove_reference_t<T>를 사용해서 좀 더 간결하게 표현할 수 있다.

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


universal reference를 받는 템플릿을 제한하는 방법

tag dispatch 접근 방식을 사용하면 universal reference에 대한 overloading 문제를 해결할 수 있지만 이를 사용할 수 없는 상황이 있다.

바로 생성자를 사용하는 경우인데 컴파일러가 자동으로 복사, 이동 생성자를 만들어버리면 overloading 문제가 발생한다.

이때 문제되는 복사, 이동 생성자들의 인자 타입만 universal reference가 받을 수 없게 만들면 universal reference에 대한 overloading 문제를 해결할 수 있다.

std::enable_if<조건>::type을 사용하면 해당 조건이 참일 때만 클래스 함수를 인스턴스화 할 수 있다.

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

이제 TPerson이 아닐 때만 생성자를 인스턴스화 하기 위해 조건을 넣으면 다음과 같다.


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

universal reference에 대한 overloading 문제를 해결하기 위해서는 이동, 복사 생성자가 호출되는 경우를 모두 제외해야 한다.

그러기 위해서는 조건에 T이 reference와 cv 지정자(const, volatile)가 붙은 경우도 모두 제외할 수 있어야 한다.

std::decay<T>::type을 사용하면 reference와 cv 지정자를 제거한 타입을 얻을 수 있다.

여기서 끝이 아니다.

만약 상속을 통해 자식 클래스 타입이 생성자의 인자로 들어오면 복사, 이동 생성자가 호출되지 못하고 universal reference 생성자가 호출된다.

따라서 해당 타입이 자식 클래스 타입인지 확인까지 해야 한다.

이를 위해 std::is_same 대신 std::is_base_of를 사용한다.

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);
};

여기에 int 타입을 받는 생성자도 universal reference 생성자에서 제외할 수 있도록 조건을 추가하면 진짜 끝이다.

class Person {
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)) {}
};

이렇게 universal reference를 받는 템플릿을 제한하는 방법을 통해 universal reference에 대한 overloading 문제를 해결하면서 효율성 있는 코드를 구현하였다.

하지만 이러한 TMP(Template Meta Programming) 방식은 가독성이 좋지 않아서 사용에 주의해야 한다.


universal reference의 장점과 단점

universal reference를 통해 perfect forwarding을 구현할 수 있다.

perfect forwarding은 매개변수 타입을 만족하기 위해 임시 객체를 생성하지 않아서 효율적이다.

하지만 perfect forwarding도 다음과 같은 단점이 있다.

perfect forwarding이 불가능한 인자가 있음

말 그대로 perfect forwarding이 불가능한 인자가 있다.

자세한 내용은 Item 30에서 다루겠다.

오류 메시지가 난해함

또 다른 단점은 잘못된 인자를 넘겼을 때 나타나는 오류 메시지가 난해하다는 점이다.

Person p(u"Konrad Zuse");

다음과 같이 std::string 매개 변수를 가진 생성자에 char16_t(C++11에 도입된 16bit 문자 타입) 타입으로 구성된 문자열 리터럴을 인자를 넘기면 const char16_t[12]std::string으로 변환할 수 없다는 에러 메시지가 나타난다.

하지만 universal reference 생성자를 사용하면 문제없이 생성자가 호출되고 그 뒤에 const char16_t*std::string 사이에 불일치가 발생할 것이다.

그만큼 에러 메시지를 이해하기 힘들어질 것이다.


카테고리:

업데이트: