본문 바로가기

3.구현/C or C++

[c++] 보편참조와 오버로딩

들어가기

보편참조는 왼쪽 참조와 오른쪽 참조를 모두 받는 참조이다. 이런 보편참조는 보통 템플릿 함수를 사용해서 구현한다. 단순히 모든 참조만 받는 용도로만 쓰면 유용하지만, 그렇지 않는 상황이 발생한다. 보편참조를 사용할 때 문제점을 알아보고 이를 해결하는 방법을 살펴보자.

작성자: ospace114@empal.com, http://ospace.tistory.com/

보편참조

사용자 이름을 목록에 추가하는 아주 일반적인 함수가 있다.

std::multiset<std::string> names;
void addName(const std::string& name) {
    names.emplace(name);
}

함수 자체는 크게 문제가 없지만 비효율성이 있다. 다음 실행 예를 보자.

std::string myName("Foo");
addName(myName);             // (1)
addName(std::string("Bar")); // (2)
addName("Fox");              // (3)

(1)은 petName을 왼값으로 인수로 넘겨서 호출되며 names에 추가될 때에 복사가 된다.

(2)는 객체가 새로 생성되어 오른값으로 넘겨지지만 name에는 왼값으로 묶인다. 그리고 names에 추가될 때에 복사된다.

(3)은 문자열이 넘겨지면 임시 std::string 객체로 묶이면서 오른값으로 넘겨지지만 name에는 왼값으로 묶인다. 그리고 names에 추가될 때에 복사된다.

결국 종리하면 (1)은 1번 복사 비용이지만, (2)와 (3)은 3번 복사 비용이 발생한다. (2)와 (3)은 오른값으로 넘겨지므로 이를 기대로 이동 연산으로 처리하면 비용이 줄어들거라 예상이 된다. 이때에 보편참조를 사용해서 왼값 뿐만 아니라 오른값도 받을 수 있도록 만들 수 있다.

std::multiset<std::string> names;
template<typename T>
void addName(T&& name) {
    names.emplace(std::forward<T>(name));
}

보편참조에서 마지막에 std::forward까지 함으로써 emplace()까지 왼값 또는 오른값 정보를 넘겨준다.

(2)에서 std::string 객체가 오른값 참조로 넘어오고 emplace()에 이동 연산으로 처리되므로 복사 1회와 이동 1회가 발생한다.

(3)에서는 const char*으로 바로 넘어오고 empalce()로 문자열이 그대로 넘어오면서 바로 std::string이 생성되고 추가된다. 결국 복사1회가 발생한다.

보편참조를 사용해서 더 효율적인 함수를 만들 수 있다.

보편참조에서 한발짝 더~

문자열을 추가하는 작업은 문제가 없다. 이번에는 idx같은 색인을 이용해 이름을 찾아서 추가하는 기능도 지원하려고 한다. idx로 이름을 찾는 함수인 nameFromIdx()가 있다고 하면 아래 처럼 오버로딩으로 함수를 정의할 수 있다.

template<typename T>
void addName(T&& name);

std::string nameFromIdx(int idx);

void addName(int idx) {
    names.emplace(nameFromIdx(idx));
}

생각대로 잘 작동한다. 이제 색인으로도 이름을 추가할 수 있게 되었다. 그런데 다음 예제를 살펴보자.

short idxName = 10;
addName(idxName); // 에러

에러가 발생하는데 이유를 알 수 없다. 당연하게 void addName(int) 함수를 호출할 줄 알았는데 그렇지 않다. unsinged int에서 int으로 묵시적 형변환 작업인 프로모션(promotion)이 되어야 하는데 에러가 발생한다. C++에서는 오버로딩 해소 규칙에 의하면 정확한 부합이 프로모션보다 우선된다. 그렇기 때문에 보편참조 함수가 더 정확한 부합이 되어 호출되기 때문에 에러가 발생한다.

즉, 조금이라도 어긋난 호출은 모두 보편참조 템플릿 함수가 호출된다. 오버로딩으로 정의된 함수는 정확하게 호출하지 않으면 호출이 안된다.

보편참조에서 두발짝 더~

이런 오버로딩 문제를 해결하고자 클래스에서 완벽 전달 생성자를 도입할려고 할 수 있다.

class Who {
public:
    template<typename T>
    Who(T&& n) : name(std::forward<T>(n)) {}
    Who(int idx) : name(nameFromIdx(idx)) {}
private:
    std::string value;
};

std::multiset<Who> names;
template<typename T>
void addName(T&& name) {
    names.emplace(Who(name));
}

클래스 내에 보편참조 템플릿 함수를 추가하는 완벽 전달 생성자를 생성한다. 이렇게 하면 addName()의 오버로딩 함수가 없어지면서 문제가 사라질거라 생각할 수 있다.

그러나 이 또한 앞에 문제와 동일한 문제가 발생한다. short으로 Who를 생성하면 int형 생성자를 호출하는게 아니라 보편참조로된 완벽 전달 생성자를 호출한다. 더 큰 문제는 복사 생성자와 이동 생성자에 대해서도 고려해야 한다. 복사 생성자와 이동 생성자에 대한 호출도 완벽 전달 생성자로 호출된다. 만약 상속이 되는 구조라면 이들 간에 영향으로 인한 문제는 더 복잡해진다.

결국 이 방법도 좋은 해결책이 되지 못한다.

보편참조와 오버로딩 사용 가이드

오버로딩 회피

오버로딩을 회피하는 방법은 몇가지가 있다. 아에 오버로딩하지 않도록 구조를 설계하는 방법이 있고, 오버로딩이 필요하면 이름을 다르게 정의하는 방법이 있다. 예를 들어 addName()과 addNameByIdx()로 나누면 된다. 클래스 구조에서는 생성자로는 이름을 변경할 수 없기에 팩토리 함수를 사용하는 방법도 있다.

const T& 인자를 사용

보편참조가 왼값으로 한정한다. 이는 처음 정의했던 “const std::string&”인자와 동일한 형태이다. 이는 조금 비효율적이지만 이슈를 회피하는 절충안이다.

값 전달 방식 인자를 사용

참조 대신에 값 전달 방식을 사용한다. 어차피 내부적으로 복사가 될 거라면 외부에서 전달 받을 때 복사를 하고 내부적으로 이동 연산으로 처리하는 방법이다. 이는 보편참조 템플릿 함수나 완벽 전달 생성자를 없애는 방법이다.

class Who {
public:
    Who(std::string n) : name(std::move(n)) {}
    Who(int idx) : name(nameFromIdx(idx)) {}
private:
    std::string name;
};

이제 short 인수로 생성하면 제대로된 idx로된 생성자를 호출한다.

태그 디스패치(tag dispatch)

보편참조와 오버로딩 함수를 최대한 활용하는 방법으로 태그 디스패치로 함수 뒤에 태그에 해당하는 인자를 추가하해서 태그에 따라 해당 함수로 디스패치하여 호출하는 방법이다.

template<typename T>
void addName(T&& name) {
  addNameImpl(
    std::forward<T>(name),
    std::is_integral<typename std::remove_reference<T>::type>()
  );
}

template<typename T>
void addNameImpl(T&& name, std::false_type) {
    names.emplace(std::forward<T>(name));
}

void addNameImpl(int idx, std::true_type) {
    addName(nameFromIdx(idx));
}

addNameImpl()의 마지막 인자가 이름이 없는 형태로 std::true_type과 false_type으로 되어 있다. 이는 태그로서 런타임에는 사용되지 않고 컴파일 타임에 호출 함수를 디스패치하는데 사용된다. 어떤 컴파일러에서는 이를 제거하는 경우도 있다.

std::id_integral()에 의해 인자가 정수계열 형식이지를 판단하여 각 인자에 부합하는 형식의 함수를 호출한다. 정수 계열이 아니면(false_type) 보편참조 템플릿 함수를 정수 계열이면(true_type) int형 인자를 가지는 함수를 호출한다.

보편참조 템플릿 함수 제한

태그 디스패칭은 보편참조 템플릿 함수에서 유용하지만 클래스에 완벽 전달 생성자에서는 잠재적 위험성이 있다. 여전히 완벽 전달 생성자가 있기 때문에 특수 멤버 함수 생성 및 호출에 영향을 미친다. std::enable_if으로 완벽 전달 생성자에 인자에 해당하는 범위를 제한할 수 있다. 다음과 같이 정의할 수 있다.

class Who {
public:
  template<typename T,
    typename = typename std::enable_if<조건>::type>
    Who(T&& n);
  //...
};

위의 “조건”에 원하는 형식을 추가하면 된다. 이를 위해 형식이 맞는지 확인위해 std::is_same으로 판정한다. 예를 들어 T는 Who가 아닌 경우는 “!std::is_same<Who, T>::value”가 된다. 추가로 고려할 부분이 보편참조가 왼쪽 참조인경우 Who&가 되기에 이 부분도 조건에 추가해야 한다. 아래는 추가로 고려해야할 사항이다.

  • 참조 종류: Who, Who&, Who&&은 같은 형식으로 처리
  • const와 volatile: const Who, volatile Who, const volatile Who도 같은 형식으로 처리

std::decay를 사용해 참조, 한정사 같은 특질을 제거할 수 있다. 아래 코드는 위의 모든 조건을 고려한 결과이다.

class Who {
public:
  template<typename T,
    typename = typename std::enable_if<
      !std::is_same<Who, typename std::decay<T>::type>::value
    >::type>
    Who(T&& n);
  //...
};

점점 머리 아파지고 있다. 아직 끝이 아니다. 추가로 고려할 부분으로 파생 클래스가 있다. 파생 클래스도 생성자 인자로 넘겨질 수 있다. Who은 자신의 파생 클래스가 있을 있다고 가정하고 해당 파생 클래스도 조건에 포함해야 한다. 이때에 std::is_base_of를 사용해 파생된 형식인지 여부를 판단할 수 있다.

class Who {
public:
  template<typename T,
    typename = typename std::enable_if<
      !std::is_base_of<Who, typename std::decay<T>::type>::value
    >::type>
    Who(T&& n);
  //...
};

다행이 마지막은 is_same을 is_base_of 변경만으로 해결했다. C++14라면 좀더 단순하게 표현이 가능하다. 마지막으로 정수형 인자에 대해서만 고려하면 된다.

class Who {
public:
  template<typename T,
    typename = typename std::enable_if<
      !std::is_base_of<Who, typename std::decay<T>::type>::value
      &&
      !std::is_integral<typename std::remove_reference<T>::type>::value
    >::type>
    Who(T&& n);
  //...
};

완벽 전달 생성자를 사용하여 보편참조와 오버로딩을 완벽하게 제어하고 있다. 그러나 모든걸 예측하지 못하거나 누락된 부분이 생긴다면 어떤 문제가 발생할지 예상할 수 없다. 또한 디버깅도 쉽지 않다.

보안점

완변전달이 더 효율적이라고 할 수 있지만, 에러 발생시 장황한 오류 메시지가 출력된다. 난해한 메시지 분석보다 이전에 static_assert로 미리 검증할 수 있는 방법이 있다. 그리고, std::is_constructible을 사용해 어떤 형식 객체가 다른 형식 객체로부터 생성가능한지 컴파일 타임에 확인할 수 있다. 이 두가지를 확인해서 미리 검증해보자.

class Who {
public:
  template<typename T,
    typename = typename std::enable_if<
      !std::is_base_of<Who, typename std::decay<T>::type>::value
      &&
      !std::is_integral<typename std::remove_reference<T>::type>::value
    >::type>
    Who(T&& n) : name(std::forward<T>(n)) {
    static_assert(
      std::is_construtible<std::string, T>::value,
      "Parameter n cannot be used to construct a std::string"
    );
  }
  //...
};

“std::is_construtible<std::string, T>::value”에 의해 T를 사용해서 std::string을 생성할 수 있다면 true가 되고 안되면 false가 된다. 그리고 static_assert()에 의해 false가 되면 에러가 발생하게 된다.

마무리

보편참조도 이해하기 쉽지 않은데 보편참조와 오버로딩에 의해 더 복잡해졌다. 근데 클래스에서까지 쓰면 오버로딩 뿐만 아니라 오버라이딩까지 신경써야하는 복잡한이 생겼다. 어쩌면 에러가 발생할 요인들이 더 많아졌다고 볼 수 있다. 정말 신중하게 엄격하고 최대한 단순하게 설계해야한다. 그렇지 않으면 나중에 문제가 발생할 경우 해결하기가 쉽지가 않을거라 생각이 든다.

보편참조에 대한 중복적재에 대한 개념이 쉽게 이해가 되지 않았다. 그리고, 예제가 특히 난해했다. 최대한 이해하기 쉽고 예제도 최대한 단순하게 작성했다. 그러면서 제가 잘못 이해하거나 누락될 수도 있네요.

부족한 글이지만 여러분에게 도움이 되었으면 합니다. 즐거운 코딩생활되세요. ospace.

참조

[1] 스콧 마이어스 저, 류광 역, Effective Modern C++, 인사이트

반응형