본문 바로가기
C++/Effective Modern C++

보편 참조에 대한 중복적재를 피하라

by COCO1337 2020. 7. 21.
// 사람 이름 하나를 매개변수로 받고, 현재 날짜와 시간을 기록하여 전역 자료구조에 추가하는 예제
std::multiset<std::string> names;	// 전역 자료구조
void logAndAdd(const std::string& name)
{
  auto now = std::chrono::system_clock::now();	// 현재 시간을 얻고
  log(now, "logAndAdd");	// 로그에 기록
  names.emplace(name);		// 이름을 전역 자료구조에 추가
}

std::string petName("Darla");
logAndAdd(petName);			// 왼값 std::string을 넘겨줌
logAndAdd(std::string("Persephone"));	// 오른값 std::string을 넘겨줌
logAndAdd("Patty Dog");			// 문자열 리터럴을 넘겨줌

logAndAdd 첫 호출에서 매개변수 name은 petName에 묶여 names.emplace로 전달된다. name이 왼값이므로 emplace에서 names로 복사한다.

두번째 호출에서 매개변수 name이 오른값("Persephon"로부터 명시적으로 생성된 임시 std::string객체)에 묶인다. name 자체는 왼값이므로 names에 복사되지만 name의 값을 names로 이동이 가능하기 때문에 복사를 피할 여지가 있다.

세번째 호출에서도 매개변수 name은 오른값에 묶이지만 암묵적으로 생성된 임시 객체 std::string에 묶였다. 하지만 문자열 리터럴이기 때문에 emplace에 직접 전달시 std::multiset안에서 std::string객체를 직접 생성한다.

logAndAdd가 보편 참조를 받게 하면, 즉 std::forward를 이용해서 emplace에 전달하면 둘째 호출과 셋째 호출의 비효율성을 제거할 수 있다.

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);			// 왼값 std::string을 넘겨줌, 이전과 동일하게 multiset으로 복사됨
logAndAdd(std::string("Persephone"));	// 오른값을 이동함
logAndAdd("Patty Dog");			// 임시 std::string을 복사하는 대신 multiset안에 std::string생성

만약 클라이언트가 logAndAdd가 요구하는 문자열 형태의 이름을 직접 얻지 못하고, 색인만 알고 있으며 그 색인을 통해 이름을 조회해야 한다. 이를 지원하기 위해 logAndAdd를 중복적재 한다고 하자.

std::string nameFromIdx(int 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);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");		// 이전과 동일하며 모두 T&&를 받는 중복적재 버전 호출
logAndAdd(22);			// int 버전 호출

short nameIdx;
logAndAdd(nameIdx);		// 오류

logAndAdd의 중복적재는 보편참조를 받는 버전과 int를 받는 버전의 2개의 중복적재가 있다. short는 보편참조를 받는 버전의 T를 short&로 연역했을 때 정확히 부합하는 형태가 되며, int 버전의 경우 short인수를 int로 승격해야 호출에 부합하게 된다. 즉 보편참조 중복적재가 호출하게 된다.

보편참조를 받는 중복적재에서 매개변수 name은 short에 묶여서 names(std::multiset<std::string> 객체)의 멤버 함수 emplace에 전달된다. 하지만 std::string의 생성자 중에 short를 받는 버전이 없기 때문에 multiset::emplace호출 안의 std::string생성자 호출이 실패한다.

보편참조를 받는 템플릿 함수는 거의 모든 형식의 인수와 정확히 부합한다. 그렇기 때문에 보편참조와 중복 적재를 결합하는것은 거의 항상 나쁜 선택이라 할 수 있다.


이러한 문제는 완벽 전달 생성자를 작성해서 피할 수 있다. std::string 또는 자유 색인을 받는 자유 함수 대신 그와 같은 생성자를 가진 Person이라는 클래스를 도입해보자

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 coneOfP(p);	// p로부터 새 Person 생성, 컴파일 실패!

함수는 복사 생성자를 호출하지 않고 Person의 std::string 자료 멤버를 Person 객체(p)로 생성하려 한다. 하지만 std::string에는 Person을 받는 생성자가 없어서 컴파일 에러가 발생한다. 이 예에서 컴파일러는 cloneOfP를 const가 아닌 왼값(p)로 초기화하며, 템플릿화 된 생성자를 Person 형식의 비 const 왼값을 받는 형태로 인스턴스화 할 수 있다.

// 인스턴스화 된 Person 클래스
class Person{
public:
  explicit Person(Person& n)		// 완벽 전달 템플릿에서 인스턴스화 됨
  : name(std::forward<Person&>(n)) {}
  ...
};

auto cloneOfP(p); 에서 복사 생성자를 호출하려면 p에 const를 추가해서 인수를 복사 생성자의 매개변수 형식과 부합시켜야 한다. 그러나 인스턴스화된 템플릿은 이미 부합하기 때문에 템플릿에서 인스턴스화된 중복적재를 호출하게 된다. 즉, Person의 비 const 왼값의 '복사'를 복사 생성자가 아닌 완벽 생성 생성자가 처리하게 된다.

하지만 복사할 객체가 const가 되도록 바꾸면 정상적으로 복사 생성자를 호출하게 된다.

const Person cp("Nancy");	// 객체가 const
auto cloneOfP(p);		// 복사 생성자 호출

// 이 경우에 생성되는 인스턴스
class Person{
public:
  explicit Person(const Person& n);	// 템플릿에서 인스턴스화 됨
  Person(const Person& rhs);		// 자동 생성된 복사 생성자
  ...
};

이때 생성되는 인스턴스도 함수 호출에서 템플릿 인스턴스와 비템플릿 함수에 똑같이 부합한다면 비템플릿 함수를 우선시하기 때문에 같은 같은 서명을 갖게되더라도 복사 생성자가 선택된다.


상속이 관여할 경우 좀 더 복잡해진다.

class SpecialPerson: public Person{
public:
  SpecialPerson(const SpecialPerson& rhs)	// 복사 생성자, base class의 완벽 전달 생성자 호출
  : Person(rhs)
  {...}
  
  SpecialPerson(SpecialPerson&& rhs)		// 이동 생성자, base class의 완벽 전달 생성자 호출
  : Person(std::move(rhs))
  {...}
};

파생 클래스 함수들이 SpecialPerson 형식의 인수를 base class에 넘겨주게 되고, SpecialPerson을 받는 std::string 생성자가 없어 코드가 컴파일이 되지 않는다.


Conclusion

- 보편 참조에 대한 중복적재는 거의 항상 보편 참조 중복 적재 버전이 예상보다 자주 호출되는 상황으로 이어진다.

- 완벽 전달 생성자들은 문제가 더 많다. 그런 생성자들은 대체로 비const 왼값에 대한  복사 생성자보다 더 나은 부합이며, 기반 클래스 복사 및 이동 생성자들에 대한 파생 클래스의 호출들을 가로챌 수 있다.


Reference

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

반응형

댓글