Modern C++의 L-Value, R-Value & Move Semantics 이해하기
Modern C++의 L-Value, R-Value & Move Semantics 이해하기
0. 서문
본격적으로 들어가기 전에, 참조(&)와 포인터(*)에 대한 이해를 빠르게 되짚어봅시다.
참조(&)는 포인터(*)처럼 메모리 어딘가에 위치한 객체의 주소를 저장합니다. 하지만 참조는 한 번 초기화되면 다른 객체를 참조하도록 변경할 수 없고 null로 설정할 수도 없습니다.
값은 두 가지 타입으로 분류할 수 있습니다: l-value와 r-value. l-value와 r-value(좌측값과 우측값)의 정의는 C 언어에서 유래했습니다.
좌측값은 할당문의 왼쪽이나 오른쪽 양쪽에 나타날 수 있는 표현식이고, 우측값은 할당문의 오른쪽에만 나타날 수 있는 표현식입니다.
int a = 0;
a; // l-value
0; // r-value
Player player;
player; // l-value
Player(); // r-value
이 정의는 C++에서 다소 다르게 발전했습니다.
L-value는 메모리 위치를 참조하며 & 연산자로 참조할 수 있습니다. R-value는 l-value가 아닌 모든 것입니다. L-value는 그들이 나타나는 표현식을 넘어서 지속됩니다 R-value는 그들이 나타나는 표현식을 넘어서 지속되지 않습니다 L-value는 명명된 변수를 참조합니다 (&) R-value는 임시 객체를 참조합니다 (&&)
솔직히, 이 정의조차도 꽤 모호해서 많은 사람들을 혼란스럽게 합니다.
l-value와 r-value를 단계별로 자세히 살펴보겠습니다…
1. L-Values
L-value는 상대적으로 간단합니다.
다음은 l-value로 분류되는 것들입니다:
- 단일 표현식을 넘어서 지속되는 객체들!
- 주소를 가짐
- 명명된 변수
- const 변수
- 배열 변수
- 비트 필드
- 공용체
- 클래스 멤버
- l-value 참조(&)를 반환하는 함수 호출
- 문자열 리터럴
그렇다면 l-value 할당과 참조는 어떻게 작동할까요?
아래 코드와 주석을 살펴봅시다.
// 모두 l-value입니다
int a = 1; // 전역 변수 a -> l-value
int& function(){ // l-value function은 반환된 a의 주소로 초기화됨
a = 3; // a-> l-value, 할당 가능
return a;
}
int main()
{
int i = 3; // i는 l-value
i = 4; // i는 l-value, 할당 가능
// i -> 4
int *ptr = &i; // i는 l-value, & 연산자로 참조 가능, ptr은 i의 주소를 가리킴
// *ptr은 i의 값 -> 4
ptr = &a; // ptr은 int * 타입 포인터이므로 참조하는 것을 변경 가능
// *ptr은 a의 값 -> 1
int & r = i; // r은 l-value i의 주소로 초기화됨, 따라서 r은 l-value로 할당된 l-value
r = 5; // r이 가리키는 주소에 값을 씀, i의 값을 변경 (r과 i 둘 다 5)
// r과 i 둘 다 같은 주소를 가리킴 -> 5
int c = function(); // l-value 참조는 초기화 중에만 주소를 저장
// c -> 내부 스택 연산 후 function의 반환값, 변경된 a 값 3
function() = 50; // function은 l-value, r-value 50을 할당 가능
// a의 값이 50으로 변경됨.
// 그리고 ptr은 a의 주소를 가리키므로 *ptr -> 50이 할당됨.
int d = a; // l-value d에 a의 값 50이 할당됨
int *ptr_2 = &function(); // int * 타입 ptr_2는 &function 사용 가능 (function은 l-value)
// 내부 스택 연산 후 function의 반환값, 변경된 a 값 3
// * ptr_2 -> 변경된 a의 값 -> 3
// c의 값도 변경됨 -> 3
// * ptr의 값도 변경됨 -> 3
return 0;
}
위에서 보듯이 L-value 할당은 상대적으로 명확하고 이해하기 쉽습니다.
2. R-Values
하지만 r-value는 정의하기가 다소 어렵습니다…
다음과 같이 말할 수 있습니다:
- l-value가 아닌 객체들… (대우명제 ㅎㅎ..)
- 사용되는 단일 표현식을 넘어서 지속되지 않는 임시 값들!
- 주소가 없는 객체
- 리터럴 (문자열 리터럴 제외)
- 참조로 반환하지 않는 함수 호출 (예: int function())
- i++과 i— (하지만 ++i와 —i 연산자는 l-value)
- 기본 산술, 논리, 비교 표현식 (+,-,*,=, <,> 등, 연산자 오버로드에 따라 다를 수 있음)
- 열거형 (enum)
- 람다 (컴파일러는 람다/익명 함수를 임시적인 것으로 처리)
몇 가지 예제를 살펴봅시다.
R-value 예제 1:
int num1 = 10;
int num2 = 15;
if (num1 < num2) // (num1 < num2)는 r-value
{
...
}
R-value 예제 2:
int function(); // function은 호출 후 지속되지 않음 -> r-value
function(); // r-value
int i = 0;
i = function(); // i는 r-value function의 반환값으로 할당된 l-value
int *ptr = &function(); // 에러!!! r-value의 주소를 참조할 수 없음
원래 C/C++ 설계자들은 r-value를 임시 객체로 정의했으므로, 기본적으로 r-value는 할당될 수 없습니다.
하지만 이는 Modern C++ (C++11)에서 r-value “move semantics”의 도입으로 변경되었습니다.
3. Move Semantics
다음 시나리오를 고려해봅시다:
//Math.cpp
std::vector<float> Math::ConvertToPercentage(const std::vector<float>& scores){
std:vector<float> percentages;
for (auto& score : scores){
//...
}
return percentages;
}
// main.cpp
#include "Math.h"
int main(){
std::vector<float> scores;
//...
scores = ConvertToPercentage(scores);
//...
}
main에서 ConvertToPercentage(scores)가 호출될 때,
Math 클래스의 이 함수는 percentages라는 std::vector<int> 타입의 임시 값인 r-value를 생성합니다.
그런 다음 할당 연산자 ”=“에 의해 scores에 할당됩니다.
C++11 이전(Modern C++ 이전)에는 어떻게 작동했을까요?
percentages의 임시 값이 scores 초기화 중에 생성된 메모리 영역에 할당될 때, 복사됩니다. 그런 다음 Math::ConvertToPercentage 함수 스택을 빠져나가면서 임시 r-value는 사라집니다.
여기에 꽤 불필요한 과정이 있습니다 - percentages 임시 값이 복사되는 순간입니다.
논리적으로 생각해봅시다.
임시 percentages 결과(r-value)를 저장하는 메모리 영역을 scores 초기화 중에 생성된 메모리 영역과 단순히 교체한다면, 스택을 빠져나갈 때 임시로 생성된 percentages 메모리 영역은 어차피 사라질 것이므로, “복사” 과정이 불필요해집니다.
불필요한 복사를 방지하는 이 방법이 Modern C++의 r-value 참조와 Move Semantics의 핵심입니다.
사실, 요즘 최신 컴파일러들은 이에 대해 꽤 똑똑합니다… 때로는 r-value 참조를 과도하게 사용하면 실제로 시스템이 느려질 수 있습니다. 😅
4. R-Value 참조 (&&)와 std::move
R-value 참조는 Modern C++ (C++11)에서 처음 도입되었습니다. & 연산자와 유사하게 작동합니다.
아래 예제는 move semantics를 사용하여 기본 타입을 참조하는 것을 보여줍니다:
#include <memory>
#include <utility>
float CalculateAverage(){
float average=3;
// ...
return average;
}
int main(){
int num = 10; // num -> l-value
//int && rNum = num; // 에러!!! num은 l-value -> int & lnum이 가능
int && rNum = std::move(num); // move는 l-value num을 참조할 수 있게 함
num = 30; // num을 변경하면 rNum도 변경됨 (참조값이므로)
int && rNum1 = 10; // OK, 10은 r-value
float && rAverage = CalculateAverage(); // OK CalculateAverage는 r-value
float tmp_1 = std::move(rAverage); // tmp_1은 l-value이므로 할당됨
float tmp_2 = rAverage; // tmp_2는 l-value이므로 할당됨
float && tmp_3 = std::move(rAverage); // tmp_3는 l-value이지만 r-value 참조값임
rAverage = 1.0f; // rAverage를 변경하면 tmp_3만 변경됨, tmp_1과 tmp_2는 할당된 값
}