rvalue reference의 이해
원본 글
http://cpplover.blogspot.jp/2009/11/rvalue-reference_23.html
lvalue, rvalue
C에서의 lvalue, rvalue의 차이는 대입 연산자의 오른쪽에 있는지 왼쪽에 있는지의 차이 뿐이었다. 즉 left hand value, right hand value.
하지만 C++에서의 lvalue, rvalue는 그와는 완전히 다른 개념이다.
- lvalue는 명시적으로 실체가 있는 명명된 객체.
- rvalue는 일시적으로 생성된 무명의 객체.
C++에서의 rvalue, lvalue의 예
struct S {};
int func() {
return 0;
};
int main()
{
int i=0;
i; // lvalue
0; // rvalue
S obj;
obj; // lvalue
S(); // rvalue
func(); // rvalue
}
여기서 알아둬야 할 것은 lvalue는 rvalue로 변환할 수 있지만, rvalue는 lvalue로 변환할 수 없다는 것이다.
Reference
c++11이전의 reference는 c++11의 lvalue reference를 뜻한다.
struct S {};
void func (S &) {}
void func_const (S const &) {}
int main()
{
S obj;
func(obj); // 1. OK
func(S()); // 2. Error
func_const(S()); // 3. OK
}
- lvalue 이므로 문제가 없다.
- rvalue를 전달하기 때문에 에러
- const reference는 rvalue를 참조할 수 있기 때문에 문제가 없다.
단, VC++의 독자 확장을 쓰면 (/Za) 2도 에러가 나지 않기 때문에 주의할 것.
여기서 눈여겨 볼 것이 3인데 C++의 복잡한 사양이다. 원래 rvalue인 것을 lvalue reference에서 참조하는 것이다. 여기서 rvalue reference가 나오게 된다.
Rvalue reference
struct S {};
int main()
{
S obj;
// lvalue reference
S & lr1 = obj; // 1. OK
S & lr2 = S(); // 2. Error
// rvalue reference
S && rr1 = obj; // 3. Error
S && rr2 = S(); // 4. OK
}
rvalue reference는 &&
를 사용한다.
- obj가 lvalue이기 때문에 문제없음.
- S()가 rvalue이기 때문에 에러.
- obj는 lvalue이기 때문에 에러.
- S()는 rvalue이기 때문에 문제없음.
rvalue reference의 존재 이유
rvalue reference라는 것은 단순히 이것뿐이다. 이름 그대로 rvalue에 대한 참조이다.
이것만으로는 왜 rvalue가 존재하는지 알기 어렵다. const lvalue reference로도 rvalue를 참조할 수 있으니까 없어도 되는 것 아닌가? 라고 생각할 수 있다.
rvalue 객체는 이름이 없기 때문에, 참조할 수 없게 되는 시점에서 자동으로 소멸된다. 자동으로 소멸하는 것을 참조해서 사용한다고 해도 아무 쓸모도 없는 것이다.
const lvalue reference를 쓰지 않고도 rvalue를 참조할 수 있는 것이 왜 그렇게 유용한 것일까?
Move semantics
class DummyBuffer
{
public:
DummyBuffer() {
ptr = new char[1000];
}
// 복사생성자
DummyBuffer(DummyBuffer const & r) {
ptr = new char[1000];
memcpy(r.ptr, ptr, 1000);
}
~DummyBuffer() {
delete[] ptr;
}
private:
char * ptr;
};
이 클래스는 생성자와 소멸자의 실행이 느리다. 만약 복사 생성자의 포인터만 바꿔치기한다면 성능 향상은 있겠지만, 원본 객체를 사용할 수 없게 되기 때문에 그렇게 해서는 안된다.
지금부터 포인터 바꿔치기만으로 안전하게 복사 생성자를 실행하는 방법을 소개해 보겠다.
struct S {};
S func()
{
return S();
}
int main()
{
S obj(func()); // 1.
S tmp;
S obj2(tmp); // 2.
// 이후 tmp는 사용하지 않는다.
}
1번의 경우는 func()의 반환 값이 rvalue이므로 안전하게 포인터를 바꿔치기할 수 있다. 그리고 2번의 경우는 tmp는 더 이상 사용하지 않기 때문에 포인터를 바꿔치기해도 상관없다.
문제는 어떻게 이것을 구현하는가이다. 여기서 rvalue reference의 진가가 발휘된다. rvalue는 그 객체의 포인터를 가로채도 문제가 없다는 데 있다.
Move constructor
위 예제에서 1번의 복사 생성자를 실행할 때 포인터를 가로채기 위해서 rvalue reference의 복사 생성자를 추가해 보겠다.
class DummyBuffer
{
public:
DummyBuffer() {
ptr = new char[1000];
}
// 복사생성자
DummyBuffer(DummyBuffer const & r) {
ptr = new char[1000];
memcpy(r.ptr, ptr, 1000);
}
// move 복사생성자
DummyBuffer(DummyBuffer && r) {
ptr = r.ptr;
r.ptr = nullptr;
}
~DummyBuffer() {
delete[] ptr;
}
private:
char * ptr;
};
move 복사 생성자는 원본의 포인터를 바꿔치기한다. 여기서 원본의 포인터에 nullptr을 넣어주지 않으면 원본의 파괴자가 불리면서 런타임 에러가 나므로 주의할 것.
lvalue의 move
2번의 경우는 어떻게 해결하면 될까? move 생성자를 구현했지만 2번의 경우는 lvalue이므로 move 생성자가 불리지 않는다.
tmp 변수는 이후로는 사용하지 않지만, 컴파일러는 프로그래머의 의도를 알 수 없으므로 당연히 일반 복사 생성자를 실행하게 될 것이다.
tmp 변수를 lvalue가 아닌 rvalue로 넘겨줄 수 있다면 move 복사 생성자를 불리게 할 수 있다.
S tmp;
S b(static_cast<S &&>(tmp));
강제적으로 이렇게 static_cast 시켜주면 tmp를 rvalue로 넘기는 게 가능하다.
이것을 좀 더 간단하게 표현하는 방법이 std::move()
이다.
S tmp;
S b(std::move(tmp));
실제 구현을 단순화시키면:
namespace std {
template <class T>
inline
typename std::remove_reference<T>::type&&
move(T&& t)
{
return static_cast< std::remove_reference<T>::type&& >(t) ;
}
}
std::move()
는 이것 뿐이다. 단순히 lvalue를 rvalue로 static_cast
시키는 것이다.
영리한 컴파일러의 경우
영리한 컴파일러일 경우에는 일반 복사 생성자도 move 복사 생성자도 불리지 않을 경우가 있다. 어떤 경우에는 실제로 객체를 복사하지 않고 원본 객체를 그대로 사용하는 경우도 있다.
(N3000 § 12.8 Copying class objects p19)
만약 위 코드를 컴파일 했을 때 생성자가 불리지 않는 경우는 이렇게 코딩 해 보는 것도 좋다.
#include <utility>
class DummyBuffer
{
public:
DummyBuffer() {
ptr = new char[1000];
}
DummyBuffer & operator=(DummyBuffer && r) {
if (this == &r) {
return *this;
}
delete[] ptr;
ptr = r.ptr;
r.ptr = nullptr;
return *this;
}
~DummyBuffer() {
delete[] ptr;
}
private:
char * ptr;
};
int main()
{
DummyBuffer tmp;
DummyBuffer x;
x = std::move(tmp);
}
오버로드
rvalue reference, lvalue reference는 당연히 함수 overload가 가능하다.
#include <iostream>
struct S {};
void f(S & s) {
std::cout << "lvalue reference" << std::endl;
}
void f(S && s) {
std::cout << "rvalue reference" << std::endl;
}
int main() {
S obj;
f(obj); // lvalue reference
f(S()); // rvalue reference
}
이 경우는 너무나도 명확하므로 쉽게 이해가 된다. 하지만 템플릿을 사용하면 어떻게 될까?
struct S {};
template <typename T>
void f(T && t) {
}
int main() {
S obj;
f(obj); // lvalue reference
f(S()); // rvalue reference
}
이 코드는 컴파일 성공한다. 여기에는 특별한 규칙이 있어서 템플릿 함수의 인수를 rvalue로 했을 경우에 lvalue를 전달하면 자동으로 lvalue로 인식하는 규칙이다.
(§ 14.9.2.1 Deducing template arguments from a function call p3)
조금은 이상한 규칙이지만, 이렇게 되지 않으면 프로그래머는 lvalue, rvalue reference 코드를 따로 작성해야 하는 수고가 따른다. 하지만 이 규칙 때문에 rvalue reference 코드만 작성하면 문제없이 동작하게 된다.
하지만 여기에 한가지 문제점이 있다.
Perfect forwarding
struct S {};
template <typename T>
void f(T && t) {
S obj(t);
}
f()
안에서 S를 복사하고 싶을 경우이다. 위의 내용을 읽었다면 여기서도 std::move()
를 사용하고 싶을 것이다. 하지만 사용할 수 없다. 왜일까?
struct S {};
template <typename T>
void f(T && t) {
S obj(std::move(t));
// 이후 t는 사용 불가
}
int main() {
S obj;
f(obj); // lvalue reference
}
이유는 인수가 lvalue reference일 가능성이 있기 때문이다. main() 함수에서 std::move()
하지 않았지만 obj가 마음대로 f()
안에서 move 되어버렸기 때문에 사용할 수 없게 된다.
하지만 f()
는 인수가 lvalue인지 rvalue인지 알 수 없다. lvalue일 경우에는 복사하고, rvalue 일 경우에는 move 시키고 싶다. 어떻게 하면 될까?
메타 프로그래밍을 사용
template <typename T>
void f(T && t) {
if (std::is_lvalue_reference<T>::value) {
T x(t);
} else {
T x(std::move(t));
}
}
이 코드의 문제점은 인수가 많아질수록 if의 수도 늘어난다는 것이다.
cast를 사용
template <typename T>
void f(T && t) {
T x(static_cast<T &&>(t));
}
인수로 lvalue가 들어왔을 경우에는 T는 T&가 되고 && 는 무시된다. 따라서 static_cast도 lvalue로 cast된다.
rvalue가 들어왔을 경우에는 그대로 rvalue가 된다.
이렇게 하면 템플릿 인수에 무엇이 들어와도 그대로 함수에 전달하는 것이 가능하다.
std::forward()
하지만 cast를 사용하는 것은 귀찮기도 하고, 버그를 낳는 코드가 되기 쉽다. 이를 위해서 존재하는 것이 std::forward()
다.
template <typename T>
void f(T && t) {
T x(std::forward<T>(t));
}
std::forward()
도 std::move()
와 마찬가지로 본질은 cast이다.
단순화시키면 아래와 같다.
namespace std {
template <class T, class U,
class = typename enable_if<
(is_lvalue_reference<T>::value ?
is_lvalue_reference<U>::value :
true) &&
is_convertible<typename remove_reference<U>::type*,
typename remove_reference<T>::type*>::value
>::type>
inline
T&&
forward(U&& u)
{
return static_cast<T&&>(u);
}
}
무시무시하게 보이는 메타프로그래밍 코드이지만 본질적으로는 cast이다.
- T가 lvalue reference라면 U도 lvalue reference 라야 한다.
- 참조를 제거한 상태에서 U에서 T로 변환이 되어야 한다.
이 두 가지 조건을 만족하지 못한 경우, std::forward()
는 overload resolution의 대상에서 제외된다. 즉 컴파일 에러가 된다. 이것으로 전형적인 오타를 방지할 수 있게 된다.
std::forward()
는 템플릿 함수의 인수를 다른 함수의 인수에 그대로 전달하기 위한 것이다. 이것을 perfect forwarding이라고 한다.
마치면서
rvalue reference는 정말 단순한 것이다. 이름 그대로 rvalue의 참조에 지나지 않는다.
std::move()
, std::forward()
도 단순한 cast일 뿐이다.
std::move()
는 lvalue를 move시키고 싶을 때 사용하고,std::forward()
는 템플릿 함수의 인수를 그대로 다른 함수에 전달할 때 사용한다.
'개발 > C++ (98,03,11,14,17,20,23)' 카테고리의 다른 글
Modern C++ : lvalue rvalue reference (98, 11) (2) | 2025.08.15 |
---|---|
Modern C++ : dynamic heap memory allocation (98, 11, 14) (1) | 2025.08.14 |
Modern C++ : pointer and address (98, 11) (3) | 2025.08.13 |
Modern C++ : const reference and value semantics (98, 11) (1) | 2025.08.12 |
Modern C++ : string vs std::string vs std::array<char, N> (98, 11, 17) (1) | 2025.08.11 |
Modern C++ : array vs std::array (98, 11) (1) | 2025.08.10 |
Modern C++ : anonymous namespace (98, 11, 17) (2) | 2025.08.09 |
Modern C++ : C-Style static function (98, 11) (1) | 2025.08.08 |