Post

C++ lvalue와 rvalue, 그리고 rvalue reference를 이해해보자

C++의 lvalue와 rvalue가 무엇인지부터 lvalue reference, rvalue reference, std::move와 move semantics가 왜 필요한지까지 설명한 글입니다.

C++ lvalue와 rvalue, 그리고 rvalue reference를 이해해보자


lvalue와 rvalue는 왜 알아야 할까?


Modern C++을 공부하다 보면 다음과 같은 코드를 자주 만나게 된다.

1
2
3
4
5
std::string name = "hello";

std::string copy = name;

std::string moved = std::move(name);

겉으로 보면 모두 std::string 객체를 다른 객체에 전달하는 코드이다.
하지만 C++에서는

1
name

1
std::move(name)

을 서로 다르게 취급한다.

이 차이를 이해하려면 먼저 lvalue와 rvalue가 무엇인지 알아야 한다.
lvalue와 rvalue는 단순히 변수의 타입을 의미하는 것이 아니다.

중요한 것은 현재 사용하고 있는 표현식(expression)이 어떤 성질을 가지고 있는가이다.


lvalue는 식별할 수 있는 객체를 나타낸다


다음과 같은 변수가 있다고 해보자.

1
int value = 10;

여기서

1
value

라는 표현식은 lvalue이다.
value라는 이름을 이용해서 해당 객체에 계속 접근할 수 있기 때문이다.

1
2
3
4
5
value = 20;

std::cout << value;

int* ptr = &value;

즉 객체가 어디에 있는지 식별할 수 있고, 이후에도 다시 접근할 수 있다.

Modern C++에서는 lvalue를 개념적으로 identity를 가지고 있는 객체를 나타내는 표현식이라고 이해하는 것이 좋다.
과거에는 lvalue를

1
대입문의 왼쪽에 올 수 있는 값

이라고 설명하는 경우가 많았다.

예를 들어

1
value = 20;

에서 value가 왼쪽에 있기 때문이다.
하지만 이 설명만으로는 충분하지 않다.

1
const int value = 10;

에서 value 역시 lvalue이지만

1
value = 20;

은 불가능하다.
const이기 때문이다.

따라서 lvalue는 단순히 대입문의 왼쪽에 올 수 있는 값이라기보다 계속 식별할 수 있는 객체를 나타내는 표현식이라고 생각하는 것이 더 정확하다.


rvalue는 주로 임시 값이나 계산 결과를 나타낸다


다음 코드를 보자.

1
int value = 10;

여기서 오른쪽의

1
10

은 rvalue이다.
10이라는 결과 자체를 나중에 특정 이름으로 다시 찾아가서 사용할 수 있는 것은 아니다.

다른 예를 보면 더 쉽게 이해할 수 있다.

1
2
3
4
int a = 10;
int b = 20;

int result = a + b;

ab는 lvalue이다.

하지만

1
a + b

라는 계산 결과는 rvalue이다.

1
2
3
a     -> lvalue
b     -> lvalue
a + b -> rvalue

객체를 임시로 생성하는 표현식 역시 대표적인 rvalue이다.

1
std::string{"hello"}

함수가 값을 반환하는 경우도 마찬가지이다.

1
2
3
4
int getValue()
{
    return 10;
}

다음 표현식은 임시로 만들어지는 결과이므로 rvalue이다.

1
getValue()

따라서 처음에는 다음 정도로 생각하면 이해하기 쉽다.

1
2
lvalue -> 계속 식별해서 사용할 수 있는 객체
rvalue -> 임시 값이나 계산 결과처럼 소모될 수 있는 값


lvalue와 rvalue는 변수의 타입이 아니다


여기서 가장 중요한 점이 있다.
lvalue와 rvalue는 변수에 고정되어 있는 타입이 아니다.

다음 코드가 있다고 해보자.

1
int value = 10;

value의 타입은

1
int

이다.

하지만

1
value

라는 표현식은 lvalue이다.

반면

1
std::move(value)

라는 표현식은 rvalue 계열로 취급된다.
즉 같은 객체를 사용하더라도 표현식에 따라 성질이 달라질 수 있다.

1
2
3
foo(value);

foo(std::move(value));

두 코드 모두 실제로는 value라는 같은 객체를 사용한다.
하지만 첫 번째 표현식은 lvalue이고,

1
value

두 번째 표현식은 rvalue로 취급된다.

1
std::move(value)

따라서 C++을 이해할 때는 객체의 타입표현식의 value category를 따로 생각해야 한다.


lvalue reference는 기존 객체를 참조한다


C++에서 &를 이용하면 lvalue reference를 만들 수 있다.

1
2
3
int value = 10;

int& ref = value;

ref는 새로운 int 값을 복사해서 가지고 있는 것이 아니다.
기존 value를 참조한다.

따라서

1
ref = 20;

을 실행하면 원래 객체도 변경된다.

1
std::cout << value;

결과는

1
20

이다.
개념적으로는 다음과 같다.

1
2
3
4
5
6
7
value
+------+
|  10  |
+------+
   ^
   |
  ref

따라서 함수에서도 객체를 복사하지 않고 직접 접근하기 위해 reference를 사용할 수 있다.

1
2
3
4
void update(Data& data)
{
    data.update();
}

하지만 일반적인 lvalue reference는 rvalue를 받을 수 없다.

1
void process(Data& data);

다음 코드는 가능하다.

1
2
3
Data data;

process(data);

하지만 temporary는 받을 수 없다.

1
process(Data{}); // error

Data{}는 rvalue이기 때문이다.


const lvalue reference는 rvalue도 받을 수 있다


const T&에는 중요한 특징이 있다.

1
void process(const Data& data);

이 함수는 lvalue뿐만 아니라 rvalue도 받을 수 있다.

1
2
3
4
Data data;

process(data);    // OK
process(Data{});  // OK

temporary를 const reference로 받아 읽기만 하는 것은 안전하기 때문이다.
이 특성 때문에 C++11 이전에도 temporary 객체를 reference로 전달하는 것이 가능했다.
예를 들어

1
2
3
4
void print(const std::string& text)
{
    std::cout << text;
}

다음 코드는 모두 가능하다.

1
2
3
4
5
std::string text = "hello";

print(text);
print(std::string{"hello"});
print("hello");

따라서 단순히 객체를 읽기만 하는 함수에서는 지금도 const T&가 매우 자주 사용된다.


그렇다면 rvalue reference는 왜 필요할까?


C++11부터 &&를 이용한 rvalue reference가 추가되었다.

1
Data&&

rvalue reference는 일반적으로 rvalue에 바인딩된다.

1
void process(Data&& data);

다음 코드는 가능하다.

1
process(Data{});

하지만 기존 lvalue를 그대로 넘길 수는 없다.

1
2
3
Data data;

process(data); // error

이때 다음과 같이 작성하면 가능하다.

1
process(std::move(data));

여기서 rvalue reference가 중요한 이유는 단순히 temporary를 reference로 받을 수 있기 때문이 아니다.
const T&도 이미 temporary를 받을 수 있었다.
rvalue reference의 핵심은 이 객체의 기존 값을 더 이상 보존할 필요가 없다는 정보를 프로그램에 전달할 수 있다는 것이다.


lvalue는 보존하고 rvalue는 소모할 수 있다


다음과 같은 문자열이 있다고 해보자.

1
std::string source = "hello";

이를 복사하면

1
std::string destination = source;

source는 lvalue이다.
따라서 C++에서는 원본이 이후에도 사용될 가능성이 있다고 생각해야 한다.

1
std::cout << source;

그러므로 destination은 자신의 문자열 데이터를 별도로 가지고 있어야 한다.
개념적으로 다음과 같다.

1
2
3
4
5
6
7
8
9
source
+----------+
| data ----|------> [ hello ]
+----------+

destination
+----------+
| data ----|------> [ hello ]
+----------+

문자열이 매우 크다면 데이터를 복사하는 비용도 커질 수 있다.
반면 다음과 같이 작성하면 의미가 달라진다.

1
std::string destination = std::move(source);

std::move(source)source의 기존 내용을 더 이상 보존할 필요가 없으므로 자원을 가져가도 된다는 의도를 표현한다.
그러면 std::string은 내부 자원을 새로 복사하는 대신 기존 자원을 새로운 객체에 넘길 수 있다.
개념적으로는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<before>

source
+----------+
| data ----|------> [ hello ]
+----------+

<after>

source
+----------+
|          |
+----------+

destination
+----------+
| data ----|------> [ hello ]
+----------+

실제 std::string의 구현은 이보다 복잡하지만 중요한 개념은 같다.
문자열 데이터 전체를 복사하지 않고 내부 자원을 이전할 수 있다.
이것이 Move Semantics이다.


std::move가 실제로 데이터를 이동시키는 것은 아니다


이름 때문에 std::move()가 실제 데이터를 이동시키는 함수처럼 보일 수 있다.
하지만

1
std::move(value)

자체가 데이터를 옮기는 것은 아니다.
개념적으로는 value를 rvalue로 취급할 수 있도록 변환한다에 가깝다.

예를 들어

1
2
3
std::string source = "hello";

std::move(source);

라고만 작성한다고 해서 source의 문자열이 어디론가 이동하지 않는다.
실제 이동은 다음과 같이 move constructor나 move assignment가 호출될 때 발생한다.

1
std::string destination = std::move(source);

이 경우 std::move(source)를 통해 rvalue overload를 선택할 수 있게 되고,

1
std::string(std::string&&)

과 같은 move constructor가 실제 자원을 이동시킨다.
따라서 역할을 나누면 다음과 같다.

1
2
std::move() -> rvalue로 취급하도록 만든다.
Move Constructor / Move Assignment -> 실제 자원을 이동한다.


rvalue reference로 받아도 변수 이름은 lvalue이다


처음 rvalue reference를 공부할 때 가장 헷갈리는 부분이다.
다음 함수를 보자.

1
2
3
void process(std::string&& name)
{
}

name의 선언 타입은

1
std::string&&

이다.

하지만 함수 내부에서

1
name

이라고 사용하면 이 표현식은 lvalue이다.
왜냐하면 이제 name이라는 이름을 이용해 계속 접근할 수 있기 때문이다.

1
2
3
name += "!";

std::cout << name;

따라서 다음 코드는 move가 아니라 copy를 수행할 수 있다.

1
2
3
4
void setName(std::string&& name)
{
    name_ = name;
}

함수 내부의 name은 lvalue 표현식이기 때문이다.
실제로 move하려면 다시 명시해야 한다.

1
2
3
4
void setName(std::string&& name)
{
    name_ = std::move(name);
}

즉 다음 두 사실을 구분해야 한다.

1
2
name의 선언 타입 -> std::string&&
name이라는 표현식 -> lvalue

rvalue reference 타입의 변수라고 해서 변수 이름까지 계속 rvalue인 것은 아니다.


이름이 붙은 rvalue reference가 lvalue가 되는 이유


다음 코드를 보자.

1
int&& value = 10;

10은 rvalue이다.
그리고 valueint&& 타입의 rvalue reference이다.

하지만 이후 코드에서는

1
value

라는 이름으로 객체에 계속 접근할 수 있다.

1
2
3
value = 20;

std::cout << value;

이 시점에서 value는 식별할 수 있는 객체를 나타내는 표현식이다.
따라서 lvalue이다.

1
int&& value = 10;

에서

1
2
value의 타입      -> int&&
value라는 표현식   -> lvalue

이다.

이 차이는 std::move, std::forward, decltype(auto)를 이해할 때 매우 중요하다.


rvalue reference는 호출자의 의도를 드러낸다


다음 함수가 있다고 해보자.

1
2
3
4
void setName(std::string&& name)
{
    name_ = std::move(name);
}

기존 문자열을 그대로 전달할 수는 없다.

1
2
3
std::string name = "hello";

setName(name); // error

호출자는 다음처럼 명시해야 한다.

1
setName(std::move(name));

호출 코드를 보는 사람도

1
std::move(name)

을 통해 name의 현재 값을 더 이상 유지하려는 것이 아니라는 것을 알 수 있다.
T&&는 단순한 성능 기능만이 아니라 API의 의미도 표현한다.

1
setName(name);

은 기존 객체를 보존하는 느낌이고,

1
setName(std::move(name));

은 기존 객체의 자원을 넘겨도 된다는 의도를 나타낸다.
이후 name 객체 자체는 여전히 유효하지만 기존 문자열 값이 그대로 유지된다고 가정해서는 안 된다.
이를 valid but unspecified state라고 한다.


copy constructor와 move constructor


rvalue reference가 도입되면서 클래스는 객체가 lvalue로 전달되었는지 rvalue로 전달되었는지 구분할 수 있게 되었다.
예를 들어 다음과 같이 두 생성자를 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Data
{
public:

    Data(const Data& other)
    {
        // Copy
    }

    Data(Data&& other)
    {
        // Move
    }
};

다음 코드에서

1
2
3
Data a;

Data b = a;

a는 lvalue이므로

1
Data(const Data&)

가 호출된다.

반면

1
Data c = std::move(a);

에서는

1
Data(Data&&)

가 선택된다.

따라서 클래스는

1
2
lvalue -> 원본을 유지해야 함 -> copy
rvalue -> 원본의 현재 값을 보존할 필요 없음 -> move 가능

과 같이 서로 다른 동작을 구현할 수 있다.


copy assignment와 move assignment도 같은 원리이다


대입 연산자도 마찬가지이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Data
{
public:

    Data& operator=(const Data& other)
    {
        // Copy Assignment
        return *this;
    }

    Data& operator=(Data&& other)
    {
        // Move Assignment
        return *this;
    }
};

다음 코드에서는 copy assignment가 호출된다.

1
2
3
4
Data a;
Data b;

a = b;

b가 lvalue이기 때문이다.

반면

1
a = std::move(b);

에서는 move assignment가 호출된다.
멤버 역시 자원을 이동시키고 싶다면 명시적으로 std::move()를 사용한다.

1
2
3
4
5
6
Data& operator=(Data&& other)
{
    name_ = std::move(other.name_);

    return *this;
}

other의 타입은 Data&&이지만 함수 내부의 other라는 표현식은 lvalue이기 때문이다.


move overload가 없으면 어떻게 될까?


다음 함수만 있다고 해보자.

1
void process(const Data& data);

그리고

1
2
3
Data data;

process(std::move(data));

를 호출한다.

std::move(data)는 여전히 rvalue이다.
하지만 const T&는 rvalue도 받을 수 있으므로 호출이 가능하다.
즉 rvalue가 다시 lvalue로 변한 것은 아니다.

단지

1
const Data&

가 rvalue도 받을 수 있기 때문에 해당 overload가 선택된 것이다.
반면 다음 함수만 있다면

1
void process(Data& data);

아래 호출은 불가능하다.

1
process(std::move(data)); // error

일반적인 T&는 rvalue에 바인딩할 수 없기 때문이다.
기본적인 관계는 다음과 같이 정리할 수 있다.

1
2
3
4
5
             lvalue       rvalue
T&              O            X
const T&        O            O
T&&             X            O
T               O            O

단, template에서 사용하는 T&&는 forwarding reference가 될 수 있어 별도의 타입 추론 규칙이 적용된다.


const 객체에 std::move를 사용하면 어떻게 될까?


다음 코드를 보자.

1
2
3
const std::string source = "hello";

std::string destination = std::move(source);

std::move(source)를 했으니 move가 발생할 것 같지만 일반적으로는 copy가 발생한다.
sourceconst이기 때문이다.
std::move(source)의 결과는 개념적으로

1
const std::string&&

이다.

하지만 일반적인 std::string의 move constructor는 다음과 같은 형태이다.

1
std::string(std::string&&);

자원을 가져오려면 원본 객체를 변경해야 하기 때문에 const 객체를 받을 수 없다.
따라서 사용 가능한

1
std::string(const std::string&);

copy constructor가 선택될 수 있다.

중요한 것은

1
std::move(const T)

복사한 다음 move 를 수행하는 것이 아니라는 점이다.
std::move()const를 제거하지 않는다.

1
2
3
4
5
const T
   ↓
std::move
   ↓
const T&&

가 될 뿐이다.
따라서 실제로 자원을 이동하는 것이 목적이라면 일반적으로 const T&&가 아니라 T&&가 필요하다.


const rvalue reference는 왜 거의 사용하지 않을까?


다음과 같은 타입도 문법적으로 가능하다.

1
const Data&&

하지만 move semantics에서는 거의 사용되지 않는다.
move의 핵심은 원본의 내부 상태를 변경하면서 자원을 새로운 객체로 이전하는 것이기 때문이다.
예를 들어 개념적으로 다음과 같은 작업이 필요할 수 있다.

1
2
3
destination.ptr = source.ptr;

source.ptr = nullptr;

하지만 sourceconst라면

1
source.ptr = nullptr;

과 같은 변경이 불가능하다.
따라서 자원 이전을 나타내는 rvalue reference는 일반적으로

1
Data&&

형태를 사용한다.
const Data&&는 특정 overload를 막거나 overload resolution을 제어하는 특수한 상황에서 사용할 수 있지만 일반적인 move 용도는 아니다.


temporary만 막는 데 const T&&를 사용할 수도 있다


const T&는 lvalue와 rvalue를 모두 받을 수 있다.

1
void process(const Data& data);

따라서 다음 코드가 모두 가능하다.

1
2
3
4
5
Data data;

process(data);

process(Data{});

그런데 기존 객체만 받고 temporary는 막고 싶다면 다음과 같은 overload를 만들 수 있다.

1
2
3
void process(const Data& data);

void process(const Data&&) = delete;

이제

1
process(data);

const Data&를 사용한다.
하지만

1
process(Data{});

에서는 rvalue인 Data{}const Data&&가 더 좋은 overload이므로 해당 함수가 선택된다.
그런데 함수가

1
= delete

되어 있으므로 컴파일 에러가 발생한다.
const T&&는 move보다는 이런 특수한 overload 제어에서 가끔 활용될 수 있다.


값으로 받는 방법도 있다


항상 const T&T&& overload를 각각 만들어야 하는 것은 아니다.
예를 들어 함수가 문자열을 최종적으로 자신의 멤버에 저장하는 경우 다음과 같이 작성할 수도 있다.

1
2
3
4
void setName(std::string name)
{
    name_ = std::move(name);
}

이 함수는 lvalue와 rvalue를 모두 받을 수 있다.

1
2
3
4
5
6
7
std::string name = "hello";

setName(name);

setName(std::move(name));

setName("hello");

lvalue를 전달하면 함수 인자인 name을 만들기 위해 복사가 필요하다.

1
2
3
4
5
호출자의 name
      ↓ copy
함수의 name
      ↓ move
name_

rvalue를 전달하면 move 또는 copy elision을 활용할 수 있다.

1
2
3
4
5
temporary
   ↓ move / elision
함수의 name
   ↓ move
name_

따라서 객체를 최종적으로 함수가 소유해야 하는 간단한 API에서는

1
void setName(std::string name)

과 같은 pass-by-value + move 방식도 자주 사용된다.
반면 성능을 세밀하게 최적화하거나 소유권 이전을 호출자에게 명확하게 요구하고 싶다면

1
2
3
void setName(const std::string& name);

void setName(std::string&& name);

처럼 overload를 나눌 수 있다.


move만 허용하는 타입도 만들 수 있다


클래스 자체를 복사할 수 없게 만들고 move만 허용할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Data
{
public:

    Data() = default;

    Data(const Data&) = delete;

    Data& operator=(const Data&) = delete;

    Data(Data&&) = default;

    Data& operator=(Data&&) = default;
};

이제 copy는 불가능하다.

1
2
3
Data a;

Data b = a; // error

하지만 move는 가능하다.

1
Data b = std::move(a);

이러한 타입을 move-only type이라고 할 수 있다.
std::unique_ptr이 대표적인 예이다.
std::unique_ptr을 보면 rvalue reference의 의미가 더욱 분명해진다.

1
auto robot = std::make_unique<Robot>();

std::unique_ptr은 하나의 객체만 자원의 소유자가 될 수 있다.
따라서 복사는 허용되지 않는다.

1
auto another = robot; // error

하지만 소유권을 이전하는 것은 가능하다.

1
auto another = std::move(robot);

개념적으로 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<before>

robot
  |
  v
Robot object

<after>

robot -> nullptr

another
   |
   v
Robot object

따라서 rvalue reference와 move semantics는 단순한 성능 최적화 이상의 의미를 가진다.
이 자원의 책임을 다른 객체에 넘긴다 는 소유권 이전을 타입 시스템으로 표현할 수 있다.

즉 rvalue reference는 복사하지 않고 소유권을 명시적으로 이전해야 하는 타입을 설계할 수 있게 해준다.


정리


lvalue와 rvalue를 이해할 때 가장 먼저 기억할 것은 이것들이 변수의 타입이 아니라 표현식의 성질이라는 점이다.

1
int value = 10;

에서

1
value

는 lvalue이고,

1
10

은 rvalue이다.
같은 value 객체도

1
value

라고 사용하면 lvalue이고,

1
std::move(value)

라고 사용하면 rvalue로 취급할 수 있다.

reference의 기본적인 역할은 다음과 같이 정리할 수 있다.

1
2
3
4
5
T& -> 수정 가능한 기존 lvalue를 참조한다.
const T& -> 읽기 전용으로 참조한다. -> lvalue와 rvalue 모두 받을 수 있다.

T&& -> 일반적으로 rvalue를 참조한다. -> 원본의 현재 값을 보존할 필요가 없다는 의도를 표현할 수 있다.
const T&& -> const rvalue reference -> 일반적인 move에는 적합하지 않으며 특수한 경우에 사용된다.

그리고 다음 코드는

1
std::move(value)

실제로 데이터를 이동시키는 것이 아니다. value를 rvalue로 취급할 수 있게 만드는 것이다.
실제 자원 이동은

1
Data(Data&&);

또는

1
Data& operator=(Data&&);

같은 move constructor와 move assignment가 수행한다.
또한 rvalue reference로 받은 변수라고 해도 이름을 사용하면 lvalue 표현식이 된다.

1
2
3
4
void setName(std::string&& name)
{
    name_ = std::move(name);
}

따라서 실제 move를 원하는 위치에서는 std::move()를 다시 사용하는 경우가 많다.

결국 C++의 lvalue와 rvalue를 가장 실용적으로 이해하면 다음과 같다.

1
2
lvalue -> 이 객체는 이후에도 사용할 수 있다. -> 기존 값을 함부로 가져가면 안 된다.
rvalue -> 이 값은 소모해도 된다. -> 내부 자원을 이동할 수 있다.

rvalue reference는 단순히 기존 reference보다 빠르게 동작하는 기능이 아니다.
객체의 값을 보존해야 하는 경우와 소모해도 되는 경우를 구분하고, 이를 기반으로 복사와 자원 이전을 선택할 수 있도록 만든 Modern C++의 핵심 기능이라고 볼 수 있다.

This post is licensed under CC BY 4.0 by the author.