C 표준에서는 대입 연산자(=)를 기준으로 왼쪽과 오른쪽에 모두 사용될 수 있는 값을 L-value, 오른쪽에만 사용될 수 있는 값이 R-value라 정의된다.
C++ 관점에서는 위 글이 완전히 틀린 얘기는 아니지만 잘못된 이해이며 C++ 관점에서는 전혀 다른 관점에서 해석할 필요가 있음.
L-value: 단일 표현식 이후에도 없어지지 않고 지속되는 객체. 쉽게 생각해서 이름을 가지는 객체. 그러므로 const 타입을 포함한 모든 변수는 L-Value.
R-value: 단일 표현식이 종료된 이후에는 더이상 존재하지 않는 임시적인 값. (이름이 없는)상수 또는 임시 객체는 R-value.
#include <iostream>
#include <string>
using namespace std;
int main()
{
int x = 3;
const int y = x;
int z = x + y;
int* p = &x;
cout << string("one");
++x;
x++;
}
추가로 ++x
는 L-value, x++
는 R-value이다. 둘 다 증가된 값을 리턴하지만 ++x
는 증가된 x
자신을 리턴하기에 L-value, x++
는 증가되기 전의 임시값을 리턴하기에 R-value이다.
L-value와 R-value의 확실한 구분법은 & 연산자를 붙여보는 것. & 연산자는 L-value를 요구하기에 표현식이 R-value라면 컴파일 오류가 발생한다.
int* ptr = &(++x);
//int* ptr = &(x++); // error C2102: '&' requires L-Value
const string& s = str;
와 같은 이제껏 우리가 사용했던 const 참조는 L-value 참조였다. 기존에 존재하던 변수(L-value)를 참조하였기 때문이다.
하지만 const 참조는 R-value로도 초기화 할 수 있다. const 참조는 non-const 변수, const 변수, R-value를 참조할 수 있다.
int x = 5;
const int& ref = x; // ok
const int y = 5;
const int& ref = y; // ok
const int& ref = 5; // ok
반면 비 const 참조에 대한 초기값은 L-value만을 허용한다.
class AAA {
public:
AAA() {
data = 0;
}
// 복사 생성자
AAA(const AAA& a) {
data = a.data;
// 깊은 복사
if (a.bptr != nullptr) {
bptr = new BBB(*a.bptr); // BBB의 복사생성자 호출
}
}
void setData(int n) {
data = n;
}
int getData() const {
return data;
}
private:
int data;
BBB* bptr = nullptr;
};
void Func_Copy(AAA a) {
cout << a.getData() << endl;
}
void Func_L_Value_Ref(AAA& ref) {
cout << ref.getData() << endl;
}
void Func_Const_Ref(const AAA& cref) {
cout << cref.getData() << endl;
// const가 붙은 함수만 접근할 수 있다는 제약이 있다.
// 멤버 데이터에 대해 read-only 하겠다.
// 따라서 cref로 setData 함수는 호출할 수 없다.
}
int main() {
AAA aaa;
Func_Copy(aaa);
Func_Copy(AAA());
Func_L_Value_Ref(aaa);
//Func_L_Value_Ref(AAA()); // 컴파일 에러: 비const 참조에 대한 초기값은 lvalue여야 한다.
Func_Const_Ref(aaa);
Func_Const_Ref(AAA()); // const 참조에 대한 초기값은 lvalue, rvalue 상관없다.
}
void Func_Copy(AAA a)
는 call by value 형태의 함수이기에 복사 생성자가 호출되고, 복사 생성자는 const 참조형이기에 임시객체(AAA()) 또한 전달할 수 있음을 알 수 있다. 따라서 Func_Copy(aaa);
과 Func_Copy(AAA());
둘다 호출 가능하다.
반면 void Func_L_Value_Ref(AAA& ref)
의 매개변수는 비 const 참조형이기에 L-value만 전달가능하다. 따라서 Func_L_Value_Ref(AAA());
에서 컴파일 에러가 발생한다.
void Func_Const_Ref(const AAA& ref)
의 매개변수는 const 참조형이기에 임시 객체 전달이 가능하다.
const 참조를 통해 임시 객체를 참조함을 알 수 있었다. 그렇다면 임시 객체를 참조하는게 어떤 의미가 있을까 의구심이 든다. 임시 객체는 말 그대로 행이 지나면 소멸되기 때문이다.
임시 객체 자체를 참조하는 것 자체에 의미가 있다기보단, 참조하는 객체를 복사하는 과정이 좀 더 간단해 질 수 있다는 것에 의미가 있다. 바로 참조 대상의 '소멸'을 가정하기 때문이다.
대게 클래스는 어떠한 리소스에 대한 포인터를 멤버로 담고 있다. 그리고 객체 간의 복사가 이루어질 때 포인터 멤버를 그대로 복사하는 것이 아니라 새로운 리소스 공간을 할당받고, 새로운 리소스 공간에 복사 대상의 리소스를 복사하는 깊은 복사 형태로 진행되길 기대한다. 따라서 복사 생성자나 복사 대입 연산자를 다음과 같이 구현한다.
AAA(const AAA& other)
{
data = other.data;
// 리소스에 대해 깊은 복사
if(other != nullptr) {
// (1) 기존 리소스 소멸
if(bptr != nullptr) {
delete bptr;
}
// (2) 리소스 공간 할당 및 해당 공간에 복사
bptr = new BBB(*a.bptr); // BBB의 복사 생성자 호출
}
}
문제는 이 리소스를 복사하는 비용이다. 리소스가 생성, 복사하는데 시간이 오래 걸리는 방대한 것이라면 깊은 복사를 하는 것이 부담이 될 수 있다. 깊은 복사 방식으로 구현한 복사 생성자는 call-by-value 형태의 함수에 객체를 전달하는 과정이나 참조형으로 반환하지 않은 함수에서 호출되는 등 꽤나 빈번하게 발생한다.
하지만 그렇다하더라도 복사 생성자를 얕은 복사로 구현할 순 없을 것이고, 다만 임시 객체를 참조하는 것에 힌트를 받아 무언가를 이동시키는 대상을 전달하는 것을 고려해 볼 수 있다. 객체 A에서 객체 B로 이동이 된다면, 객체 A는 추후 소멸되어도 상관없을 것이다. 그리고 객체 A는 소멸됨을 가정하기에 객체 B가 가리키는 리소스는 기존에 객체 A가 가리키는 리소스를 그대로 가리켜도 충돌되는 문제가 없기에 단순 복사 방식으로 복사를 진행하여도 무방하다. 따라서 이러한 이동의 개념으로 깊은 복사의 비용을 피하는 것이다.
모던 C++ 11부터 소멸을 가정한 R-value를 참조하는 우측값 참조 문법을 추가하였다. 임의의 타입 AAA에 대해 AAA&&를 AAA의 우측값 참조라고 정의한다(AAA&는 AAA의 좌측값 참조). 이 우측값 참조로 복사 생성자/복사 대입 연산자와 대비되는 이동 생성자/이동 대입 연산자를 정의할 수 있다.
class AAA {
public:
...
// 복사 생성자
AAA(AAA& a) {
data = a.data;
// 깊은 복사
if (a.bptr != nullptr) {
bptr = new BBB(*a.bptr); // B의 복사생성자 호출
}
}
// 이동 생성자
AAA(AAA&& a) { // 객체 a는 이동 대상이라고 생각!!
// 이동이란 뜻은 원본을 유지할 필요가 없다는 뜻이다.
data = a.data;
// 깊은 복사
//if (a.bptr != nullptr) {
// bptr = new BBB(*a.bptr); // B의 복사생성자 호출
//}
// 위와 같이 깊은 복사를 위한 구현이 필요없음
bptr = a.bptr;
a.bptr = nullptr; // a 원본 객체가 나중에 소멸될때 이 부분을 다시 delete 할 수 있기에
// => 얕은 복사를 통해 정보를 얻어오는 것이기에 속도 측면에서 유리하다!
}
// 이동 대입 연산자
void operator=(AAA&& a) {
data = a.data;
bptr = a.bptr;
a.bptr = nullptr; // 이동 대상의 객체 소멸을 대비
}
...
private:
int data;
BBB* bptr = nullptr;
};
// 오른값 참조
void Func_R_Value_Ref(AAA&& ref) {
cout << ref.getData() << endl;
ref.setData(5);
cout << ref.getData() << endl;
}
// => ref가 이동대상이란 의미, 즉 원본은 더이상 필요없다는 힌트를 준다.
int main() {
AAA a1;
a1.setData(100);
// 이동 생성자 호출
AAA a2(static_cast<AAA&&>(a1)); // a1은 더이상 사용하지 않는다라는 의미
// a1 원본을 날린다.
// a1 -> a2로 이동
AAA a3 = std::move(a2);
cout << a3.getData() << endl;
AAA a4 = static_cast<AAA&&>(a2); // 이동 대입 연산자 호출
cout << a4.getData() << endl;
}
임시 객체를 전달하며 임시 객체라는 R-value를 오른값 참조를 활용할 수 있다.
오른값 참조를 기반으로 한 이동 생성자, 이동 대입 연산자는 오른값을 더이상 사용하지 않는 객체로 취급하여, 깊은 복사가 아닌 얕은 복사로 해당 정보를 넘겨받으며 속도를 향상시킬 수 있다.
이동 생성자 AAA(AAA& a)
, 이동 대입 연산자 void operator=(AAA&& a)
의 a를 이동의 대상으로 여겨(원본을 유지할 필요가 없는 객체로 간주) 얕은 복사를 통해 정보를 얻어 속도적 측면에서 유리함을 가져오는 것이다.
=> "복사가 아닌 이동으로써 속도를 높인다!"
static_cast<AAA&&>(aaa) == std::move(aaa)
L-Value도 std::move를 사용하여 오른값 참조에 묶을 수 있음.
이름이 move지만 진짜 이동하는 것은 아니고 && 참조로 캐스팅만함.std::string s = "text"; std::string&& ref = std::move(s);
이 오른값 참조로 값이 다른 변수에 전달되고, 그 변수의 타입에 해당하는 이동 생성자(혹은 대입자)가 있다면, 무브 시맨틱이라는 것이 발생한다. 말 그대로 이동(move)를 수행한다.
컴퓨터에서 무거운 영상 파일들을 옮길 때
파일이 너무 무겁다면 그 파일을 복사하고 붙여넣는(ctrl+c, v) 속도도 엄청나게 느려짐.
하지만 파일을 잘라내고 붙일 때(ctrl+x, v)는 별다른 시간이 소요되지 않고 더 빠른 속도로 완료함.
잘라내기처럼 파일이 한 곳만 존재할 것을 보장하는 데서 발생하는 일종의 move이다.
위의 예처럼 무브 시맨틱도 이와 같은 원리이다.
다음 s 변수에 담긴 문자열의 크기가 크다고 가정.
std::string s = ".....";
std::string s2 = s;
위 코드에서 s2의 복사 생성자가 호출되면서 s의 내부값을 일일이 복사해서 가져감. 기존 s의 크기가 클수록 소요되는 시간도 선형적으로 증가.
std::string s2 = std::move(s);
// s를 std::string&&으로 캐스팅
// s2의 이동생성자 호출!
하지만 위와 같은 코드로 작성한다면 s의 길이와 상관없이 이 작업은 아주 작은 상수시간에 종료된다. 그리고 기존의 s는 텅 비게됨.
무브 시맨틱은 연산 중에 발생한 임시객체를 다룰 때도 효용을 보인다.
std::string s = "hello";
std::string s2 = "world";
std::string s3 = s + s2;
위 코드의 세번쨰줄에서 s와 s2가 더해져서 둘이 이어진 임시객체 std::string("hello world")가 생성. (std::string s3 = std::string("hello world"); 의 형태)
그리고 임시객체의 값을 복사해가기 위해 복사생성자가 호출되고 저 값들을 전부 복사해 갈 것이다. 당장 저 길이라면 상관없지만, 임시객체가 저것보다 훨씬 크다면 문제가 된다.
하지만 무브 시맨틱을 활용하면 성능을 높일 수 있다. s + s2의 결과물은 R-Value이다. 그러므로 이동생성자가 호출돼서 s + s2의 결과값이 낭비없이 s3에 쏙 넘어간다.
무브 시맨틱은 위와 같은 성능 향상 뺴고도, 하나의 장점이 또 있다.
무브 시맨틱은 말 그대로 move이다. 그러므로 move의 동작은 copy와 다르게 의미론적으로 유일성(unique)를 보장한다.
참고로 스마트 포인터인 std::unique_ptr 같은 경우 이런 move만 행할 수 있다. copy는 금지되어 있다.
auto p = std::make_unique<T>(...); // std::unique_ptr<T> p2 = p; // 에러! 복사 불가 std::unique_ptr<T> p2 = std::move(p); // p -> p3 이동. p는 null이됨.
R-Value 참조가 등장하면서 원래 4개였던 클래스 기본 함수들이 6개로 늘어남. 클래스명이 T일 경우 기본 함수들은 아래와 같음
T() = default; // 기본 생성자
~T() = default; // 기본 생성자
T(const T&) = default; // 복사 생성자
T& operator= (const T&) = default; // 복사 대입자
T(T&&) = default; // 이동 생성자
T& operator= (T&&) = default; // 이동 대입자
이동 생성자와 이동 대입자는 구현할 필요가 없다. 복사 생성자와 같은 얕은 복사의 문제는 없기 떄문에 그냥 =default만 해줘도 된다.(컴파일러에게 자동구현시키기.)
R-Value 참조자는 R-Value가 아니다. R-Value 참조자 자체는 이름도 있고, 주소연산도 가할 수 있으며, 표현식이 종료되어도 생존한다.
따라서 아래와 같은 예시에서 move는 발생하지 않는다.
std::string s = "....";
std::string&& s2 = std::move(s); // s -> s2 이동
std::string s3 = s2; // s2 -> s2 이동 x, 단지 복사 생성자가 호출될 뿐.
move 과정은 순전히 오른값으로만 전달되어야 함. 아래와 같이 수정하면 move 수행
std::string s = ".....";
std::string&& s2 = std::move(s); // s -> s2 이동
std::string s3 = std::move(s2); // s2 -> s3 이동
R-Value 참조자는 const를 붙이면 안됨. 에러는 나지 않지만, 이동에 실패.
결국 R-Value 참조는 굉장히 유용.
문제는 자잘한 규칙과 함정이 많고, 복잡함.
참고