R-Value Reference는 C++ 11에서 도입된 개념으로, C++ 11을 기점으로 이전 코드와의 상당한 성능 차이를 발생시키는 주요 개념이므로 잘 알고 있어야한다.
error C2106: '=' : left operand must be l-value
error C2106: '=' : 왼쪽 피연산자는 l-value이어야 합니다.
생각보다 종종 보는 에러이다.
다음 예시 코드를 보고 자신이 L-Value와 R-Value를 잘 알고 있는지 테스트해 보자.
정답은 접어두었다.
void func(int& l_ref)
{
cout << "L-value ref" << '\n';
}
void func(int&& r_ref)
{
cout << "R-value ref" << '\n';
}
int getResult()
{
return 100;
}
int main()
{
int x = 5;
const int c_x = 10;
int& l1 = x;
int& l2 = c_x;
int& l3 = 5;
const int& c_l1 = x;
const int& c_l2 = c_x;
const int& c_l3 = 5;
int&& r1 = x;
int&& r2 = c_x;
int&& r3 = 5;
int&& r4 = getResult();
const int&& c_r1 = x
const int&& c_r2 = c_x;
const int&& c_r3 = 5;
const int&& c_r4 = getResult();
cout << r3 << '\n';
r3 = 123;
cout << r3 << '\n';
func(x);
func(5);
func(getResult());
return 0;
}
void func(int& l_ref)
{
cout << "L-value ref" << '\n';
}
void func(int&& r_ref)
{
cout << "R-value ref" << '\n';
}
int getResult()
{
return 100;
}
int main()
{
int x = 5;
const int c_x = 10;
// L-Value references
int& l1 = x; // Modifiable L-Values
int& l2 = c_x; // Compile error. Non-Modifiable L-Values
int& l3 = 5; // Compile error. R-Values
const int& c_l1 = x; // Modifiable L-Values
const int& c_l2 = c_x; // Non-Modifiable L-Values
const int& c_l3 = 5; // R-Values
// R-Value references
int&& r1 = x; // Compile error. Modifiable L-Values
int&& r2 = c_x; // Compile error. Non-Modifiable L-Values
int&& r3 = 5; // R-Values
int&& r4 = getResult(); // R-Values
const int&& c_r1 = x; // Compile error. Modifiable L-Values
const int&& c_r2 = c_x; // Compile error. Non-Modifiable L-Values
const int&& c_r3 = 5; // R-Values
const int&& c_r4 = getResult(); // R-Values
// R-Value Modify
cout << r3 << '\n'; // 5
r3 = 123;
cout << r3 << '\n'; // 123
// L/R-Value parameters
func(x); // func(int& l_ref) called
func(5); // func(int&& r_ref) called
func(getResult()); // func(int&& r_ref) called
return 0;
}
L-Value와 R-Value
이름에서 알 수 있듯이 일반적인 상황에서 L-Value는 대입 연산자(=)를 기준으로 왼쪽에 있는 값이고 R-Value는 오른쪽에 있는 값이다. 그러나 이건 단순히 붙여진 이름의 기원일 뿐 정확한 정의는 아니다.
int a = 0; // a: L-value, 0: R-Value
int b = a; // b: L-value, a: L-Value
코드의 두 번째 줄을 보면 알 수 있듯이 오른쪽에 있는 변수 a도 L-Value이다.
따라서 정확한 정의로 이름이 있고 메모리 주소를 가지고 있는 값을 L-Value, 이름이 없고 메모리 주소 공간을 가지지 않는 임시 값을 R-Value라고 한다. 쉽게 말해 다시 값을 가져오고 수정할 수 있는 우리가 일반적으로 쓰는 변수들이 L-Value이고 할당을 위해 생성되는 임시적인 값들을 R-Value라고 한다고 보면 된다.
R-Value Reference
R-Value Reference는 C++ 11에 도입된 개념으로 기존에 저장하지 않았던 임시 값인 R-Value를 L-Value처럼 저장하고 수정할 수 있도록 만들었다. 이것이 왜 필요한지는 같이 사용되는 개념인 Move Semantics를 설명하고 나서 이해할 수 있다.
먼저 L/R-Value Reference를 매개변수로 갖는 함수를 호출하는 샘플 코드를 보면
void func(int& x)
{
std::cout << "L-Value" << '\n';
}
void func(int&& x)
{
std::cout << "R-Value" << '\n';
}
int main()
{
int a = 1;
func(a); // L-Value 출력
func(1); // R-Value 출력
return 0;
}
이렇게 넘기는 값이 L-Value인지 R-Value인지에 따라 다른 함수가 호출되는 것을 볼 수 있다.
이처럼 L-Value Referece에서는 &를 한 개, R-Value Reference에서는 &을 두 개 사용한다.
기호를 공유할 뿐 기존에 쓰이던 논리 연산과 아무런 관계가 없다.
Move Semantics - std::move()
Move Semantics이란 C++ 11에 도입된 개념으로 객체의 리소스를 복사하지 않고 소유권(Ownership)을 이전하는 방식이다.
R-Value Reference와 병용되며 객체를 복사(copy semantics)하지 않고 이동(move semantics)을 통해 불필요한 메모리 할당과 복사를 방지할 수 있게 되었다.
주의사항으로 std::move() 함수는 매개변수로 들어온 L-Value를 R-Value로 반환할 뿐 직접 이동시키는 함수는 아니다.
Move Semantics가 일어나기 위해서는 이동 생성자(Move Constructor)가 호출되거나 대입 연산자(=)를 사용할 때 비로소 이동이 일어난다. 따라서 std::move()는 이동을 시키기 위해 R-Value로 만들어 놓는 사전 작업이라 볼 수 있다. 함수 이름이 move()라 혼동할 수 있겠다.
참고사항
Syntax: 문장이 문법적으로 문제가 없는지 확인
Semantics: 문장의 의미가 맞는지 확인
예를 들어 정수형 x, y를 더하는 것과 문자열 string s1, s2를 더하는 것은 같은 + 기호를 사용하지만 정수형은 값을 더하는 반면 문자열은 + 연산자를 재정의 하여 두 문자열을 이어붙이며 다르게 동작한다. 이렇게 문장의 의미를 분석하며 동작 방식을 확인하는 것이 Semantics이다.
std::vector<int> v1 = { 1,2,3,4,5 };
std::vector<int> v2;
std::move(v2); // 할당하지 않았으므로 v1의 데이터가 옮겨지지 않음
std::string a = "abc";
std::string b = a; // 복사 (Copy semantics)
std::string c = std::move(a); // 이동 (Move semantics). a의 데이터를 가져오며 소유권 변경
std::vector<int> v1 = {1,2,3,4,5};
std::vector<int> v2 = v1; // 복사 (Copy semantics)
std::vector<int> v3 = std::move(v1); // 이동 (Move semantics). v1의 데이터를 가져오며 소유권 변경
아직 감이 잘 안오면 해당 코드를 breakpoint하여 한 줄씩 어떻게 변하는지 확인해 보는것을 권장한다.
추가사항으로 C++ STL에서는 주요 함수에 R-Value가 들어올 상황을 감안하여 미리 다 오버로딩 해놓았다.
기존에도 Call by Reference 방식을 사용하면 L-Value에서도 복사하지 않고 사용하지 않았나?
완전한 이해를 위해 L-Value에서 사용하던 Call by Value, Call by L-Reference와 Call by R-Reference 를 메모리 구조와 비교해볼 필요가 있다.
void callbyValue(std::string s)
{
std::string b = s;
}
void callbyLRef(std::string& s)
{
std::string b = s;
}
void callbyRRef(std::string&& s)
{
//std::string b = s; // X
std::string b = std::move(s);
}
int main()
{
std::string a = "abc";
callbyValue(a);
callbyLRef(a);
//callbyRRef(a); // Compile error
callbyRRef("abc");
return 0;
}
void callbyValue(std::string s)
1. string a 변수를 위해 스택 메모리 영역에서 위에서 부터 a, 힙 메모리 영역에서 아래에서 부터 "abc"가 저장된다.
2. callbyValue(string s)가 호출되면서 스택 메모리 영역에 a 밑에 s, 힙 메모리 영역에 a가 가리키는 "abc" 위로 "abc"가 저장된다.
3. 로컬 변수 b를 저장하기 위해 스택 메모리 영역에 s 밑에 b, 힙 메모리 영역에 s가 가리키는 "abc" 위로 "abc"가 저장된다.
결국 callbyValue를 실행시키기 위하여 "abc"가 2번 Copy 된다.
callbyLRef
기존에 우리가 크기가 큰 vector나 클래스등을 value가 아닌 reference로 매개변수를 받으며 불필요한 복사를 막던 방법이다.
1. string a 변수를 위해 스택 메모리 영역에서 위에서 a, 힙 메모리 영역에서 맨 아래에 "abc"가 저장된다.
2. &s는 a가 가리키는 "abc"를 동일하게 가리킨다.
3. 로컬 변수 b를 저장하기 위해 스택 메모리 영역에 s 밑에 b, 힙 메모리 영역 a와 s가 가리키는 "abc" 위로 "abc"가 저장된다.
callbyLRef를 실행시키기 위하여 "abc"는 한번 Copy 된다.
callbyRRef
대망의 R-Value다.
main()에서 이름과 주소가 없는 임시 값 "abc" R-Value로 넘겨줬지만
함수 매개변수 내에서는 s라는 이름을 가지고 있으므로 callbyRRef 함수 내에서는 s가 L-Value가 된다. 따라서 주석처리해둔 부분으로 실행하게 되면 callbyLRef와 마찬가지로 "abc"가 한번 복사가 일어나며 R-Value Reference를 사용하는게 의미가 없다.
그러나 여기서 std::move()를 이용하여 변수 &&s를 R-Value로 바꿔주게 되면 b는 "abc"를 바로 가져가게 되며 소유권이 이전된다. 따라서 복사가 한번도 일어나지 않는다.
실전 예제
다음과 같은 Cat 클래스가 있다.
class Cat
{
public:
void setName(std::string name)
{
this->name = name;
}
private:
std::string name;
};
int main()
{
Cat tabby;
std::string s = "Tabby";
tabby.setName(s);
tabby.setName("Tabby");
return 0;
}
위의 코드에서는 L-Value를 넘길 때 2번 복사가 일어난다. 배운 개념을 도입해서 바꿔보자.
void setName(std::string&& name)
{
this->name = move(name);
}
tabby.setName(s); // Compile error.
tabby.setName("Tabby");
void setName(std::string& name)
{
this->name = move(name);
}
tabby.setName(s);
tabby.setName("Tabby"); // Compile error.
문제점1. L-Reference, R-Reference를 매개변수로 받는 방식으로는 동일한 함수 setName()으로 L-Value, R-Value를 전부 받을 수 없다.
문제점2. move를 하면 소유권이 넘어가 main함수의 string s의 데이터가 손실된다.
그렇다면 모든 setter에 L-Value 버전 R-Value 버전을 다 만들어야할까? 또한 main의 string s 데이터를 유지하고 싶다면?
먼저 main 함수의 문자열 s의 데이터 손실을 막기 위해선 const를 사용하면 된다.
class Cat
{
public:
void setName(const std::string& name)
{
this->name = move(name); // warning C26478
}
std::string getName()
{
return name;
}
private:
std::string name;
};
int main()
{
Cat tabby;
std::string s = "Tabby";
tabby.setName(s);
tabby.setName("Tabby");
std::cout << s << '\n'; // Tabby. s의 데이터가 제대로 남아있다.
std::cout << tabby.getName() << '\n'; // Tabby
return 0;
}
그러나 이 방식은 R-Value로 매개변수가 넘어갔을 때도 name에 복사가 발생한다. 고생한 보람이 없다. 또한 표준이 아니다. 위 방식대로 하면 move(name)에서 경고가 뜬다.
우리의 목적은 L-Value와 R-Value 둘 다 받을 수 있으면서
L-Value가 넘어왔을 때 1번의 복사, R-Value로 넘어왔을때 0번의 복사가 일어나길 원한다.
매개변수를 Call by Value으로 받고 함수 내에서 move()를 사용하자
class Cat
{
public:
void setName(std::string name)
{
this->name = std::move(name);
}
std::string getName()
{
return name;
}
private:
std::string name;
};
int main()
{
Cat tabby;
std::string s = "Tabby";
tabby.setName(s); // 1 copy
tabby.setName("Tabby"); // 0 copy
std::cout << s << '\n';
std::cout << tabby.getName() << '\n';
return 0;
}
먼저 L-Value인 tabby.setName(s); 가 실행됐을때 상황을 보자.
값을 매개변수로 받았으므로 당연히 copy가 발생한다. 그러나 여기서 std::move()를 하면
매개변수 name은 tabby의 멤버변수 tabby.name에게 데이터를 넘겨주고 소유권을 잃게 되며 한번 더 복사가 일어나지 않는다(위에서 callbyValue() 상황과 비교해보면 한 번의 복사가 덜 발생한다)
따라서 L-Value를 매개변수로 넘기는 상황에서 값으로 넘기더라도 move()를 활용하여 단 한번의 복사가 발생한다. 또한 값으로 넘겨줬으므로 main에 string s의 데이터가 보존된다.
다음으로 R-Value를 넘기는 tabby.setName("Tabby")이 실행됐을 때 상황을 보자.
컴파일러는 이미 setName에 R-Value가 넘어오는 것을 이미 알고 있으므로 임시로 "Tabby"를 저장해두었던 것을 추가로 복사하지 않고 바로 매개변수 name 멤버변수에 할당한다. (이것을 copy elision이라 한다.)
copy elision이란 컴파일러가 복사 또는 이동 연산자를 회피 할 수 있으면 회피하는 것을 허용하는 방식이다.
이후 move()가 실행되어 name의 데이터는 tabby.name으로 넘어가며 소유권을 잃게된다.
최종적으로 값 타입 매개변수+move 조합으로 L-Value에서도 복사를 한번 줄이고, R-Value 마저 같은 setter로 사용 가능하고 main의 원본 데이터도 보존했다.
Call by Value + std::move() 조합으로 해결 가능하다면 왜 STL은 함수를 오버로딩 하는가
위처럼 setName을 합칠 수 있다면 예시로 들었던 std::vector의 push_back 같은 주요 함수들은 왜 L-Value 버전, R-Value 버전을 따로 만들어뒀을까?
컴파일러 최적화인 복사 생략(copy elision)이 발생 해야 해당 조합으로 복사가 생략되며 성능 최적화를 꾀할 수 있는데 STL에서는 사용자의 모든 상황에서 copy elision이 발생할 것이라고 보장할 수 없으므로 두 가지 방식의 함수를 오버로딩 해놓았다.
따라서 우리도 copy elision가 발생하지 않는 상황이고 이를 최적화 하고 싶다면 두 가지 방식을 오버로딩 해야한다.