KoreanFoodie's Study

Effective Modern C++ | 항목 26 : 보편 참조에 대한 중복적재를 피하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 26 : 보편 참조에 대한 중복적재를 피하라

GoldGiver 2022. 10. 26. 09:59

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

항목 26 : 보편 참조에 대한 중복적재를 피하라

핵심 :

1. 보편 참조에 대한 중복적재는 거의 항상 보편 참조 중복적재 버전이 예상보다 자주 호출되는 상황으로 이어진다.
2. 완벽 전달 생성자들은 특히나 문제가 많다. 그런 생성자는 대체로 비const 왼값에 대한 복사 생성자보다 더 나은 부합이며, 기반 클래스 복사 및 이동 생성자들에 대한 파생 클래스들의 호출들을 가로챌 수 있기 때문이다.

 

다음과 같은 코드가 있다고 가정하자.

std::multiset<std::string> names;

void logAndAdd(const std::string& name)
{
  auto now =
    std::chrono::system_clock::now();

  log(now, "logAnddAdd");

  names.emplace(name);
}

...

std::string petName("Darla");

logAndAdd(petName); // 왼값

logAndAdd(std::string("Perephone")); // 오른값

logAndAdd("Patty Dog"); // 문자열 리터럴

첫 호출에서 매개변수 name 은 petName 에 묶이며, name 이 왼값이므로, emplace 는 그것을 names 에 복사한다.

둘째 호출에서는 매개변수 name 이 오른값에 묶인다. name 자체는 왼값이므로 names 에 복사된다. 그러나 원칙적으로 name 의 값을 names 로 이동하는 것이 가능하다. 따라서 복사 1회의 비용이 발생하지만 대신 한 번의 이동으로 작업을 완수할 여지가 있다.

셋째 호출에서도 매개변수 name 은 오른값에 묶여 std::string 복사 1회의 비용(문자열 리터럴에서 암묵적으로 복사 생성자 호출)을 치르지만, 원칙적으로는 복사는 커녕 이동 비용을 치를 필요도 없다.

logAndAdd 가 보편 참조를 받게 하고 std::forward 를 사용하면, 둘째 호출과 셋째 호출의 비효율성을 제거할 수 있다.

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

 

그런데 이제는 std::string 타입이 아니라, 정수 인자를 통해 std::string 색인을 찾는 오버로드 함수를 정의했다고 하자.

void logAndAdd(int idx)
{
  auto now =
    std::chrono::system_clock::now();
  log(now, "logAnddAdd");
  names.emplace(nameFromIdx(idx));
}

logAndAdd(22);

하지만 다음과 같은 코드는 오류를 발생시킨다.

short nameIdx = 26;

logAndAdd(nameIdx);

logAndAdd 의 중복적재는 두 가지인데, 보편 참조를 받는 버번은 T 를 short& 로 연역할 수 있으며, 그러면 주어진 인수와 정확히 부합하는 형태가 된다. int 를 받는 버전은 short 인수를 int 로 승겨(promotion) 해야 호출과 부합한다. 보통의 중복적재 해소 규칙에 따라, 정확한 부합이 승격을 통한 부합보다 우선시된다. 따라서 보편 참조 중복적재가 호출된다.

그런데 보편 참조 중복 적재는 전달받은 인자로 std::string 객체를 생성하려고 시도한다. 이 부분에서 에러가 발생하게 된다! 보편 참조 중복 적재는 자동으로 생성하는 복사 생성자와 이동 생성자와도 경쟁을 해서 이김(?) 으로써, 이상한 동작을 하도록 만들 수 있다!

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

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

  // 이동 생성자(컴파일러가 생성)
  Person(Person&& rhs);
};

...

Person p("Nancy");

// 컴파일 에러!
auto cloneOfP(p);

위의 코드에서 cloneOfP 는 복사 생성자를 호출하지 않고, Person 을 받는 완벽 전달 생성자를 호출한다(인스턴스화가 이루어짐)! 그 이유는, p 는 Person& 타입(왼값)이지 const Person& 이 아니기 때문이다(const 가 없어서 복사 생성자가 호출이 안됨)!

만약 코드를 다음과 같이 바꾸면 컴파일이 될 것이다.

const Person p("Nancy");

// 컴파일 에러!
auto cloneOfP(p);

C++ 는 오버로딩 해소 중 어떤 함수 호출이 템플릿 인스턴스와 비템플릿 함수(즉, '보통' 함수)에 똑같이 부합한다면 보통 함수를 우선시 한다는 규칙이 있다.

 

마지막으로, 상속이 관여할 경우, 문제가 더 커질 수 있다.

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

  SpecialPerson(const SpecialPerson& rhs)
  : Person(std::move(rhs)) // 기반 클래스의 완벽 전달 생성자 호출!
  { ... }
};

위의 코드에서, 기반 클래스의 복사, 이동 생성자들을 호출하지 않는 이유는 무엇일까? 일단, 인자로 SpecialPerson 을 전달하고 있는데 기반 클래스에서는 파생클래스가 존재하는지 아닌지조차 모른다. 따라서 애초에 기반 클래스에서 파생 클래스를 인수로 받은 생성자를 생성한다는 것이 논리적으로 말이 되지 않는다. 그러므로 자연스럽게, 인자 형식을 SpecialPerson 이 되는 완벽 전달 생성자가 인스턴스화 되어, 복사나 이동 생성자 대신 호출되는 것이다!

Comments