[Modern C++] 항목 27: 보편 참조에 대한 오버로딩 대신 사용할 수 있는 기법들을 알아 두라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 5. rvalue 참조, 이동 개념, 완벽 전달

👉🏻 항목 27: 보편 참조에 대한 오버로딩 대신 사용할 수 있는 기법들을 알아 두라

🔍 도입

  • 항목 26에서 설명했듯이, 보편 참조에 대한 오버로딩은 자유 함수에서도, 멤버 함수(특히 생성자)에서도 다양한 문제를 일으킨다.
  • 이번 항목에서는 보편 참조에 대한 오버로딩이 아닌 기법을 이용하거나, 보편 참조 오버로딩이 일치할 수 있는 인수 타입을 제한함으로써 바람직한 행동을 달성하는 방법들을 살펴본다. (논의는 항목 26의 예제들에 기초한다.)

🔍 기법 1 — 오버로딩을 포기한다

  • logAndAdd의 두 오버로딩을 logAndAddName, logAndAddNameIdx처럼 서로 다른 이름으로 나누면 보편 참조 오버로딩의 단점을 피할 수 있다.
  • 그러나 이 방식은 생성자에는 통하지 않는다. 생성자의 이름은 언어에 의해 고정되기 때문이다.

🔍 기법 2 — const T& 매개변수를 사용한다

  • C++98로 돌아가서 보편 참조 대신 const에 대한 lvalue 참조 매개변수를 사용하는 것.
  • 단점은 우리가 원하는 만큼 효율적이지 않다는 것. 그러나 보편 참조와 오버로딩의 상호작용에 의한 문제점을 아는 상황에서는, 효율성을 포기하더라도 예상치 못한 문제를 피하는 것이 매력적인 절충안일 수 있다.

🔍 기법 3 — 값 전달 방식의 매개변수를 사용한다

  • 종종 복잡도를 높이지 않고 성능을 높이는 방법은 참조 전달 매개변수 대신 값 전달 매개변수를 사용하는 것이다(직관에는 반하지만). 이는 항목 41의 조언(복사될 것이 확실한 객체는 값으로 전달)과 일치한다.
class Person {
public:
  explicit Person(std::string n)   // T&& 생성자를 대체한다;
  : name(std::move(n)) {}          // std::move의 용법은 항목 41을 보라
  explicit Person(int idx)
  : name(nameFromIdx(idx)) {}
  
private:
  std::string name;
};
  • 정수 하나만 받는 std::string 생성자는 없으므로, int류 타입(std::size_t, short, long 등)의 인수로 호출하면 int를 받는 오버로딩이 선택된다. std::string을 생성할 수 있는 모든 인수("Ruth" 등)에 대해서는 std::string을 받는 생성자가 선택된다.

🔍 기법 4 — 꼬리표 배분(tag dispatch)을 사용한다

  • const lvalue 참조나 값 전달은 완벽 전달을 지원하지 않는다. 완벽 전달이 목적이라면 보편 참조 말고는 대안이 없다. 그런데도 오버로딩을 포기하지 않으려면(즉 보편 참조와 오버로딩을 함께 사용하되, 보편 참조 자체에 대한 오버로딩은 피하려면) 어떻게 해야 할까?
std::multiset<std::string> names;  // 전역 자료구조

template<typename T>               // 로그를 기록하고, 이름을
void logAndAdd(T&& name)           // 자료구조에 추가하는 함수
{
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}
  • 여기에 int 인덱스 값을 받는 오버로딩을 추가하면 항목 26의 문제(보편 참조 버전이 예상보다 자주 호출됨)가 재발한다. 이를 피하기 위해 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>가 필요한 이유: 보편 참조 name으로 lvalue가 전달되면 T는 lvalue 참조로 추론되므로, 참조를 제거한 뒤에 std::is_integral을 적용해야 정수 여부를 올바르게 판별할 수 있다.
// 비정수 인수를 받는 버전: 전역 자료구조에 추가한다
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));
}

// 정수 인수를 받는 버전: 인덱스로 이름을 조회해서 logAndAdd를 호출한다
std::string nameFromIdx(int idx);

void logAndAddImpl(int idx, std::true_type)
{
  logAndAdd(nameFromIdx(idx));
}
  • std::true_type/std::false_type은 실행시점에는 전혀 쓰이지 않는 일종의 꼬리표(tag)로, 원하는 오버로딩이 선택되게 하는 용도로만 쓰인다. 이렇게 컴파일 시점 값(주어진 작업을 꼬리표에 기초해서 ‘배분’하는 것)을 이용하는 설계를 꼬리표 배분(tag dispatch)이라 부른다. 템플릿 메타프로그래밍의 표준적인 구축 요소다.
  • 핵심은, 배분 함수(logAndAdd)는 제한 없는 보편 참조를 받지만 그 자체는 오버로딩되지 않는다는 것. 오버로딩되는 것은 구현 함수(logAndAddImpl)이며, 그 호출의 해소는 보편 참조뿐 아니라 꼬리표 매개변수에도 의존한다. 꼬리표 값들은 주어진 인수에 대해 단 하나의 오버로딩만 유효한 일치가 되도록 설계되었으므로 항목 26의 문제를 겪지 않는다.

🔍 기법 5 — 보편 참조를 받는 템플릿을 제한한다 (std::enable_if)

  • 꼬리표 배분은 클라이언트 API 역할을 하는 단일한(오버로딩되지 않은) 배분 함수가 필요하다. 그런데 완벽 전달 생성자(항목 26 참고)는 이 조건에서 예외다. 컴파일러가 자동으로 복사·이동 생성자를 작성할 수 있기 때문에, 생성자를 하나만 작성해서 그 안에서 꼬리표 배분을 적용해도 컴파일러가 작성한 함수가 그 설계를 종종(항상은 아니지만) 우회한다.
  • 이런 상황에는 std::enable_if가 필요하다. std::enable_if를 이용하면 컴파일러가 마치 특정 템플릿이 존재하지 않는 것처럼 행동하게 만들 수 있다(비활성화된 템플릿). 지정된 조건이 만족될 때에만 템플릿이 활성화된다.
class Person {
public:
  template<typename T,
           typename = typename std::enable_if<조건>::type>
  explicit Person(T&& n);
  
};
  • 조건: TPerson이 아닐 때에만 이 생성자가 활성화되어야 한다. 두 타입이 같은지 판별하는 타입 특성 std::is_same을 이용하면 될 것 같지만, lvalue로 초기화되는 보편 참조는 항상 lvalue 참조로 추론되므로(항목 28) 그것만으로는 부족하다.
Person p("Nancy");
auto cloneOfP(p);   // lvalue로 초기화됨; T는 Person&로 추론 → std::is_same<Person,Person&> == false (원하지 않는 결과)
  • 참조 한정자와 const/volatile을 무시해야 하는 문제도 있으므로, 이들을 모두 제거하는 타입 특성 std::decay를 함께 사용한다.
!std::is_same<Person, typename std::decay<T>::type>::value
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);
  
};

⚠️ 상속 문제 — std::is_same → std::is_base_of

  • 위 설계로도 SpecialPerson : public Person처럼 상속이 관여하면 문제가 재발한다. 자식 클래스의 복사·이동 생성자가 SpecialPerson 인수를 기반 클래스에 넘겨줄 때, SpecialPersonPerson과 같지 않으므로(std::decay 적용 후에도) 기반 클래스의 완벽 전달 생성자 템플릿이 활성화되고, 그 인스턴스가 복사·이동 생성자보다 더 나은 일치가 되어 버린다.
  • 해결책: 조건을 “TPerson이 아니다”가 아니라 “TPerson도 아니고 Person에서 파생된 타입도 아니다”로 정교화해야 한다. std::is_base_of<T1, T2>T2T1에서 파생된 타입이면 참(사용자 정의 타입은 자기 자신으로부터 파생된 것으로 간주됨)이다. 그냥 std::is_samestd::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);
  
};
  • C++11에서는 이것이 최종 해답이다. C++14에서는 std::enable_if_t, std::decay_t 별칭 템플릿을 이용해 typename::type을 제거할 수 있다.
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);
  
};

🔍 마지막 다듬기 — 정수 인수 구분

  • 지금까지는 Person(또는 파생 타입) 인수에 대해 보편 참조 생성자를 비활성화하는 방법만 다뤘다. 원래 목표였던 정수 인수와 비정수 인수의 구분은 아직 해결되지 않았다. 이제 다음 두 가지만 하면 된다: (1) 정수 인수를 처리하는 Person 생성자 오버로딩 추가, (2) 그런 인수들에 대해서는 템플릿화된 생성자가 비활성화되도록 조건 추가.
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)              // std::string이나 std::string으로
  : name(std::forward<T>(n)) {}       // 변환되는 인수를 위한 생성자

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

                                     // 기타 복사, 이동 연산자들

private:
  std::string name;
};
  • 이 설계는 완벽 전달을 사용하므로 효율성이 최대이며, 보편 참조에 대한 오버로딩을 금지하는 대신 활성화 조건을 제어함으로써 오버로딩을 피할 수 없는 상황(생성자 등)에도 적용할 수 있다.

🔍 절충점들 — 완벽 전달의 장단점

  • 처음 세 기법(오버로딩 포기, const T& 전달, 값 전달)은 호출되는 함수의 각 매개변수에 대해 타입을 지정한다. 나머지 두 기법(꼬리표 배분, 템플릿 활성화 제한)은 완벽 전달을 사용하므로 매개변수 타입을 지정하지 않는다.
  • 완벽 전달의 장점: 더 효율적이다. 선언된 매개변수 타입을 만족하기 위해 임시 객체를 생성하는 비효율이 없다(예: 문자열 리터럴을 Personstd::string 멤버 생성자로 직접 전달 가능).
  • 완벽 전달의 단점:
    1. 완벽 전달이 불가능한 인수들이 있다(항목 30 참고).
    2. 클라이언트가 유효하지 않은 인수를 전달했을 때 오류 메시지가 난해하다. 예를 들어 char16_t 문자열 리터럴로 Person 객체를 생성하면, 처음 세 접근방식에서는 직접적인 오류 메시지가 나오지만, 완벽 전달 기반 접근방식에서는 훨씬 나중에(std::string 생성자 단계) 불일치가 발견되어 매우 긴(저자 기준 160줄 이상) 오류 메시지가 출력된다.
      • 이 때문에 보편 참조를 성능이 최우선인 인터페이스에만 사용하는 것이 좋다고 생각하는 개발자가 많다. - static_assert로 완화: std::is_constructible 타입 특성으로 초기값 사용 가능 여부를 컴파일 시점에 점검해서 더 명확한 오류 메시지를 낼 수 있다.
class Person {
public:
  template<typename T, typename = std::enable_if_t<조건>>
  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"
    );
      // 여기서 통상적인 생성자 작업을 수행한다
  }
  
};
  • 단, static_assert는 생성자의 본문에 있지만, 매개변수를 std::string으로 전달하는 코드는 그보다 앞의 멤버 초기화 목록에 있으므로, static_assert에서 비롯된 깔끔한 메시지는 통상적인 오류 메시지(160줄 이상)가 출력된 이후에 출력된다.

🧐 정리

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

Cpp 카테고리 내 다른 글 보러가기

댓글남기기