[Modern C++] 항목 26: 보편 참조에 대한 오버로딩을 피하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 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::forward로emplace에 전달하면(항목 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의 오버로드는 두 가지다. 보편 참조 버전은T를short&로 추론해 정확히 일치하지만,int버전은short를int로 승격(promotion)해야 일치한다. 오버로드 해소 규칙상 정확한 일치가 승격을 통한 일치보다 우선하므로 보편 참조 버전이 호출된다. - 매개변수
name(즉short&)이std::forward를 통해emplace→std::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을 생성; 컴파일에 실패한다!
- 컴파일러의 추론:
cloneOfP는const가 아닌 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 추가 필요)이므로 완벽 전달 생성자가 호출된다. 그런데 이 함수는
Person의std::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에 대한 복사 생성자보다 더 나은 일치이며, 기반 클래스 복사·이동 생성자들에 대한 자식 클래스의 호출들을 가로챌 수 있기 때문이다.
댓글남기기