Rvalue reference는 봐도봐도 자꾸 헷갈려서 한번 정리를 해봐야겠다고 생각했다.
Lvalue/Rvalue
Lvalueness/rvalueness 는 object의 성질이 아니라 expression의 성질이다. Lvalue는 계속 접근이 가능한 object를 가리킨다. 반면 Rvalue는 그것이 포함된 full-expression이 끝나면 사라지는 temporary를 가리킨다. 참고로 ++x 는 lvalue이고 x++는 rvalue이다.
Lvalue examples :
- obj
- *ptr
- ptr[index]
- ++x
Rvalue exapmle :
- x+y
- string(“asdf”)
- x++
Lvalue와 rvalue는 둘 다 const일 수도 있고 non-const일 수도 있다.
1 2 3 4 5 6 7 8 |
string aa = "non-const lvalue"; const string bb = "const lvalue"; string cc() { return "non-const rvalue"; } const string dd() { return "const rvalue"; } aa; // non-const lvalue bb; // const lvalue cc(); // non-const rvalue dd(); // const lvalue |
Rvalue Reference
Rvalue는 const T& 에만 bind되고 T&에는 붙을 수 없다. 왜냐하면 temporary를 변경하는 걸 허용하지 않기 때문이다. 그럼 rvalue는 어차피 변경 못하는데 const rvalue랑 non-const rvalue는 뭐가 다른지? Non-const rvalue에 대해서는 non-const member function을 호출하는 것이 허용된다. C++03에서는 이 점만 달랐다.
하지만 C++11에서는 rvalue reference가 생기면서 rvalue reference를 통해서 non-const rvalue를 변경할 수 있게 되었다. Rvalue reference는 initialization과 overload resolution에서 차이가 있고 다른 동작은 lvalue reference와 똑같다.
1 2 3 4 5 6 |
string f() { return "non-const rvalue"; } string& a = f(); // error const string& b = f(); // ok string&& c = f(); // ok <- rvlaue ref를 통해 rvalue 변경 가능 const string&& d = f(); ok |
Rvalue Reference Initialization
const/non-const lvalue/rvalue reference가 bind 될 수 있는 것들은 다음과 같다.
non-const lvaule | const lvalue | non-const rvaule | const rvalue | |
---|---|---|---|---|
T& | O | X | X | X |
const T& | O | O | O | O |
T&& | X | X | O | X |
const T&& | X | X | O | O |
이것은 다음과 같은 initialization 규칙을 따른 것이다.
- const-correctness 를 지킨다.
- lvalue reference는 rvalue를 변경 못한다.
- rvalue reference는 rvalue에만 bind되고 rvalue를 변경할 수 있다.
T&는 non-const lvalue reference이기 때문에 rvalue에는 붙을 수 없다. 만약 된다면 temporary를 변경할 수 있기 때문이다. 그리고 const-correctness 때문에 const lvalue에 붙을 수 없다. 따라서 T&는 non-const lvalue에만 붙는다. (VC++은 non-const rvalue에도 붙을 수 있다.. warning level을 /W4로하면 warning은 뜬다.) const T&는 const이기 때문에 변경할 위험이 없으므로 rvalue에도 붙을 수 있고, 따라서 모든 것에 다 붙을 수 있다. 코드로 보면 아래와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
string aa = "non-const lvalue"; const string bb = "const lvalue"; string cc() { return "non-const rvalue"; } const string dd() { return "const rvalue"; } string& a = aa; string& b = bb; // error string& c = cc(); // error string& d = dd(); // error const string& e = aa; const string& f = bb; const string& g = cc(); const string& h = dd(); string&& i = aa; // error string&& j = bb; // error string&& k = cc(); string&& l = dd(); // error const string&& m = aa; // error const string&& n = bb; // error const string&& o = cc(); const string&& p = dd(); |
Rvalue Reference Overload Resolution
그렇다면 bind 가능한 것이 여러개 있을 경우 어떤걸 선택할까? 예를 들어 아래와 같이 함수 f가 overload 되어 있을 때 non-const lvalue는 어떤 규칙에 따라 overload된 함수들 중 하나를 선택할까?
1 2 3 4 |
string f(string&) { return "string&"; } string f(const string&) { return "const string&"; } string f(string&&) { return "string&&"; } string f(const string&&) { return "const string&&"; } |
바로 다음과 같은 overload resolution 규칙을 따른다. 여기서 강하게 약하게라고 쓴 것은 2, 3이 상충되면 2가 이긴다는 뜻이다.
- 일단 위에서 말한 initialization 규칙에 안 맞으면 제외한다.
- lvalue/rvalue는 각각 lvalue/rvalue reference에 강하게 끌린다.
- non-const/const expression은 각각 non-const/const reference에 약하게 끌린다.
위에서 처럼 4가지 버전의 f를 다 만들 일은 별로 없고, 다음과 같은 두 개를 사용하는 것이 유용하다.
1 2 |
string f(const string&) { return "const string&"; } string f(string&&) { return "string&&"; } |
이렇게 했을 때 위의 overload resolution 규칙에 따르면 non-const rvalue만 string&& 버전의 f를 부르고, 나머지는 모두 const string& 버전의 f를 부르게 된다. 즉 c++11에서는 rvalue reference를 통해서 non-const rvalue를 걸러낼 수 있게 된 것이다.
Move Semantics
A에는 copy constructor가 있다. 메모리를 잡아서 other.p의 내용을 복사한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class A { public: A() : p(nullptr), size(0) {} A(const A& other) : size(other.size) { p = new char[size]; std::copy(other.p, other.p + size, p); } ~A() { delete[] p; } char *p; int size; }; |
그런데 만약에 other가 곧 사라질 temporary라면 굳이 복사할 필요가 없고 그냥 p = other.p 라고 하면 될 것이다. 위에서 어떻게 rvalue를 걸러내는지 알아보았는데 이것을 이용해서 move constructor를 만들 수 있다. 마찬가지로 move assignment operator도 정의 할 수 있다.
1 2 3 4 5 |
A(A&& other) : size(other.size) { p = other.p; other.p = nullptr; } |
Implicit Declaration
Default constructor, copy constructor, copy assignment operator는 컴파일러가 자동으로 생성할 수 있다. move constructor, move assignment operator 역시 자동으로 생성된다. 하지만 copy constructor, copy assignment operator, move constructor, move assignment operator, destructor 중 하나라도 선언하면 move constructor, move assignment operator 둘 다 안 생성된다.
Moving Lvalue
헷갈리지 말아야 할 것은 named rvalue reference 그 자체는 lvalue라는 것이다… 왜냐햐면 이름이 있으면 계속 접근할 수 있기 때문이다.
- named lvalue reference : lvalue ( ex : int& r = *p 일 때 r)
- unnamed lvalue reference : lvalue ( ex : vector<int>v 일 때 v[0])
- named rvalue reference : lvalue
- unnamed rvalue reference : rvalue
그래서 move constructor를 아래 처럼 만들면 string의 move constructor가 불리지 않고 copy constructor가 불린다. 왜냐하면 A&& other는 rvalue reference지만 함수 내에서는 other라는 이름이 있기 때문에 lvalue이기 때문이다. 으으..
1 2 3 4 5 6 7 8 9 10 |
class A { public: A() {} A(const A& other) : str(other.str) A(A&& other) : str(other.str) // WRONG! ~A() { delete[] p; } string str; }; |
이럴때 std::move를 사용한다. std::move는 lvalue든 rvalue든 무조건 rvalue로 바꿔준다. 그래서 temporary가 아닌 경우에도 rvalue로 바꿔버릴 수 있다. 즉, temporary가 아니지만 내가 알아서 책임지겠다는 뜻이다.
1 2 3 4 5 6 7 8 9 10 |
class A { public: A() {} A(const A& other) : str(other.str) A(A&& other) : str(move(other.str)) // GOOD! ~A() { delete[] p; } string str; }; |
Forwarding Problem
아래와 같은 코드의 의도는 T(arg)를 바로 실행한 것과 똑같도록 그대로 arg를 T 생성자로 넘겨주는 것, 즉 perfect forwarding이다. 그런데 Arg arg 이렇게 하면 복사가 일어나므로 의도랑 다르다.
1 2 3 4 5 |
template<typename T, typename Arg> shared_ptr<T> factory(Arg arg) { return shared_ptr<T>(new T(arg)); } |
이걸 해결하기 위해 Arg& arg를 사용한다.
1 2 3 4 5 |
template<typename T, typename Arg> shared_ptr<T> factory(Arg& arg) { return shared_ptr<T>(new T(arg)); } |
그런데 이렇게 하면 rvalue를 인지로 받지 못한다. (이유는 Reference Initialization에서 살펴보았다.)
1 |
factory<X>(42); // error |
그래서 const Arg&를 받는 overload 통해 해결한다.
1 2 3 4 5 |
template<typename T, typename Arg> shared_ptr<T> factory(const Arg& arg) { return shared_ptr<T>(new T(arg)); } |
그런데 이건 인자가 하나니까 2개만 있으면 되는데 인자가 2개면 4가지 조합의 overload가 필요하고 3개면 8개가 필요하다. 거기다 이 방법은 move semantic도 동작하지 않는다. C++03에서는 이 문제를 해결할 수 없지만 rvalue reference는 가능하다. std::forward는 lvalue/rvalue, non-const/const를 그대로 보존해서 넘겨준다.
1 2 3 4 5 |
template<typename T, typename Arg> shared_ptr<T> factory(Arg&& arg) { return shared_ptr<T>(new T(std::forward<Arg>(arg))); } |
Template Argument Deduction and Reference Collapsing
std::move와 std::forward는 어떻게 동작하는 것일까? 먼저 template argument deduction에 대해 살펴보자.
Reference Collapsing
Reference의 reference를 직접 코드에서 하는 것은 불가능하다. 하지만 compile할 때 template argument deduction하는 과정에서 어쩔수 없이 발생하는데 이 때는 reference collapsing이 일어난다. Reference collapsing은 간단하다. rvalue reference의 rvalue reference 는 rvalue reference가 되고 나머지는 다 lvalue reference가 된다.
- A& & -> A&
- A& && -> A&
- A&& & -> A&
- A&& && -> A&&
Template Argument Deduction
기본적으로 이것은 parameter랑 argument의 타입을 맞추는 것이다.
1 2 3 4 |
template <class T> void f(T); int b = 11; f(b); // P = T, A = int, Deduces T = int |
그런데 Deduction 하기 전에 먼저 parameter와 argument에 transform을 한다. 규칙이 복잡하지만 필요한 것만 살펴본다.
- P에 const 같은게 있어도 무시한다.
- P가 reference type이면 &를 다 떼버린다.
- P가 cv-unqualified rvalue reference 이고 A가 lvalue이면 A 대신 A& 사용한다.
아래의 f 함수에서 P 는 T&& 인데 위의 규칙을 적용하면 P = T가 된다. f(i)의 경우 i가 lvalue이기 때문에 3번의 특수 규칙에 따라 A = int&이다. 따라서 P=A 가 되려면 T=int&라고 추론하게 된다. 그런데 T가 int&이면 f<int&>(int& &&); 이렇게 된다. 여기서 바로 reference collapsing이 일어나서 f<int&>(int&)이 된다. f(0)의 경우 0은 rvlaue이기 때문에 3번 규칙이 적용되지 않는다. 따라서 P=T, A=int, T=int, f<int>(int&&)이 된다.
T&& 는 이렇게 lvalue, rvalue를 다 받을 수 있기 때문에 universal reference라고 한다. 근데 정확하게 T&&만 universal이고 아래 코드의 g() 처럼 const T&& 이런건 universal이 아니다. 3번 규칙은 universal에만 적용된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
template <class T> int f(T&&); // P is rvalue reference to cv-unqualified T ("universal reference") template <class T> int g(const T&&); // P is rvalue reference to cv-qualified T (not special) int main() { int i; int n1 = f(i); // argument is lvalue: calls f<int&>(int&) (special case) int n2 = f(0); // argument is not lvalue: calls f<int>(int&&) // int n3 = g(i); // error: deduces to g<int>(const int&&), which // cant bind an rvalue reference to an lvalue: } |
std::move, std::forward
그러면 다시, std::move와 std::forward는 어떻게 동작하는 것일까. move와 forward의 코드는 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
template <typename T> struct remove_reference { typedef T type; } template <typename T> struct remove_reference<T&> { typedef T type; } template <typename T> struct remove_reference<T&&> { typedef T type; } template<class T> typename remove_reference<T>::type&& std::move(T&& a) noexcept { typedef typename remove_reference<T>::type&& RvalRef; return static_cast<RvalRef>(a); } template<class S> S&& forward(typename remove_reference<S>::type& a) noexcept { return static_cast<S&&>(a); } |
만약 move에 lvaue string이 넘어왔다면, 위에서 봤듯이 T=string& 가 된다. 따라서 2번째 remove_reference가 적용돼서 remove_reference<string&>::type=string 이므로 string&& 타입을 리턴하게 된다. Rvalue일 경우도 해보면 string&& 타입을 리턴한다. 이렇게 move는 lvalue, rvalue를 모두 rvalue로 바꾸는 것이다.
forward의 경우에는 참고로 non-deduced context이기 때문에 explicit template argument를 줘야한다. (forward(x)가 아니라 forward<A>(x)로 해야한다.) 설명을 위해 아래 코드를 다시 가져왔다.
1 2 3 4 5 |
template<typename T, typename Arg> shared_ptr<T> factory(Arg&& arg) { return shared_ptr<T>(new T(std::forward<Arg>(arg))); } |
아래와 같이 lvalue로 factory를 불렀을 때 deduction을 따라가 보자.
1 2 |
X x; factory<A>(x); |
factory함수에서 T=A, Arg=X&가 되어 아래처럼 된다.
1 2 3 4 5 6 7 8 9 |
shared_ptr<A> factory(X& && arg) { return shared_ptr<A>(new A(std::forward<X&>(arg))); } X& && forward(remove_reference<X&>::type& a) noexcept { return static_cast<X& &&>(a); } |
Collapsing 후는 이렇게 된다. forward가 lvalue reference를 리턴하는 것을 알 수 있다.
1 2 3 4 5 6 7 8 9 |
shared_ptr<A> factory(X& arg) { return shared_ptr<A>(new A(std::forward<X&>(arg))); } X& std::forward(X& a) { return static_cast<X&>(a); } |
factory를 rvalue로 불렀을 땐?
1 2 |
X foo(); factory<A>(foo()); |
T=A, Arg=X 이므로 이렇게 된다. forward가 rvalue reference를 리턴하는 것을 알 수 있다.
1 2 3 4 5 6 7 8 9 |
shared_ptr<A> factory(X&& arg) { return shared_ptr<A>(new A(std::forward<X>(arg))); } X&& forward(X& a) noexcept { return static_cast<X&&>(a); } |