![](/assets/img/post/cpp.png)
Modern C++의 l-r-value & Move Semantic 에 대하여
0. Preface
들어가기 전에 참조(&)와 포인터(*)의 기본 개념을 짚어보자.
참조(&)는 포인터(*)와 같이 메모리 어딘가에 위치한 개체의 주소를 저장한다. 하지만 참조는 한번 초기화 된 후 다른 개체를 참조하거나 null로 만들 수 없다.
값(value)은 l-value 와 r-value 두가지로 분류가 가능하다. l-value 와 r-value 즉, 좌측값과 우측값의 정의는 C 언어로부터 내려온다.
좌측값은 대입시에는 왼쪽 혹은 오른쪽에서 오는 식이고, 우측값은 대입시에 오직 오른쪽에만 오는 식이다.
1
2
3
4
5
6
int a = 0;
a; // l-value
0; // r-value
Player player;
player; // l-value
Player(); // r-value
이는 C++ 에 넘어와서 다소 다른 방법으로 정의되었다.
좌측값은 어떠한 메모리위치를 가리키며 &연산자로 그 위치를 참조할 수 있다. 우측값은 좌측값이 아닌값이다. l-value 는 식이 끝난 후에 지속됨 r-value 는 식이 끝난 후에 지속되지 않음 l-value 는 이름있는 변수를 참조 (&) r-value 는 일시적인 개체를 참조(&&)
사실 이 정의도 너무나 모호하여 많은이들이 헷갈려한다.
자 이제 차근차근 l-r-value에 대해 좀 더 자세히 알아보자..
1. l-value
l-value는 다소 명료하다.
가능한 정리
- 단일식을 넘어서 지속되는 개체!!
- 주소가 있음
- 이름이 있는 변수
- const 변수
- 배열 변수
- 비트 필드 (bit-fields)
- 공용 구조체 (unions)
- Class 멤버
- 좌측 값의 참조(&)로 반환하는 함수 호출
- 문자열 리터럴
그렇다면 l-value의 대입 및 참조는 어떨까?
아래 코드와 주석을 보며 이야기하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 모두 l-value
int a = 1; // global 변수 a -> l-value
int& function(){ // l-value function은 return 하는 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 * 타입의 pointer이므로 참조하는 주소를 변경가능
// *ptr 는 a의 값 -> 1
int & r = i; // l-value인 i의 주소값으로 r은 초기화, 즉 r은 l-value 가 대입된 l-value
r = 5; // r이 가르키는 주소값에 그 위치에 값을 씀, 즉 i의 값이 변함(r 과 i 는 5)
// r 과 i 둘 다 같은 주소를 가르키고있음 -> 5
int c = function(); // l-vlaue의 참조는 초기화시에만 그 주소를 저장
// c -> function의 내부 스택의 연산후의 return 값인 바뀐 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 가능(funtion은 좌측값이므로)
// function의 내부 스택의 연산후의 return 값인 바뀐 a 값 3
// * ptr_2 -> 바뀐 a의 값 -> 3
// c 의 값 도 변함 -> 3
// * ptr 의 값도 변함 -> 3
return 0;
}
좌측값의 대입은 위처럼 비교적 명료하여 쉽사리 이해된다.
2. r-value
하지만 r-value 는 정의가 다소 어렵다..
일단 가능한 정리는 아래와 같다.
- l-value가 아닌 개체… (대우 명제ㅎㅎ..)
- 사용되는 단일식을 넘어 지속되지 않는 일시적인 값!!
- 주소가 없는 개체
- 리터럴(문자열 리터럴 제외)
- 참조로 반환하지 않는 함수 호출(e.g. int function())
- i++ 와 i– (하지만, ++ 와 – 연산자는 l-value 이다)
- 기본 산술식, 논리식, 비교식(+,-,*,=, <,> 등, 연산자 overload 시 달라질수 있음)
- 열거형(enum)
- 람다(lambda) (즉, 컴파일러는 람다(무명함수)를 일시적인 것으로 판단한다..)
예시들을 보자.
r-value 의 예제1
1
2
3
4
5
6
7
int num1 = 10;
int num2 = 15;
if (num1 < num2) // (num1 < num2)는 r-value
{
...
}
r-value 의 예제2
1
2
3
4
5
int function(); // funtion 은 호출후 지속되지 않음 -> r-value
function(); // r-value
int i = 0;
i = function(); // r-value function의 return값을 대입한 i 는 l-value
int *ptr = &function(); // ERROR!!! r-value의 주소는 참조할수 없다
초기 c/c++의 설계자들은 r-value를 일시적 개체로 정의했기 때문에 기본적으로 r-value에는 대입이되지 않게 만들어 두었다.
하지만 Modern C++ (stdc++11)에서 r-value의 “move semantic” 을 도입한 이후에 달라졌다.
3. Move Semantic
아래와 같은 시나리오를 생각해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//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 클래스내의 이 함수는 std::vector
이 경우 Modern C++ 이라 불리우는 C++1x 이전인 구 C++에서는 위와 같은 상황에서 어떻게 동작할까?
scores의 초기화시 생성된 메모리 영역에 percentages의 임시 값이 대입되는 순간에 복사가 된다. 그리고 임시 값인 r-value는 Math::ConvertToPercentage 함수 스택을 나오며 사라지게 된다.
여기에서 상당히 불필요한 과정이 있는데, 이는 percentages 임시값을 복사 하는 순간이다.
상식적으로 생각해보자.
임시로 생성된 percentages의 결과값(r-value)이 저장된 메모리 영역과 scores의 초기화 과정에서 생성된 메모리 영역을 단순히 바꿔(Swap)버리면, 어짜피 임시로 생성된 percentages의 메모리영역은 스택을 빠져나오며 없어질 것이기 때문에 “복사“라는 과정이 필요없게 된다.
이 불필요한 복사를 막는 방법이 바로 Modern C++ 가 자랑하는 r-value의 참조와 Move Semantic의 핵심이다.
사실 최근엔 컴파일러느님들이 알아서 잘해준다.. 사실 r-value의 참조를 너무 남발해서 시스템이 느려지는 경우도 종종발생한다. ㄷㄷ
4. r-value 참조 (&&) 와 std::move
r-value 의 참조는 Modern C++ 인 C++11에 처음 등장한 연산자이다. 기능상 & 연산자와 비슷한 역할을 한다.
아래 예제는 move semantic을 이용하여 기본 타입들의 참조를 보여준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <memory>
#include <utility>
float CaculateAverage(){
float average=3;
// ...
return average;
}
int main(){
int num = 10; // num -> l-value
//int && rNum = num; // Error!!! num 은 l-value -> int & lnum 은 가능
int && rNum = std::move(num); // l-value인 num을 참조할 수 있도록 해주는 move
num = 30; // num 을 바꾸면 rNum도 바뀐다 (reference 값이기때문)
int && rNum1 = 10; // OK, 10 은 r-value
float && rAverage = CaculateAverage(); // OK CaculateAverage 는 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의 참조(reference) 값
rAverage = 1.0f; // rAverage 를 바꾸면 tmp_3 값만 바뀜, tmp_1, tmp_2 는 대입
}