C++ lvalue와 rvalue, 그리고 rvalue reference를 이해해보자
C++의 lvalue와 rvalue가 무엇인지부터 lvalue reference, rvalue reference, std::move와 move semantics가 왜 필요한지까지 설명한 글입니다.
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;
a와 b는 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이다.
그리고 value는 int&& 타입의 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가 발생한다.
source가 const이기 때문이다.
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;
하지만 source가 const라면
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++의 핵심 기능이라고 볼 수 있다.