[Modern C++] 항목 26: 보편 참조에 대한 오버로딩을 피하라

게시:     수정

카테고리:

태그: ,

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

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

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

👉🏻 항목 26: 보편 참조에 대한 오버로딩을 피하라

🔍 문제 상황 — 비효율적인 초기 구현

  • 사람 이름을 매개변수로 받아 현재 시간과 함께 전역 자료구조에 추가하는 함수를 작성한다고 하자.
std::multiset<std::string> names;      // 전역 자료구조

void logAndAdd(const std::string& name)
{
  auto now = std::chrono::system_clock::now(); // 현재 시간을 얻는다
  log(now, "logAndAdd");                       // 로그에 기록한다
  names.emplace(name);                         // 이름을 전역 자료구조에
}                                               // 추가한다 (항목 24 참고)
  • 이 함수를 세 방식으로 호출하면 다음과 같은 비효율이 있다.
std::string petName("Darla");

logAndAdd(petName);                        // lvalue std::string을 넘겨줌
logAndAdd(std::string("Persephone"));      // rvalue std::string을 넘겨줌
logAndAdd("Patty Dog");                    // 문자열 리터럴을 넘겨줌
  • 첫째 호출: name이 lvalue이므로 emplace가 복사한다 — 피할 수 없는 복사.
  • 둘째 호출: name은 rvalue에 묶이지만 name 자체는 lvalue라 복사가 일어난다 — 원칙적으로 이동으로 대체 가능(복사 1회 → 이동 1회로 개선 여지).
  • 셋째 호출: 문자열 리터럴로부터 임시 std::string 객체가 암묵적으로 생성되고 그것이 다시 names로 복사된다 — 리터럴을 emplace에 직접 넘겼다면 애초에 임시 객체 생성 자체가 불필요했을 비효율.

🔍 개선 — 보편 참조 + std::forward

  • logAndAdd가 보편 참조(항목 24)를 받고, 그 참조를 std::forwardemplace에 전달하면(항목 25) 둘째·셋째 호출의 비효율을 제거할 수 있다.
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");

logAndAdd(petName);                       // 이전처럼 lvalue가
                                           // multiset으로 복사됨

logAndAdd(std::string("Persephone"));     // rvalue를 이동한다
                                           // (복사가 아니라)

logAndAdd("Patty Dog");                   // 임시 std::string 객체를
                                           // 복사하는 대신, multiset
                                           // 안에 std::string을 생성한다
  • 최적의 효율성을 달성했다!

⚠️ 함정 — 보편 참조 오버로딩의 위험성

  • logAndAdd의 클라이언트 중에는 인덱스만 알고 있는 경우가 있어, int 인덱스를 받는 오버로드를 추가한다고 하자.
std::string nameFromIdx(int idx);   // idx에 해당하는 이름을 돌려준다

void logAndAdd(int idx)             // 새 오버로드
{
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(nameFromIdx(idx));
}
  • 통상적인 예상에 대해서는 예상대로 작동한다.
std::string petName("Darla");

logAndAdd(petName);                       // 이들은 모두 T&&를 받는
logAndAdd(std::string("Persephone"));     // 오버로드를 호출한다
logAndAdd("Patty Dog");

logAndAdd(22);                            // int 버전을 호출한다
  • 그러나 인덱스 값을 short로 넘겨주면 문제가 생긴다.
short nameIdx;
                        // nameIdx에 값을 대입
logAndAdd(nameIdx);       // 오류!
  • 원인: logAndAdd의 오버로드는 두 가지다. 보편 참조 버전은 Tshort&로 추론해 정확히 일치하지만, int 버전은 shortint로 승격(promotion)해야 일치한다. 오버로드 해소 규칙상 정확한 일치가 승격을 통한 일치보다 우선하므로 보편 참조 버전이 호출된다.
  • 매개변수 name(즉 short&)이 std::forward를 통해 emplacestd::string 생성자로 전달되는데, std::string에는 short를 받는 생성자가 없으므로 컴파일이 실패한다.
  • 핵심 교훈: 보편 참조를 받는 템플릿 함수는 C++에서 가장 욕심 많은 함수다. 거의 모든 타입의 인수와 정확히 일치하기 때문에, 보편 참조와 오버로딩을 결합하는 것은 개발자가 예상한 것보다 훨씬 많은 타입을 빨아들여 거의 항상 나쁜 선택이다.

⚠️ 더 심각한 사례 — 생성자에서의 보편 참조 오버로딩

  • 인덱스를 받는 자유 함수 대신, 그런 일을 하는 생성자들을 가진 Person 클래스를 도입한다고 하자.
class Person {
public:
  template<typename T>
  explicit Person(T&& n)        // 완벽 전달 생성자
  : name(std::forward<T>(n)) {} // 자료 멤버를 초기화한다

  explicit Person(int idx)      // int를 받는 생성자
  : name(nameFromIdx(idx)) {}
  
private:
  std::string name;
};
  • int 이외의 정수 타입(std::size_t, short, long 등)을 넘기면 위와 같은 이유로 컴파일이 실패한다. 그런데 이번에는 문제가 더 심각하다. 특정 조건에서 C++은 복사 생성자와 이동 생성자를 자동으로 작성하는데, 템플릿화된 생성자가 그 복사/이동 생성자에 해당하는 시그니처로 인스턴스화될 수 있는 경우에도 자동 작성이 일어난다(항목 17 참고). 즉, Person은 사실상 다음과 같은 모습이 된다.
class Person {
public:
  template<typename T>              // 완벽 전달 생성자
  explicit Person(T&& n)
  : name(std::forward<T>(n)) {}
  explicit Person(int idx);         // int를 받는 생성자

  Person(const Person& rhs);        // 복사 생성자 (컴파일러가 작성함)
  Person(Person&& rhs);             // 이동 생성자 (컴파일러가 작성함)
  
};
Person p("Nancy");
auto cloneOfP(p);   // p로부터 새 Person을 생성; 컴파일에 실패한다!
  • 컴파일러의 추론: cloneOfPconst가 아닌 lvalue(p)로 초기화되므로, 완벽 전달 생성자를 Person&을 받는 형태로 인스턴스화할 수 있다.
class Person {
public:
  explicit Person(Person& n)                 // 완벽 전달 템플릿에서
  : name(std::forward<Person&>(n)) {}        // 인스턴스화됨
  explicit Person(int idx);
  Person(const Person& rhs);                 // 복사 생성자 (컴파일러가 생성함)
};
  • 이 인스턴스화된 템플릿은 복사 생성자보다 더 나은 일치(비const 인수에 대해 정확한 타입 vs const 추가 필요)이므로 완벽 전달 생성자가 호출된다. 그런데 이 함수는 Personstd::string 자료 멤버를 Person 객체(p)로 생성하려 하는데, std::string에는 Person을 받는 생성자가 없으므로 알아보기 힘든 오류 메시지들과 함께 컴파일이 실패한다.
  • 복사할 객체가 const이면 상황이 완전히 달라진다.
const Person cp("Nancy");   // 객체가 이제는 const
auto cloneOfP(cp);          // 복사 생성자를 호출한다!
  • 이제는 복사 생성자가 받는 매개변수(const Person&)와 정확히 일치하므로 복사 생성자가 우선 선택된다(비템플릿 함수가 동일하게 일치하는 템플릿 인스턴스보다 우선한다는 규칙, 항목 17 참고).

⚠️ 상속이 관여할 때 — 더 큰 여파

class SpecialPerson: public Person {
public:
  SpecialPerson(const SpecialPerson& rhs) // 복사 생성자;
  : Person(rhs)                           // 기반 클래스의 완벽
  {  }                                   // 전달 생성자를 호출!

  SpecialPerson(SpecialPerson&& rhs)      // 이동 생성자;
  : Person(std::move(rhs))                // 기반 클래스의 완벽
  {  }                                   // 전달 생성자를 호출!
};
  • 자식 클래스의 복사·이동 생성자가 기반 클래스의 복사·이동 생성자가 아니라 기반 클래스의 완벽 전달 생성자를 호출한다! 자식 클래스 함수가 SpecialPerson 타입의 인수를 기반 클래스에 넘겨주는데, Person 안에서 그 타입에 대해 템플릿 인스턴스화·오버로드 해소를 해보면 SpecialPerson을 받는 std::string 생성자가 없어 컴파일이 실패함을 알 수 있다.

🧐 정리

  • 보편 참조 매개변수에 대한 오버로딩은 거의 항상 보편 참조 오버로딩 버전이 예상보다 훨씬 자주 호출되는 상황으로 이어진다.
  • 완벽 전달 생성자는 특히나 문제가 많다. 대체로 비const lvalue에 대한 복사 생성자보다 더 나은 일치이며, 기반 클래스 복사·이동 생성자들에 대한 자식 클래스의 호출들을 가로챌 수 있기 때문이다.

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

댓글남기기