본문 바로가기
컴퓨터/Modern Effective C++ 정리

Modern Effective C++ item 27 : Universal Reference 오버로딩의 대안들

by 김짱쌤 2015. 5. 6.

Universal Reference 오버로딩의 대안들

item 26에서 universal reference를 오버로딩하는 것의 문제점에 대해서 이야기했다. 하지만 universal 오버로딩 방식의 장점도 분명하다. 이 장에서는 그런 상황에 처했을때, 어떻게 곁가지 문제들을 회피하면서 원하는 기능을 수행할 수 있는지를 이야기 할 것이다.

오버로딩 금지

item 26의 logAndAdd 예시를 생각해보자. universal reference의 강력한 포용력(?)때문에 다른 오버로딩들이 씹혀버리는 불상사가 발생하였다. 그렇다면 그냥 오버로딩을 포기하는게 속편할 수 있다. logAndAddName 과 logAndAddNameIdx 같은 느낌의 이름으로 바꾸면 일차적으로 해결은 된다. 그런데 생성자는 어떻게 이름을 바꿀 것인가????(노답)

const T& 로 전달한다.

c++98로 회귀해버리는 것이 또다른 방법이 될 수 있다. universal reference는 없는 걸로 치고 const T& 전달을 사용하는 것이다. 이번 챕터의 전반에 걸쳐서 universal reference전달의 효용에 대해서 이야기 했지만, 그것들을 포기할 수만 있다면 const T&로 전달하는 오버로딩을 사용해도 괜찮을 거다.

값으로 전달한다.

이상하게 들릴지 모르겠지만, 복잡한 참조대신 값으로 전달해도 성능 저하가 발생하지 않는 방법이 있다. item 41의 조언에 따르면 값 복사가 발생한다는 것을 알면 그냥 값으로 전달하면 된다고 한다. 자세한 내용은 41에서 보고 여기서는 Person 예시에서 어떻게 값 전달 방식을 구현하는지만 살짝 보여준다.

class Person
{
public:
    explicit Person(std::string n)
    : name(std::move(n)) { }

    explicit Person(int idx)
    : name(nameFromIdx(idx)) { }

private:
    std::string name;
};

int관련 인자들과 string 관련 인자들을 직접 명시하여 받아줄 수 있는 생성자들을 오버로딩 하였다. NULL 넣으면 어떻게 하지? 라고 생각하신분은 item 8의 nullptr을 다시 공부하시길.

Tag dispatch 를 사용하자.

const T& 이건 값에의한 전달이건 모두다 perfect forwarding을 사용할 수 없다. 어떻게든 universal reference를 써서 성능을 향상시키고 싶다면 item 26에서 말한 문제들을 회피해야한다. Tag Dispatch는 그런 욕망을 충족시켜줄 좋은 방법이 될 것이다.

오버로딩 함수는 호출하는 시점에 어떤 것을 선택할지 결정된다. universal reference는 모든 인자들을 받아서 사용할 수 있기 때문에 다른 함수의 오버로딩까지 받아버리는 문제가 발생한다. 따라서 전달받는 인자를 하나 추가해서 그것을 적합한 매칭을 유도하는 태그로 사용하자는 것이 tag dispatch 해결법의 요지이다. item 26의 logAndAdd 예제를 보면서 다시 이야기 해보자.

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

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

이 예제의 문제점은 많지만 우리가 해결하고 싶은 가장 큰 문제는 int가 인자로 오는 경우 따로 처리를(nameFromIndex) 해주고 싶다는 거다. int 가 들어오는 경우와 그렇지 않은 경우를 나누어서 처리할 수있는 별도의 함수 logAndAddImpl를 각각의 경우에 대해 오버로딩 하여 처리할 것이다.

첫번째 인자로는 universal reference를 forwarding 하여 그 장점을 유지한다. 두번째 인자로는 받은 인자가 int형인지 아닌지를 체크할 수 있는 태그를 사용한다. 이 두번째 인자인 태그가 item 26에서 보았던 문제들을 회피할 수 있게 해준다. 왜냐면 우리는 이걸가지고 뭐가 들어왔는지 체크를 하여 적절한 함수를 오버로딩 해서 쓸 수 있을 거니까!

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(std::forward<T>(name),
                  std::is_integral<T>());
}

이 함수는 일견 그럴듯해 보이지만 문제가 있는 코드다. 인자로 들어온 값이 int의 lvalue 였다면 name은 universal reference의 규칙에 의하여 int& 타입이 될 것이고, 이건 std::is_integral에서 int라고 판단하지 못한다. 그러면 우리는 item 9에서 한번 봤던 std::remove_reference를 사용하여 레퍼런스 없는 타입을 가져다 사용할 수 있다.

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(std::forward<T>(name),
                  std::is_integral< std::remove_reference_t<T> >());
}

자 이제 logAndImpl의 구현을 보자. logAndImpl은 int 타입인 경우와 그렇지 않은 경우로 나누어서 오버로딩 될 것이다. 먼저 int타입인 경우의 오버로딩 함수를 보자.

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

int 가 아닌 경우의 구현은 처음의 logAndAdd 함수와 별반 다를 것이 없다. 우리가 유심히 봐야 할 부분은 std::false_type이라는 인자 타입이다. 개념적으로 우리는 들어온 인자가 int인지 아닌지를 파악해서 서로 다른 오버로딩 함수를 불러오고 싶어했다. 그런데 이게 true인지 false인지를 알려면 직접 값을 확인하는 runtime이 아니고서는 알수가 없다. 하지만 오버로딩할 함수를 선택하는 시점은 컴파일 타임이다. 따라서 true에 해당하는 타입과, false에 해당하는 타입을 TMP의 추론을 통해 받아와야한다. 이 역할을 해 주는 것이 std::true_type 과 std::false_type이다. 위의 logAndAdd에서 std::is_integral이 TMP의 타입추론을 통하여 int 타입인 경우 std::true_type을 아닌 경우 std::false_type을 상속받게된다. 그래서 우리는 logAndAddImpl의 두번째 인자의 타입을 std::true_type과 std::false_type로 나누어서 만들어주기만 하면 된다. 그러면 int가 인자로 들어왔을 경우의 logAndAddImpl은 아래와 같이 만들어 질 것이다.

std::string nameFromIdx(int idx);

void logAndAddImpl(int idx, std::true_type)
{
    logAndAdd(nameFromIdx(idx));
}

들어온 인자값이 int라는 것만 알면 이제 nameFromIdx 함수를 통해서 실제 이름을 가져와서 logAndAdd에 집어 넣어주면 된다. 이런 형태의 구현에서 std::true_type과 std::false_type이 우리가 원하는 실행 분기를 나누는 tag의 역할을 한다. 이 tag는 컴파일 타임에서 타입 추론에만 사용되고 런타임에서 사용하는 인자가 아니므로 이름을 붙일 필요가 없다. 이름을 안써주면 컴파일러가 런타임에 사용하지 않는 인자임을 알고 최적화를 시켜준다고 한다. 새로운 대안함수 logAndAdd는 전달된 인자의 타입을 추론하여 적절한 tag로 변화시킨후 필요한 오버로딩 함수로 dispatch해주고 있다. 따라서 이런 형태의 구조를 Tag Dispatch라는 이름으로 부른다. 이건 TMP에서 매우 자주 사용되는 형태의 구현방식이다. c++ 라이브러리들의 코드를 보면 자주 만나게 될 것이다.

중요한 것은 어떻게 동작하느냐 보다 어떻게 universal reference를 쓰면서도 item 26에서 발생한 문제들을 회피하는지이다. dispatching 함수 logAndAdd는 universal reference를 인자로 받는다. 그리고 tag에 따라 나뉘어진 overloading 실행함수 lodAndAddImpl을 호출한다. tag값(타입)은 들어온 인자의 타입에 따라서 적절히 결정되고, 그 결과 우리는 tag에 맞게 원하는 실행 함수를 호출할 수 있다. 이러한 일련의 과정을 통해 universal reference를 사용하면서도 들어온 인자의 타입에 따라 원하는 함수를 호출 할 수 있게 된다.

universal reference 받는 템플릿함수에 조건을 걸자

tag dispatch의 핵심은 한개의 dispatching 함수를 사용한다는 것이다. 이 특징은 special member function 오버로딩에서는 문제가 된다. special member function은 내가 원하지 않아도 자동 생성될 수 있기 때문에 항상 dispatch함수를 통해 원하는 실행 흐름을 만들 수가 없다. item 26에서 봤던 Person 생성자 예제를 생각해보자. const Person타입을 전달 했을때와 Person타입을 전달했을 때 각각을 받는 생성자가 서로 달랐다. 만들지도 않은 복사생성자가 튀어나와서 맘대로 const Person을 받아갔다. 이때 발생한 진짜 문제는 Person 관련 타입에 대해서 universal reference를 받는 (사실 string을 받고 싶은) 생성자가 동작한다는 점이다. 그럼 우리는 Person 상수형 참조형 기타등등을 받으면 기존의 복사/이동 생성자를 호출시키고, 아니면 universal reference를 쓰는 생성자를 호출하게 하고 싶다. 특정 조건 하에서 함수 템플릿을 동작시키려면 std::enable_if의 도움을 받으면 된다.

std::enable_if는 조건에 따라 템플릿 함수를 활성화(enable) 또는 비활성화 시킬 수 있다. 컴파일러는 비활성화 된 템플릿 함수는 마치 존재하지 않는 것처럼 처리한다. 이제 우리는 조건에다가 전달받은 타입이 Person 관련 타입인지 아닌지만 체크해주면된다.

class Person
{
public:
    template<typename T,
             typename = std::enable_if_t<
                 !std::is_same<Person, T>::value
             >
    explict Person(T&& n);
};

std::is_same을 통해서 들어온 인자의 타입과 Person을 비교하고 있다. 대충 개념은 이렇지만 위 코드는 정확한 코드가 아니다. 앞에서 한번 본 이유지만 T가 Person이랑 완전히 같은 타입이어야만 std::true_type으로 추론될 것이기 때문이다. 우리가 체크해야하는 경우로 참조형/ const / volatile 정도가 있다. 이 모든 부가적인 속성을 떼어버리고 순수한 타입만 남기는 std::decay라는 훌륭한 type_trait가 있다. std::enable_if의 조건만 바꿔주면 된다.

class Person
{
public:
    template<typename T,
             typename = std::enable_if_t<
                !std::is_same<Person, std::decay_t<T>>::value
             >
    explict Person(T&& n);
};

이제 const Person 할아버지가 와도 universal reference 생성자가 호출될 일이 없다. Person문제는 끝난것 처럼 보인다... 하지만...(아 좀 끝내라) 아직 남은 문제가 남아있다. item 26에서도 나왔던 SpecialPerson문제이다.

class SpecialPerson : public Person
{
public:
    SpecialPerson(const SpecialPerson& rhs)
    : Person(rhs)
    { ... }
    SpecialPerson(SpecialPerson&& rhs)
    : Person(std::move(rhs))
    { ... }
};

SpecialPerson은 Person을 상속받는 자식 클래스이다. 각 생성자에서 Person의 생성자를 호출하는데 넘기는 인자가 무려 SpecialPerson 타입이다. 이대로 넘어가면 enable_if조건을 모두 패스하고 universal reference생성자를 호출하게 되어버린다.(ㅜㅜ) 이제 따져야 하는 조건이 좀더 생겼다. T가 Person의 자식타입인지까지 따져주자. 역시나 좋은 type trait std::is_base_of가 있다. std::is_same과는 달리 자식 타입인지 까지 체크하여 true_type/false_type으로 변경된다. 자신과 동일한 타입인 경우에도 true_type이 되므로 is_same을 그냥 대체하여 사용하면 된다.

class Person
{
public:
    template<typename T, 
             typename = std::enable_if_t<
                !std::is_base_of<Person, std::decay_t<T>>::value
             >
    >
     explicit Person(T&& n);
};

자 거의 다왔다.... 진짜 마지막으로 생성자로 int가 오는 경우 처리해줄 별도의 생성자를 만들어주자. universal reference 생성자의 조건에 int타입 추가해주고 int 전용 생성자를 만들어주면된다.

class Person
{
public:
    template<typename T,
             typename = std::enable_if_t<
                !std::is_base_of<Person, std::decay_t<T>>::value &&
                !std::is_integral<std::remove_reference_t<T>>::value
             >
    >
    explicit Person(T&& n)
    : name(std::forward<T>(n)) { ... }

    explicit Person(int idx)
    : name(nameFromIdx(idx)) { ... }
};

지금까지 universal reference의 장점을 살리면서도 문제들을 회피할 수 있는 방법에 대해서 알아보았다. 하지만 이런 함수 오버로딩 방식이 반드시 좋은 것은 아니다.

universal reference 사용의 단점

universal reference를 사용한다는 것은 들어올 인자를 명시하지 않고 generic하게 처리하는 것을 의미한다. 지금까지 같이 따라온 분들은 느끼셨을지 모르겠지만, 나는 분명히 이렇게 짜면 누가 알아보느냐라는 생각이 든다. 지금 universal로 받는 인자가 하나여서 그렇지, 여러개가 된다고 상상해보는 것 조차 두렵다. 반면 앞서 언급한 다른 오버로딩 방식은 타입을 분명하게 명시하여 가독성이 뛰어나지만 perfect forwarding을 사용하지 못하는 것떄문에 발생하는 문제들이 있다.

하지만 몇몇 인자들의 경우 universal reference를 사용해도 perfect forwarding 될 수 없는 문제가 있다. 이때 인자 타입을 명시하면 충분히 전달이 가능하다. 그러니까 universal reference는 만능은 아닌거다. 이 경우에 대해서는 item 30에 자세하게 다룰 예정이라고 한다.

인자 전달하는 동안에 에러가 발생하는 경우, 이때 generic한 템플릿 함수를 사용하는 universal reference 버전의 오버로딩 함수들은 정말 긴~~~~~~ 에러 메시지를 내뱉는다. 템플릿을 사용하여 프로그래밍을 해본 사람들은 알거다. 나는 에러메시지를 이해하는데에 정말 많은 시간이 필요했다. 이것또한 분명한 단점이라 할 수 있다. 인자를 분명히 명시한 함수들은 템플릿 함수를 쓸 필요가 없기 때문에 간결한 에러가 발생할 것이다.

물론 이것에 대한 해결책(미봉책)이 존재한다. 바로 에러메시지를 포함한 static_assert를 넣어주는 거다. 위 예제에서 universal reference로 string변환이 불가능한 녀석이 담겨져 오는 상황을 가정해보자. static_assert로 변환 가능한지를 체크해주는 type trait std::is_constructible을 사용해주면 된다.

class Person
{
public:
    template<typename T,
             typename = std::enable_if_t<
                !std::is_base_of<Person, std::decay_t<T>>::value &&
                !std::is_integral<std::remove_reference_t<T>>::value
             >
    >
    explicit Person(T&& n)
    : name(std::forward<T>(n)) { 
             static_assert(
                std::is_constructible<std::string, T>::value,
                "인자 n은 std::string을 생성하는데 사용될 수 없습니다."
             );
      }

    explicit Person(int idx)
    : name(nameFromIdx(idx)) { ... }
};

좀더 알기 쉽고 분명한 에러메시지가 나와줘서 다행이다. 물론 초기화 리스트의 std::forward가 발동한 뒤에 static_assert가 터지기 때문에 길고 긴 에러뒤에 달리겠지만... 여기까지 universal reference 오버로딩 대안들을 읽어주신 여러분 감사합니다.