Item 26. universal reference에 대한 overloading을 피해라

이름을 인자로 받아 현재 날짜와 시간을 기록하고 그 이름을 전역 자료구조에 추가하는 함수를 구현한다고 가정해보자.

std::multiset<std::string> names;  // 전역 자료구조
 
void logAndAdd(const std::string& name) {
  auto now = std::chrono::system_clock::now(); // 현재 시간 획득
  
  log(now, "logAndAdd"); // 로그에 기록

  names.emplace(name); // 이름을 전역 자료구조에 추가
}

위 코드는 잘 동작하지만 비효율적인 부분이 존재한다.

다음 함수를 3가지 방법으로 호출해보자.

std::string petName("Darla");

// 1. lvalue std::string을 넘겨주는 방법
logAndAdd(petName);

// 2. rvalue std::string을 넘겨주는 방법
logAndAdd(std::string("Persephone"));

// 3. 문자열 리터럴을 넘겨주는 방법
logAndAdd("Patty Dog");

각 방법은 다음과 같은 작업을 수행한다.

  1. name이 lvalue std::string 객체를 참조하고 names가 해당 객체를 복사한다.
  2. name이 rvalue std::string 객체를 참조하고 names가 해당 객체를 복사한다.
  3. name이 문자열 리터럴로부터 생성된 임시 rvalue std::string 객체를 참조하고 names가 해당 객체를 복사한다.

2번 방법의 경우, rvalue 객체를 lvalue reference로 참조하여 이동 연산 대신 복사 연산을 하는 비효율이 존재한다.

또한 3번 방법의 경우, 리터럴 문자열을 emplace 함수로 바로 넘기면 복사 연산은커녕 이동 연산도 발생하지 않는데 이를 활용하지 못하고 있다.

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

/* 위와 동일 */
std::string petName("Darla");

// 1. lvalue std::string을 넘겨주는 방법
logAndAdd(petName);

// 2. rvalue std::string을 넘겨주는 방법
logAndAdd(std::string("Persephone"));

// 3. 문자열 리터럴을 넘겨주는 방법
logAndAdd("Patty Dog");

각 방법은 다음과 같은 작업을 수행한다.

  1. name이 lvalue std::string 객체를 참조하고 names가 해당 객체를 복사한다.
  2. name이 rvalue std::string 객체를 참조하고 names가 해당 객체를 이동시킨다.
  3. name이 문자열 리터럴을 참조하고 names가 내부에서 std::string을 생성한다.


universal reference에 대한 overloading 문제

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); // 정상 동작

overloading 함수는 정상적으로 잘 동작한다.

하지만 인덱스의 타입이 int가 아닌 short이면 어떻게 될까?

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

보다시피 컴파일 에러가 발생한다.

그 원인은 overloading 함수의 우선순위에 있다.

int를 받는 버전은 short 인자를 int로 암시적 변환을 통해 호출해야 한다면 universal reference를 받는 버전은 T의 타입을 short&로 추론하여 바로 호출할 수 있다.

중복적재 해소(overload resolution) 규칙에 따라 정확하게 조건에 부합하는 overloading 함수가 우선 호출된다.

따라서 short를 인자로 사용하면 universal reference 버전의 함수가 호출되고 emplace 함수에 short 타입을 인자를 전달하여 컴파일 에러가 발생하는 것이다.

위 예시에서 알 수 있듯이 universal reference에 대한 overloading은 최대한 피하는 것이 좋다.


universal reference를 사용하는 생성자를 overloading 하는 경우

클래스 생성자의 인자로 universal reference를 사용할 때에도 overloading 문제가 발생할 수 있다.

class Person {
public:
  template <typename T>
  explicit Person(T&& n)
  : name(std::forward<T>(n)) {}

  explicit Person(int idx)
  : name(nameFromIdx(idx)) {}

private:
  std::string name;
};

위 예시에서도 앞서 이야기한 universal reference에 대한 overloading 문제가 그대로 나타난다.

idxint이외의 정수 타입(std::size_t, short, long 등)이면 universal reference 버전의 생성자가 호출되어 컴파일 에러가 발생한다.

여기서 끝이 아니다. 직접 구현한 생성자 이외에도 특수 멤버 함수들의 자동 작성 조건이 맞으면 컴파일러가 자동으로 생성한 생성자가 존재한다는 사실을 잊어서는 안 된다.

class Person {
public:
  template <typename T>
  explicit Person(T&& n)
  : name(std::forward<T>(n)) {}

  explicit Person(int idx)
  : name(nameFromIdx(idx)) {}

  // 컴파일러가 자동 생성하는 복사 생성자
  Person(const Person& rhs);
  
  // 컴파일러가 자동 생성하는 이동 생성자
  Person(Person&& rhs);

private:
  std::string name;
};

Person p("Nancy");

auto cloneOfP(p); // 컴파일 에러, 복사 생성자가 아닌 universal reference 버전 생성자를 호출

컴파일러가 자동으로 복사 생성자를 생성했지만 우선 순위가 높은 universal reference 버전 생성자가 호출되고 컴파일 에러가 발생한다.

복사 생성자를 호출하려면 const를 붙여서 복사 생성자의 매개변수 타입과 정확하게 부합하게 만들면 된다.

const Person p("Nancy");

auto cloneOfP(p); // 정상 동작

함수의 시그니처가 같은 경우 템플릿 함수보다 일반 함수가 우선순위가 높다.


카테고리:

업데이트: