type&& 이슈의 핵심은 때로는 rvalue reference를 의미하나, 때로는 rvalue 또는 lvalue reference로 해석된다는 것이다. 분명히 구문상 생긴 것은 rvalue reference인 것 같은데, 실제 의미는 lvalue reference일 수 있다는 것이다. 이렇게 특이한 유연성을 가지는 reference를 가리켜, universal reference라고 한다.
type&&이 universal reference를 가리킬 때의 세부 사항들은 상당히 tricky 하기에, 전반적인 세부 설명은 일단 뒤로 미루겠다. (3. Reference collapsing 챕터 참고) 지금은 당장 알아야 되는 규칙들부터 살펴보기로 하자.
변수나 인자가 type deduction(추론)이 필요하며, 정확히 "T&&"로 선언되면, 해당 변수나 인자는 universal reference이다.
변수가 타입 추론이 필요한 경우는 auto로 선언된 변수이고, 인자가 타입 추론이 필요한 경우는 템플릿 함수의 인자인 경우다. 참고로, 위 둘의 타입 추론 규칙은 완벽하게 동일하며, 템플릿 함수의 인자로 훨씬 많이 사용된다.
후반부의 3. Reference collapsing 챕터에서는 universal reference를 typedef와 decltype과 함께 사용하는 것에 대해 설명할 것이다. 하지만, 그 이전에는 universal reference가 템플릿 함수 인자나 auto 변수로 사용되는 것에 한정해서 설명을 진행해 나가겠다.
2-1. auto&&
모든 레퍼런스와 마찬가지로 universal reference 역시 반드시 초기화가 되어야 하며, universal reference는 그것이 rvalue reference인지 lvalue reference인지 결정하는 initializer가 존재한다.
Universal reference는 추론될 타입이 rvalue reference인가? lvalue reference인가?에 따라 &&를 해석한다.
다음의 예제를 살펴보자.
Widget&& var1 = someWidget; auto&& var2 = var1;
위 예제에서 var1의 주소를 알 수 있으므로, var1 자체(변수 그 자체)는 lvalue이다. var2의 타입은 auto&&로 선언되었기에 universal reference이고, var1이 lvalue이므로 var2는 lvalue reference로 해석된다. 아무 생각없이 코드를 읽다보면, var2가 rvalue reference라고 읽을 수 있는 함정이 숨어 있는 것이다.
즉, 위 예제의 2라인은 다음과 동일하다 할 수 있다.
또다른 예제를 하나 살펴보도록 하자.
std::vector<int> v; auto&& val = v[0];
val은 auto&&로 선언되어 universal reference이고, std::vector<int>::operator[]의 결과인 v[0]으로 초기화 되었다. std::vector<T>::operator [] 는 lvalue를 반환하므로, val 역시 lvalue reference이다.
2-2. T&& as template function argument
앞서 universal reference는 템플릿 함수 인자로써 훨씬 더 많이 사용된다고 하였다. 위의 템플릿 함수 예제를 한번 더 살펴보자.
template <typename T> void f(T&& param); // 주소를 취할 수 없는 literal 10은 rvalue // 따라서, param은 rvalue reference (int&&) f(10); // 주소를 취할 수 있는 x는 lvalue // 따라서, param은 lvalue reference (int&) int x = 10; f(x);
위 예제의 f 함수는 무엇이 인자로 넘어오는지에 따라, param은 rvalue reference가 될 수도, lvalue reference가 될 수도 있다. 이럴 경우 param이 universal reference인 것이다.
"&&"이 universal reference를 의미하는 경우는 타입 추론이 필요한 경우만이라는 것을 잊지 말아야 한다. 이 말을 뒤집으면, 타입 추론이 아닌 명시가 되어 있는 경우에는 "&&"는 늘 rvalue reference를 의미한다는 소리다.
template <typename T> void f(T&& param); // T에 대한 타입 추론이 필요. && ≡ universal reference template <typename T> class Widget { ... Widget(Widget&& rhs); // 명시적인 Widget 타입. && ≡ rvalue reference }; template <typename T1> class Gadget { ... template <typename T2> Gadget(T2&& rhs); // T2에 대한 타입 추론이 필요. && ≡ universal reference }; void f(Widget&& param); // 명시적인 Widget 타입. && ≡ rvalue reference
지금까지의 내용을 제대로 이해했다면, 위 예제는 전혀 의문을 품을 것이 없다. 타입을 추론해야 하는 경우 &&는 universal reference이고, 타입이 명시적인 경우엔 &&는 rvalue reference인 것이다.
2-3. 혼동하기 쉬운 경우들
그러면, 앞서 나왔던 다음의 예제는 어떻게 될까?
template <typename T> void f(std::vector<T>&& param); // rvalue ref
위 f 함수의 인자는 추론해야 할 타입 T도 있고, "&&"도 붙어있다. 하지만, T&&가 아닌 std::vector<T>&&이므로 이 녀석의 타입은 명시적으로 std::vector<T>에 대한 &&이다. 따라서, universal reference가 아닌 일반적인 rvalue reference인 것이다.
Universal reference가 템플릿 함수 인자로 사용될 때엔 반드시 "T&&"의 형태여야 한다. 여기에 const 한정자만 가져다 붙여도 "&&"는 universal reference로 해석되지 않는다.
template <typename T> void f(const T&& param); // rvalue ref
또한, T가 템플릿 인자이고, 템플릿 함수 인자가 T&&여도 T에 대한 타입 추론이 발생하지 않는 경우도 있다.
template <typename T, type Allocator = allocator<T> > class vector { public: ... void push_back(T&& x); // 이미 T의 타입을 알기에, 타입 추론이 발생하지 않는다. && ≡ rvalue reference };
위 예제에서 T는 템플릿 인자이고, push_back 함수는 T&&를 함수 인자로 받는다. 하지만, push_back 함수는 std::vector의 객체가 존재하지 않으면 호출될 수 없다.
즉, std::vector<T>의 객체가 생성되기 위해, instantiate 되는 과정에서 T는 이미 타입 추론이 완료되었기에, push_back이 호출되는 시점에서 다시 T에 대한 타입 추론을 할 필요가 없는 것이다. 따라서, push_back의 "T&&"는 universal reference가 아닌, rvalue reference 이다.
보충 설명이 될 수 있는 예제를 하나 더 살펴보자.
// Widget 객체를 생성하는 팩토리 함수 Widget makeWidget(); // Widget 벡터 생성 std::vector<Widget> vw; Widget w; // 이 함수를 풀어쓰면 다음과 같다. // std::vector<Widget>(Widget&& w); // 이미 이 시점에서 Widget 타입이라는 것이 명시되어 있으므로, 타입 추론할 필요가 없다. vw.push_back(makeWidget());
push_back과 대조적으로 emplace_back의 경우는 다음과 같이 정의되어 있다.
template <typename T, typename Allocator = allocator<T> > class vector { public: ... template <class... Args> void emplace_back(Args&&... args); // 각 Args들의 타입 추론이 필요. && ≡ universal references };
emplace_back 함수는 variadic template 함수로 구현되어 있어, 가변수의 인자를 받는다. 따라서, 가변수의 개별 인자 타입들은 모두 타입 추론이 필요하다. 다음처럼 외부 함수 선언을 해보면, 이해가 더 쉽게 될 것이다.
template<class... Args> void std::vector<Widget>::emplace_back(Args&&... args);
결과적으로 emplace_back의 모든 인자들은 모두 universal reference인 것이다. push_back의 인자가 명백히 rvalue reference였던 것과는 대조적이다.
2-4. lvalueness or rvalueness
마지막으로 기억해야 할 중요 포인트는 표현식이 lvalueness 또는 rvalueness를 가지느냐는 그것의 타입에 의존적이라는 것이다.
int 타입이 변수로 선언되면 lvalue이지만, literal 값이면 rvalue이다. Widget 타입이 변수로 선언되면 lvalue이지만, 팩토리 함수등이 반환한 임시값이면 rvalue이다.
표현식이 lvalue냐 rvalue냐는 그것의 타입에 의존적이기 때문에, 타입은 rvalue reference이지만, 표현식 자체(변수일 경우 해당 변수값)는 lvalue 또는 rvalue가 될 수 있다.
// Widget을 생성하는 팩토리 함수 Widget makeWidget(); // var1의 타입은 rvalue reference이지만, var1 자체는 lvalue Widget&& var1 = makeWidget(); // 여기에서의 var1은 lvalue // var2의 타입은 rvalue reference이지만, var2 자체는 lvalue Widget&& var2 = std::move(var1);
rvalue reference 타입이지만, 이름이 붙어 있는 변수나 인자들은 주소를 획득할 수 있기에 lvalue 들이다. 다음의 예제를 다시 한번 살펴보도록 하자.
template <typename T> class Widget { ... Widget(Widget&& rhs); // rhs의 타입은 rvalue reference, ... // 하지만, rhs 자체는 lvalue }; template<typename T1> class Gadget { ... template <typename T2> Gadget(T2&& rhs); // rhs의 타입은 universal reference ... // 하지만, rhs 자체는 lvalue };
Widget의 생성자 함수에서 rhs는 rvalue reference이기에, rhs가 rvalue로 bound 될 것이라는 걸 알지만 rhs 자체는 lvalue이다. 따라서, 무엇이 rhs로 넘겨지든 rvalueness의 이점을 유지하기 위해 rhs를 rvalue로 변환할 필요가 있다. 이것이 바로 std::move를 사용해야 하는 일반적인 동기(motivation)가 된다.
Widget의 경우와 유사하게 Gadget의 생성자 함수에서 rhs는 universal reference이다. rhs는 rvalue 또는 lvalue로 bound 될 수 있으나, rhs 자체는 lvalue이다. rhs가 rvalue로 bound될 경우엔 rvalueness의 이점을 유지해야 하고, lvalue로 bound될 경우엔 lvalueness를 유지시켜 줘야 한다. 이것이 바로 std::forward를 사용해야 하는 일반적인 동기(motivation)가 된다. 즉, rvalue는 rvalue로, lvalue는 lvalue로 bound 시켜야 할 때 std::forward를 사용하는 것이다.
3. Reference Collapsing
C++11의 특정한 생성 과정에서 레퍼런스의 레퍼런스가 발생하지만, C++은 레퍼런스의 레퍼런스를 허용하지 않는다. 만약, 소스코드 자체에 명시적으로 레퍼런스의 레퍼런스 형식을 포함한다면 그 코드는 유효한 코드가 아니다.
Widget w1; // 에러! 레퍼런스의 레퍼런스 따윈 없는거임! Widget& & w2 = w1;
하지만, 컴파일러가 타입을 추론하는 과정에서의 결과로써, 레퍼런스의 레퍼런스가 발생하는 경우들이 있다. 이렇게 발생한 레퍼런스의 레퍼런스에 대해 reference collapsing을 수행하게 되며, 이것이 바로 universal reference가 lvalue reference 또는 rvalue reference로 해석될 수 있는 기본 메커니즘이다.
3-1. Template function arguments
Universal reference 타입을 템플릿 인자로 받아 타입을 추론하는 과정에서, lvalue와 rvalue는 조금 다르게 타입이 추론된다. T의 lvalue 타입은 T&로써 추론되나, T의 rvalue 타입은 그냥 T로 추론된다.
템플릿 함수 인자의 타입이 universal reference이고, 이 인자로 lvalue와 rvalue를 받을 때 각각 어떤 일이 발생하는지 조금 더 살펴보기로 하자.
template<typename T> void f(T&& param); ... int x; ... // rvalue로 f 함수 호출 f(10); // lvalue로 f 함수 호출 f(x);
숫자 리터럴 10으로 f 호출시, T는 int로 추론되며 f 함수는 다음과 같이 instantiate된다.
// rvalue로부터 f 함수 instantiated void f(int&& param);
하지만, 변수 x, lvalue로 f 호출시 T는 int&로 추론되며, f 함수는 레퍼런스의 레퍼런스를 포함한 채 instantiate된다.
// lvalue로부터 f 함수 instantiated void f(int& && param);
레퍼런스의 레퍼런스로 인해 얼핏 보기엔 유효하지 않은 코드같지만, 이는 명시적으로 유저가 입력한 것이 아닌, 컴파일러가 타입을 추론하는 과정에서 생긴 것이며, 이 과정에서의 문제를 회피하기 위해 앞서 소개한 "reference collapsing"을 수행한다.
lvalue reference와 rvalue reference, 두 종류의 레퍼런스 종류로 인해 4가지의 레퍼런스의 레퍼런스 조합이 존재할 수 있다. - & & (L + L)
- & && (L + R)
- && & (R + L)
- && && (R + R)
그리고, 두 가지의 reference collapsing 규칙이 다음과 같이 존재한다. - && && (R + R) 은 &&로 collapse 된다.
- 그 외 나머지 조합(L 이 하나라도 포함된)은 &로 collapse 된다.
즉, 위 collapse 규칙을 적용하면 다음과 같이 된다. - & & (L + L) -> &
- & && (L + R) -> &
- && & (R + L) -> &
- && && (R + R) -> &&
위 규칙을 lvalue로 f 함수가 instantiate되는 상황에 적용하면, 컴파일러는 최종적으로 아래처럼 해석하여 적법한 코드가 된다.
// reference collapsing 이후 lvalue로부터 f 함수 instantiated void f(int& param);
지금까지의 내용보다 훨씬 더 미묘한 경우가 있는데, 바로 변수의 타입이 레퍼런스인 경우이다. 이 경우 타입의 레퍼런스 부분이 무시(reference-stripping)된 채 처리된다. 다음의 예제를 살펴보자.
template<typename T> void f(T&& param); int x; // r1의 타입은 int&& int&& r1 = 10; // r2의 타입은 int& int& r2 = x;
r1과 r2 모두 템플릿 함수 f를 호출하는 과정에서 먼저 int로 간주된다. 위 경우처럼 레퍼런스 부분을 무시(reference-stripping)하고 타입을 처리할 때는 universal reference의 타입을 lvalue는 T&로, rvalue는 T로 추론하는 규칙으로부터 독립적이다. (즉, 그 규칙에 영향을 받지 않는다)
따라서, 위 예제는 다음과 같이 컴파일러는 해석한다.
최종적으로 템플릿 함수 f에서 r1과 r2는 param으로 넘어갈 때 모두 int&로 추론된다. 맨 처음 r1과 r2의 reference-stripping으로 인해 int로 간주되었고, 그 결과 r1과 r2는 lvalue 이기 때문에, 템플릿 함수 f의 최종 인자로 넘어갈 땐 universal reference의 타입 추론 규칙에 의해 int&로 간주되는 것이다.
3-2. auto variable
위에서 언급했듯이 reference collapsing이 발생하는 경우는 대부분 템플릿 함수가 instantiate될 때 발생한다. 또 하나의 경우가 있는데, auto 변수가 선언되는 경우이다.
auto 변수의 universal reference를 타입 추론할 때 lvalue는 T&로, rvalue는 T로 추론된다. (템플릿 함수의 인자에 대해 universal reference를 타입 추론하는 것과 동일하다) 앞서 나왔던 예제를 다시 한번 살펴보자.
// var1은 Widget&& 타입 Widget&& var1 = someWidget;
auto&& var2 = var1;
var1의 타입은 Widget&&이지만, var2의 초기화 과정에서 타입이 추론될 때 var1의 referenceness는 무시된다. 템플릿 인자 처리에서처럼 변수 타입이 reference인 경우 reference-stripping을 수행, var1은 Widget 타입으로 간주되는 것이다.
var2의 universal reference를 초기화하는데 var1이 lvalue로 간주되면서, var2의 universal reference는 Widget&로 추론된다. 따라서, 최종적으로 컴파일러는 다음과 같이 reference collapsing을 수행한다.
// reference-stripping으로 인해 먼저 var1은 대입되기 전 Widget 타입 // Widget var1은 lvalue이므로, lvalue reference로 타입 추론 Widget& && var2 = var1; // 최종적으로 다음과 같이 reference collapsing이 발생 Widget& var2 = var1;
3-3. typedef
세번째 reference collapsing이 발생하는 경우는 typedef를 사용하는 경우이다.
template <typename T> class Widget { typedef T& LValueRefType; ... }; Widget<int&> w
객체 w는 다음과 같이 instantiate 된다.
// instantiate 된 직후의 typedef typedef int& & LValueRefType; // reference-collapsing이 발생한 이후 typedef int& LValueRefType;
이 상태에서 다음과 같이 Widget을 사용하게 되면..
void f(Widget<int&>::LvalueRefType&& param);
이는 다음과 같이 해석되고,
최종적으로 다음과 같이 reference collapsing이 발생한다.
3-4. decltype
마지막으로 살펴볼 reference collapsing 사례는 decltype의 사용이다.
템플릿 함수 인자 추론이나 auto 변수 추론의 경우처럼, decltype(표현식) 역시 표현식에 대한 T 또는 T&로의 타입 추론을 수행하며, 이후 C++11의 reference collapsing 규칙을 적용한다.
아~ 애석하게도, decltype에 적용된 타입 추론 규칙은 템플릿이나 auto의 타입 추론 규칙과 동일하지 않다. 세부 내용은 너무 불가사의하기에(arcane) 여기에서 모든 내용을 다루진 않겠다. (5. 추가 링크의 decltype 관련 링크 확인)
하지만, 주목할만한 차이점 하나는 non-reference 타입의 이름 있는 변수에 대해, 템플릿과 auto가 T&로 추론하는 것과 달리 decltype은 T로 추론한다.
또다른 중요 차이점 하나는 decltype의 타입 추론은 표현식에만 의존한다. 표현식이 초기화되는(initializing) 과정에서의 타입은 무시된다.
Widget w1, w2; // v1은 lvalue로부터 초기화되는 auto-based universal reference // 따라서, v1의 타입은 w1을 참조하는 lvalue reference // Widget& v1 = w1;과 동일하다 auto&& v1 = w1;
// v2는 decltype-based universal reference // w2가 어떤 타입인지 관계없이, decltype(표현식)의 표현식에만 의존해 타입이 결정된다 // decltype(w1)은 Widget이므로(Widget&이 아님), v2의 타입은 rvalue reference // 즉, Widget&& v2 = w2;와 동일하다. // 하지만, w2는 lvalue // rvalue reference를 lvalue로 초기화할 수 없으므로 아래 코드는 컴파일 에러 발생 // decltype(w1)&& v2 = std::move(w2);로 해야 적법한 코드가 된다 decltype(w1)&& v2 = w2;
9~12라인의 주석을 곱씹어보면, decltype-based universal reference를 이해하기 쉬울 것이다.
4. 정리
타입 선언시 "&&"는 rvalue reference 또는 universal reference를 둘 중 하나로 해석될 수 있다. 그리고, universal reference는 lvalue reference 또는 rvalue reference 둘 중 하나로 해석될 수 있다.
Reference collapsing은 universal reference가 lvalue reference 또는 rvalue reference로 해석될 수 있는 메커니즘을 제공한다. 이는 컴파일 과정에서 레퍼런스의 레퍼런스가 발생하는 특수한 상황에서 수행된다. - 템플릿 함수 인자 추론
- auto 변수 추론
- 레퍼런스 typedef
- decltype 표현식 추론
|