Rvalue References

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일 수도 있다.

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와 똑같다.

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 규칙을 따른 것이다.

  1. const-correctness 를 지킨다.
  2. lvalue reference는 rvalue를 변경 못한다.
  3. 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에도 붙을 수 있고, 따라서 모든 것에 다 붙을 수 있다. 코드로 보면 아래와 같다.

Rvalue Reference Overload Resolution

그렇다면 bind 가능한 것이 여러개 있을 경우 어떤걸 선택할까? 예를 들어 아래와 같이 함수 f가 overload 되어 있을 때 non-const lvalue는 어떤 규칙에 따라 overload된 함수들 중 하나를 선택할까?

바로 다음과 같은 overload resolution 규칙을 따른다. 여기서 강하게 약하게라고 쓴 것은 2, 3이 상충되면 2가 이긴다는 뜻이다.

  1. 일단 위에서 말한 initialization 규칙에 안 맞으면 제외한다.
  2. lvalue/rvalue는 각각 lvalue/rvalue reference에 강하게 끌린다.
  3. non-const/const expression은 각각 non-const/const reference에 약하게 끌린다.

위에서 처럼 4가지 버전의 f를 다 만들 일은 별로 없고, 다음과 같은 두 개를 사용하는 것이 유용하다.

이렇게 했을 때 위의 overload resolution 규칙에 따르면 non-const rvalue만 string&& 버전의  f를 부르고, 나머지는 모두 const string& 버전의 f를 부르게 된다. 즉 c++11에서는  rvalue reference를 통해서 non-const rvalue를 걸러낼 수 있게 된 것이다.

Move Semantics

A에는 copy constructor가 있다. 메모리를 잡아서 other.p의 내용을 복사한다.

그런데 만약에 other가 곧 사라질 temporary라면 굳이 복사할 필요가 없고 그냥 p = other.p 라고 하면 될 것이다. 위에서 어떻게 rvalue를 걸러내는지 알아보았는데 이것을 이용해서 move constructor를 만들 수 있다. 마찬가지로 move assignment operator도 정의 할 수 있다.

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이기 때문이다. 으으..

이럴때 std::move를 사용한다. std::move는 lvalue든 rvalue든 무조건 rvalue로 바꿔준다. 그래서 temporary가 아닌 경우에도 rvalue로 바꿔버릴 수 있다. 즉, temporary가 아니지만 내가 알아서 책임지겠다는 뜻이다.

Forwarding Problem

아래와 같은 코드의 의도는 T(arg)를 바로 실행한 것과 똑같도록 그대로 arg를 T 생성자로 넘겨주는 것, 즉 perfect forwarding이다. 그런데 Arg arg 이렇게 하면 복사가 일어나므로 의도랑 다르다.

이걸 해결하기 위해 Arg& arg를 사용한다.

그런데 이렇게 하면 rvalue를 인지로 받지 못한다. (이유는 Reference Initialization에서 살펴보았다.)

그래서 const Arg&를 받는 overload 통해 해결한다.

그런데 이건 인자가 하나니까 2개만 있으면 되는데 인자가 2개면 4가지 조합의 overload가 필요하고 3개면 8개가 필요하다. 거기다 이 방법은 move semantic도 동작하지 않는다. C++03에서는 이 문제를 해결할 수 없지만 rvalue reference는 가능하다. std::forward는 lvalue/rvalue, non-const/const를 그대로 보존해서 넘겨준다.

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의 타입을 맞추는 것이다.

그런데 Deduction 하기 전에 먼저 parameter와 argument에 transform을 한다. 규칙이 복잡하지만 필요한 것만 살펴본다.

  1. P에 const 같은게 있어도 무시한다.
  2. P가 reference type이면 &를 다 떼버린다.
  3. 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에만 적용된다.

std::move, std::forward

그러면 다시, std::move와 std::forward는 어떻게 동작하는 것일까. move와 forward의 코드는 다음과 같다.

만약 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)로 해야한다.) 설명을 위해 아래 코드를 다시 가져왔다.

아래와 같이 lvalue로 factory를 불렀을 때 deduction을 따라가 보자.

factory함수에서 T=A, Arg=X&가 되어 아래처럼 된다.

Collapsing 후는 이렇게 된다. forward가 lvalue reference를 리턴하는 것을 알 수 있다.

factory를 rvalue로 불렀을 땐?

T=A, Arg=X 이므로 이렇게 된다. forward가 rvalue reference를 리턴하는 것을 알 수 있다.

Leave a Reply

Your email address will not be published. Required fields are marked *