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

Modern Effective C++ item 28 : reference collapsing

by 김짱쌤 2015. 5. 13.

reference collapsing 이해하기

item 23 에서 template parameter로 들어오는 타입이 lvalue인가, rvalue인가에 따라서 어떻게 템플릿 타입이 추론되는 지를 이야기 했다. 하지만 정확히 어떻게 universial reference가 초기화되는지는 이야기 하지 못했다. 이번 장에서 다루는 reference collapsing을 이해하면 universial reference의 초기화 방식에 대해서 정확히 알 수 있을 것이다.

template 타입 추론에서 reference collapsing

template<typename T>
void func(T&& param);

템플릿 인자 T의 타입 추론은 들어온 인자 param이 lvalue인지 rvalue인지에 따라서 결정된다. 이때 사용되는 결정방법은 매우 간단하다. lvalue가 인자로 들어오면 T는 T&로 추론되고, rvalue가 인자로 들어오면 T는 참조 없이 그냥 T로 추론된다. 예제를 보면서 더 쉽게 이해해보자.

Widget widgetFactory();

Widget w;

func(w); //w는 lvalue이므로 T는 Widget&로 추론

func(widgetFactory()); // widgetFactory의 리턴값은 rvalue이므로 T는 Widget으로 추론

이 추론 규칙이 universial reference와 std::forward의 토대가 된다. 앞으로 이야기 할 것이므로 잘 기억해두고 지나가자 그 이야기를 하기전에 미리 이해해야하는 부분이 있다. 그 시작은 이러하다. C++에서 참조에대한 참조는 허용되지 않는다.

int x;
...
auto& & rx = x; //error! 참조에 대한 참조를 선언할 수 없다.

이렇게 보면 이상한 부분이 떠오르지 않은가? universial reference를 사용하던 숱한 함수들과 맨처음 말했던 T의 추론규칙을 생각해보라. lvalue가 T의 인자로 오게되면 추론을 통해서 T&로 추론된다고 하였다. 그러면 위의 예제에서 보았던 func이 universial reference를 사용하는 템플릿 함수라고 가정하면 이런 이상한 인스턴싱이 발생한다.

template<typename T>
void func(T&& param);

func(w); //인스턴싱 되면 아래의 함수로

void func(Widget& && param);  //참조에 대한 참조??

하지만 컴파일러는 에러를 내뱉지 않는다. item 24에서 봤던 내용을 생각해보면, lvalue로 초기화된 universial reference는 lvalue reference가 된다. 그러면 어떻게 컴파일러는 위의 함수 인스턴스를 아래의 함수의 형태로 변경하는 것인가?

void func(Widget& param)

그 답이 바로 reference collapsing이다. 컴파일러가 참조에 대한 참조를 금지한다고 이야기 했지만, template 인스턴싱과 같은 특정한 상황에서 컴파일러는 그것을 금지하는 대신 reference collapsing이라는 추가 작업을 수행한다.

reference의 종류는 2가지 (lvalue, rvalue) 이다. 따라서 가능한 참조에 대한 참조의 조합은 총 4가지 경우가 있다. 그 모든 경우에 대해서 컴파일러는 다음의 룰에 따라서 reference collapsing을 진행한다.

TYPE referenceCollapsing(TYPE A, TYPE B){
    if( A == LVALUE_REF || B == LVALUE_REF )
        return LVALUE_REF;
    else 
        return RVALUE_REF;
}

이 룰에 따라서 위의 예제 void func(Widget& && param)에서 발생한 reference collapsing을 생각해보자. lvalue reference 와 rvalue reference가 만나서 lvalue reference가 된다. 따라서 void func(Widget& param)이라는 인스턴싱을 하게 된 것이다.

std::forward 에서 reference collapsing

reference collapsing은 std::forward의 구현에서도 핵심이 된다. item 25에서 말한것처럼 std::forward는 universial reference에 적용되기 때문에 일반적으로 다음과 같이 사용할 수 있을 것이다.

template<typename T>
void f(T&& param){
    ...
    someFunc(std::forward<T>(param));
}

배운대로 천천히 생각해보자. 우선 T는 universial reference인 param이 lvalue로 초기화 되느냐, 아니면 rvalue로 초기화 되느냐에 따라서 각각 lvalue reference / lvalue로 추론된다. 그려면 T&&인 universial reference의 타입은 T가 lvalue reference인 경우 reference collapsing규칙에 따라서 각각 lvalue reference가 될 것이고, T가 lvalue인 경우(초기화 인자가 rvalue인 경우) 그대로 rvalue reference가 될 것이다.

이제 std::forward는 들어온 universial reference가 추론된 형태 그대로를 다른 함수에게 전달해줘야한다. 위에서 언급한 T와 universial reference의 관계를 생각해보면, universial reference가 Widget& 인 경우 T는 Widget& 이고, std::forward<T>std::forward<Widget&>로 인스턴싱 될 것이다. 그리고 universial reference가 Widget&&인 경우 T는 Widget이고 std::forward<T>는 std::forward<Widget>로 인스턴싱 될 것이다. 여기까지의 전제를 활용하여 std::forward의 목적을 이루는 구현의 심플한 버전은 다음과 같다.

template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{ return static_cast<T&&>(param); }

이 함수 그대로 universial reference가 Widget&인 경우, std::forward<Widget&>인 상태의 인스턴싱으로 다음과 같은 함수가 될 것이다.

Widget& && forward(typename remove_reference<Widget&>::type& param)
{ return static_cast<Widget& &&> (param); }

아직 남은 인스턴싱 규칙들이 있다. type_trait인 remove_reference<T>::type은 T의 참조를 제거한다. 따라서 remove_reference<T>::type&인 param은 Widget&가 된다. 그리고Widget& &&는 reference collapsing규칙을 통해 Widget&이 된다. 따라서 결과적으로 forward함수는 다음과 같이 인스턴싱 될 것이다.

Widget& forward(Widget& param)
{ return static_cast<Widget&> (param); }

universial reference가 Widget&인 경우 std::forward는 결과적으로 param을 Widget&로 캐스팅하여 전달한다. 이는 목표에 맞는 동작이다. 이제 universial reference가 Widget&&인 경우에도 같은 방식으로 적용해보자. 이때 T는 Widget이므로 std::forward<T>std::forward<Widget>으로 인스턴싱 될 것이다.

Widget&& forward(typename remove_reference<Widget>::type& param)
{ return static_cast<Widget&&> (param); }

여기서는 참조에 대한 참조가 발생하지 않으므로 remove_reference만 적용하면

Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }

결과적으로 universial reference가 rvalue reference인 경우 그대로 rvalue reference로 캐스팅하여 전달하게 되었다. 이것은 우리가 목표한 동작 그대로이며 universial reference가 어떤 참조이건간에 모두 그 형태 그대로를 전달했다는 것을 알 수 있다.

auto에서 reference collapsing

Reference collapsing은 4가지 상황에서 발동한다. 첫번째는 앞에서 봤던 템플릿 인스턴싱이고, 두번째가 auto 변수를 생성할 때이다. auto의 타입추론방식이 template의 T의 추론방식과 거의(item 2 참고) 같기 때문에 세부사항까지 template의 경우와 일치한다.

Widget widgetFactory();
Widget w;

auto&& w1 = w; //Widget& && w1 = w; => Widget& w1 = w;
auto&& w2 = widgetFactory();//Widget&& w2 = widgetFactory();

여기까지보면 이제 universial reference의 실체가 보인다. 얘는 뭔가 새로운 차원의 reference인것은 아니고, 그냥 rvalue reference인데, 템플릿으로 추론 + reference collapsing에 의하여 초기화하는 인자의 값에 따라서 결과적으로 각각 lvalue reference와 rvalue reference로 인스턴싱 되는 것이다.

reference collapsing이 적용되는 다른 경우

이제 reference collapsing이 발생하는 다른 두가지 상황을 이야기 해야겠지... 세번쨰는 typedef과 type aliasing(using)을 사용했을 경우이다. 템플릿 클래스 Widget의 예를 보면 잘 이해할 수 있다.

template<typename T>
class Widget
{
public:
    typedef T&& RvalueRefToT;
};

Widget<int&> w; //int&로 인스턴싱

이때 RvalueRefToT 타입은 T&&형태가 되어야 하는데 들어온 T가 int&이므로RvalueRefTOT는 reference collapsing에 의하여 int가 된다.

마지막 경우는 decltype이다. decltype은 변수의 타입을 받아올 수 있는데, 이 타입이 참조형일 수 있다. 그리고 decltype ( variable ) & 이런 식으로 타입을 새로 지정할 수 있으므로, 이 경우 참조에 대한 참조가 발생한다. 이때 컴파일러는 에러를 내지 않고, reference collapsing을 수행해준다. 


이렇듯 reference collapsing은 템플릿의 중요한 기능들을 구현하기위해 반드시 필요한 핵심 요소이다. reference collapsing을 이해하면서 universial reference와 std::forward등의 구체적인 구현방식들을 잘알 수 있었다.