KoreanFoodie's Study

Effective Modern C++ | 항목 27 : 보편 참조에 대한 중복적재 대신 사용할 수 있는 기법들을 알아 두라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 27 : 보편 참조에 대한 중복적재 대신 사용할 수 있는 기법들을 알아 두라

GoldGiver 2022. 10. 26. 10:00

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 27 : 보편 참조에 대한 중복적재 대신 사용할 수 있는 기법들을 알아 두라

핵심 :

1. 보편 참조와 중복적재의 조합에 대한 대안으로는 구별되는 함수 이름 사용, 매개변수를 const 에 대한 왼값 참조로 전달, 매개변수를 값으로 전달, 꼬리표 배분 사용 등이 있다.
2. std::enable_if 를 이용해서 템플릿의 인스턴스화를 제한함으로써 보편 참조와 중복적재를 함께 사용할 수 있다. std::enable_if 는 컴파일러가 보편 참조 중복적재를 사용하는 조건을 프로그래머가 직접 제어하는 용도로 쓰인다.
3. 보편 참조 매개변수는 효율성 면에서 장점인 경우가 많지만, 대체로 사용성 면에서는 단점이 된다.


보편 참조에 대한 중복적재로 인해 생기는 문제점들을 어떻게 해결할 수 있을까? 후보들을 한 번 살펴보자.
1. 중복 적재를 포기한다 : 함수 이름을 바꾼다. 하지만 이건 원래의 목적을 포기하는 행위이며, 클래스의 생성자의 경우 아무런 문제를 해결해주지 못한다.
2. const T& 매개변수를 사용한다 : 보편 참조 매개변수 대신 const T& 를 받는 생성자를 만든다. 이는 C++98 버전의 구식 방식이다.
3. 값 전달 방식의 매개변수를 사용한다 : 참조 전달 매개변수 대신 값 전달 매개변수를 사용하는 것이다.

class Person {
public:
  // T&& 대신 std::string 사용
  template<typename T>
  explicit Person(std::string n)
  : name(std::forward<T>(n)) {}
  
  explicit Person(int idx);
};

위의 코드는 이제 정수 형식은 int 로 변환되고, std::string 형식은 std::string 을 받는 생성자를 호출하게 된다!
4. 꼬리표 배분(tag dispatch)을 사용한다 : 보편 참조와 중복적재를 둘 다 사용하되 보편 참조에 대한 중복적재는 피할 수 있다. 오버로딩 시, 매개변수 목록에 보편 참조가 아닌 매개변수들도 포함되어 있으면, 보편 참조가 아닌 매개변수들에 대한 충분히 나쁜 부합이 보편 참조 매개변수가 있는 중복적재를 제치고 선택될 가능성이 있다. 위의 logAndAdd 의 예시에서, 실제로 logAndAddImpl 이라는 함수를 호출하게 만들어 정수 타입과 비정수 타입의 케이스를 처리하는 방식을 보자.

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

위에서 std::remove_reference 를 사용한 이유는 T 에 왼값 인자(int &) 가 들어올 수 있는데, int& 는 int 로 취급을 하지 않기 때문이다. std::is_integral 은 말 그대로 해당 인자가 정수인지 아닌지 판단한다. 이제 logAndAddImpl 의 구현을 보자.

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


std::string nameFromIdx(int idx);

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

우리는 컴파일 타임에서 중복적재를 해소하고 싶었으므로, true 와 false 라는 bool 값 대신 std::false_type, std::true_type 이라는 타입을 활용했다. 또한 이러한 타입들의 매개변수에는 이름도 붙이지 않았다. 그래서 컴파일러는 런타임에서는 꼬리표 매개변수들을 아예 제거하는 최적화를 수행하기도 한다. 중복적재 해소가 꼬리표에 기초해서 '배분(dispatch)' 되고 있으므로, 이러한 설계를 꼬리표 배분이라고 부른다.

우리가 맨 처음 제시한 Person 클래스의 예시에서, 보편 참조 생성자에 Person 타입의 매개변수가 전달되었을때, 컴파일러가 자동으로 만들어 주는 복사 생성자가 무시되는 경우가 있었다. 그렇다면 Person 타입의 매개변수가 전달되었을 때, 완벽 전달 생성자를 비활성화 시켜주면 어떨까? 다음과 같이 하면 된다.

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

std::enable_if 를 이용하면 컴파일러가 마치 특정 템플릿이 존재하지 않는 것처럼 행동하게 만들 수 있다. 조건식 내부에는, !std::is_same<Person, T>::value 등의 표현식을 넣으면 된다. 위 코드를 제대로 이해하고 싶으면, "SFINAE" 에 대해서도 조금 공부해 보자.
그런데 사실 T 는 참조 여부와 const성/volatile 성이라는 두 가지 추가 속성을 가지므로, 이를 제거해 주어야 제대로 된 비교를 수행할 수 있다. std::decay<T>::type 은 T 에서 모든 참조와 cv 한정사(cv-qualifier; 즉 const, volatile 한정사)를 제거한 형식을 제공한다. 이러한 것들을 적용한 구현은 다음과 같다.

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)
  : name(std::forward<T>(n)) {}
};

다만 위 구현은, 파생 클래스에서 기반 클래스의 이동/복사 생성자를 호출했을 경우를 처리하지 못한다. 즉, std::is_same 에서 T 가 정확히 해당 클래스와 같은 것 뿐만 아니라, 파생 클래스까지 같은지를 체크해 주어야 한다!

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)
  : name(std::forward<T>(n)) {}
};

따라서 위와 같이 std::is_same 을 std::is_base_of 로 바꿔주기만 하면 된다! 참고로 std::is_base_of<T, T>::value 는 T 가 사용자 정의 형식이면 참이며, 내장 형식(primitive type)일 때는 거짓이다.
C++14 버전으로 바꾸면 코드가 조금 더 간결해질 것이다.

class Person {
public:
  template<
  typename T,
  typename = std::enable_if_t<
    !std::is_base_of<
      Person, 
      std::decay_t<T>
      >
      ::value
    >
  >
  explicit Person(T&& n)
  : name(std::forward<T>(n)) {}
};

그럼 이제 마지막으로 정수 인수와 비정수 인수를 구분하는 부분까지 추가해서, 코드를 완성해 보자.

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

  // std::string 이나 std::string 으로 변환되는 생성자
  explicit Person(T&& n)
  : name(std::forward<T>(n)) { ... }

  // 정수 인수를 위한 생성자
  explicit Person(int idx)
  : name(nameFromIdx(idx)) { ... }
};


완벽 전달은 효율적이고 다양한 상황에 대처할 수 있지만, 완벽 전달이 불가능한 인수도 있으며(항목 30 참고), 오류가 발생했을 때 디버깅이 난해할 수 있다는 점이 있다. 디버깅을 위해, static_assert 로 컴파일 타임에서 형식 변환을 한 번 더 점검하는 방법도 있다.

// std::string 이나 std::string 으로 변환되는 생성자
explicit Person(T&& n)
: name(std::forward<T>(n)) 
{
  // T 객체로부터 std::string 을 생성할 수 있는지 점검
  static_assert(
    std::is_constructible<std::string, T>::value,
    "Parameter n can't be used to construct a std::string");
}

 

Comments