bdfgdfg
[C++ 11] 오른값 참조 및 이동 의미(move semantic) 본문
먼저 이것을 설명하기 전에 lvalue와 rvalue의 개념이 잡혀있어야 한다.
C++에서의 모든 표현식은 lvalue 또는 rvalue이다.
lvalue는 단일 식(해당 코드 라인)을 넘어 없어지지 않고 지속되는 객체.
-> 주소가 존재하는 이름 있는 변수 (const타입 포함).
-> 문자열 리터럴 상수의 경우에는 lvalue이다.
반면 rvalue의 경우에는 단일 식을 넘어 지속되지 않는 임시적인 객체.
-> 임시 객체란 런타임에 잠깐 사용되는 객체로
-> 상수 또는 임시 객체는 rvalue라고 할 수 있다.
-> 주소가 없는 객체,리터럴 상수, i++, i--등등
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
int main()
{
int x = 100; // 100은 rvalue
100 = x; // rvalue에 값 대입은 불가능
const int y = x;
int* ptr = &x; // x는 주소가 있는 객체(lvalue)
std::string("hello"); // 이름없는 임시 객체. 해당 라인을 벗어나면 더이상 사용불가.
int* ptr2 = &(++x);
// x++,x--는 임시객체를 반환한다.
int* ptr3 = &(x++); // &연산자는 주소연산자. 임시객체는 주소연산자로 주소값을 구하지 못한다.
int& ref = x; // lvalue참조.
int& ref2 = (ref++); // rvalue참조는 안된다.(임시 객체이므로)
const int& ref3 = (ref++); // 단 상수 참조의 경우에는 가능하다.(임시객체는 ref3가 파괴될때까지 존재)
return 0;
}
|
cs |
이제 기존의 int&, double&.. 등등은 모두 lvalue참조자라는 것을 알 수 있다.
즉 단일 식 이후 지속되지 않는 임시 객체인 rvalue를 대상으로 lvalue참조자를 사용하지 못한다는 이야기.
하지만 C++11에서부터는 lvalue참조뿐만 아닌 rvalue참조자 또한 추가되었다.
Rvalue reference (오른값 참조)
lvalue 참조가 lvalue만을 참조할 수 있다면 C++11에서 추가된 rvalue 참조는 rvalue만을 참조할 수 있다.
lvalue참조는 자료형(타입)에 &키워드 하나만을 붙였다면 rvalue참조는 자료형에 &키워드를 두개 붙인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
class Test
{
public:
int m_test;
};
Test Rvalue()
{
return Test();
}
int main()
{
Test lvalue;
Test& lvalueRef = lvalue;
Test& lvalueRef2 = Rvalue(); // 불가능
// 에러 내용 : 비 const 참조에 대한 초기값은 lvalue이여야 함.
Test&& rvalueRef = lvalue; // 불가능
// 에러 내용 : rvalue 참조를 lvalue에 바인딩 할 수 없습니다.
Test&& rvalueRef = Rvalue();
return 0;
}
|
cs |
lvalue참조자에 rvalue를 할당하면 에러를 내뱉고 rvalue참조자에 lvalue를 할당하면 에러를 내뱉는다.
여기서 의문점이 드는게 왜 이런 Rvalue참조자가 무엇을 해결하기 위해 C++11에서 추가가 되었을까.
rvalue참조자는 move semantic을 지원하기 위한 기능이며 불필요한 복사를 방지하는 것을 목적으로 존재한다.
먼저 기존의 복사 생성자를 보자.
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
|
class Test
{
public:
Test() :m_ptr(new int[1000]())
{
std::cout << "생성자 호출" << std::endl;
for (int i = 0; i < 1000; ++i)
m_ptr[i] = i + 1;
}
~Test() { std::cout << "소멸자 호출" << std::endl; delete[] m_ptr; }
Test(const Test& other)
{
std::cout << "복사 생성자" << std::endl;
}
private:
int* m_ptr = nullptr;
};
Test GetTest()
{
Test a;
return a;
}
int main()
{
Test test = GetTest(); // 임시객체 a가 test로 복사되고(복사생성자) a는 사라진다.
return 0;
}
|
cs |
GetTest함수에서 Test 객체를 생성한 후 반환하는 모습.
실행 순서를 보면 먼저
1. GetTest함수 호출 후 Test 객체 생성(기본 생성자 호출)후 반환
2. 복사 생성자 호출. 함수의 반환값인 Test객체의 임시객체의 내용을 얕은 복사처리.(코드의 복사 생성자를 선언하지 않았다고 가정)
3. 임시 객체의 소멸자 호출.
-> 문제 발생.
문제가 발생하는 이유는 얕은복사를 진행했기 때문.
Test라는 클래스의 멤버는 포인터 멤버를 들고있고, 생성자 호출시에 동적할당후 소멸자에서 delete처리를 한다. 함수의 반환 후 복사가 끝나면 소멸자가 호출이 되기에 복사 대상이었던 test변수의 멤버 ptr은 지워진 메모리를 참조한다.
(댕글링 포인터)
그렇기에 최소한 깊은 복사를 진행해줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
|
class Test
{
public:
Test(const Test& other)
{
m_ptr = new int[1000];
for (int i = 0; i < 1000; ++i)
m_ptr[i] = other.m_ptr[i];
}
private:
int* m_ptr = nullptr;
};
|
cs |
하지만 생각해보면 GetTest함수의 객체는 반환 후 더이상 사용할일이 없기에 굳이 복사를 하지말고 소유권을 넘겨주면 어떨까라는 생각.
1
2
|
Test test = GetTest(); // (1)
Test test2 = test; //(2)
|
cs |
test의 경우에는 GetTest를 통해 임시객체를 반환받아 복사 생성자를 호출한다.
그리고 임시객체는 소멸한다.
test2의 경우에는 test객체의 내용을 복사한다. 그리고 test객체는 계속해서 존재한다.
여기서 복사와 이동의 개념을 분리한 것.
1번은 임시 객체를 넘겨주고 복사한 뒤 객체는 사라지기에 사실상 소유권을 넘겨도 상관없음.(이동)
2번은 임시 객체가 아닌 객체를 대상으로 복사를 진행하고 해당 객체는 계속해서 존재한다.(복사)
여기서 1번의 문제를 해결하기 위해 추가된 것이 Rvalue-reference와 Move semantic.
먼저 move함수를 보자.
1
|
std::move();
|
cs |
이 함수가 하는 역할은 간단하다.
rvalue참조자를 반환하며 인자로 넘긴 lvalue를 rvalue로 변환한다.
(간단하게 말하면 인자로 넘긴 값을 오른값 참조로 캐스팅한다)
이 함수와 더불어 클래스에는 이동 생성자와 이동 대입 연산자가 추가 되었다.
1
2
|
Test(Test&& other);
Test& operator=(Test&& other);
|
cs |
참고로 const는 붙이지 않는다. 임시 객체의 내용을 수정해야하므로. (보통 nullptr로 밀어버리기 위해)
이제 위의 것들을 조합하여 위에서의 불필요한 코드인 임시 객체를 반환하고 굳이 copy하는 로직을 고쳐보면
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
33
|
class Test
{
public:
Test(Test&& other) : m_ptr(other.m_ptr) // const는 안 붙인다.
{
std::cout << "이동 생성자 호출" << std::endl;
other.m_ptr = nullptr; // 이동했으면 nullptr로 밀어줘야한다.
}
Test& operator=(Test&& other)
{
std::cout << "이동 대입 연산자 호출" << std::endl;
m_ptr = other.m_ptr;
other.m_ptr = nullptr;
return *this;
}
private:
int* m_ptr = nullptr;
};
Test GetTest()
{
Test a;
return std::move(a);
}
int main()
{
Test t = GetTest(); // 이동 생성자 호출
Test k;
k = std::move(t); // 이동 대입 연산자 호출
return 0;
}
|
cs |
실행결과
즉 이제 임시 객체를 반환하는 경우에는 불필요한 복사를 막을 수 있는 것.
-> 이동 연산은 얕은 복사를 진행한다고 봐도 된다. 정확히는 소유권 이전.
이렇게 move연산은 상대방의 데이터를 얕은 복사하면서 소유권을 이전해오는 개념.
-> 메모리 재할당이 필요가없다.
하지만 위의 예제에서는 불필요한 복사가 없으니 속도가 빨라질것이라고 생각하지만 사실 그렇지는 않다.
포인터나 참조대신 객체 자체를 반환하는 함수에서 rvalue를 반환하는 것은 매우 느리다.
이유는 반환 값 최적화(Return Value Optimization. RVO)라고 하는 컴파일러 최적화를 무시해버리기 때문이다.
RVO는 C++11이후 표준으로 재정되어 컴파일러가 알아서 복사 없이 최적화를 진행하는 기법.
실제로 visual studio 2019에서 각각 Debug모드, Release모드로 실행해보면
1
2
3
4
5
6
7
8
9
10
11
|
Test GetTest()
{
Test a;
return a;
}
int main()
{
Test t = GetTest();
return 0;
}
|
cs |
Test클래스에서는 복사 생성자,대입연산자 이동 생성자,대입연산자 모두 정의된 상태이며, 각각 호출된 생성자와 대입 연산자의 함수의 이름을 콘솔에 출력하게끔 정의되어 있다.
Debug모드에서는 이동 생성자를 호출했지만 Release에서는 그 시도조차 하지 않은 것.
(이동생성자를 정의하지 않았을 시 Debug모드에서는 복사 생성자가 호출된다)
이러한 RVO기법은 2가지로 나뉘는데
1. 위에서 설명한 RVO.
2. NRVO(Named Return Value Optimization) - 말그대로 이름있는 임시 객체의 반환 최적화.
사실 위에서는 정확히 말하면 RVO가 아닌 NRVO 기법이 적용 되었음.
이름없는 임시 객체의 반환은 Debug모드에서도 복사 생성자와 이동 생성자 둘 다 호출되지 않는다.
NRVO는 Debug모드에서는 적용되지 않는듯한데 결국 둘 모두 Release모드에서는 적용된다.
정리
- 오른값 참조는 rvalue만을 참조할 수 있음
- 임시 객체가 아닌 지속적인 객체도 std::move를 통해 rvalue로 만들어 버릴 수 있다.
- 함수에서 std::move로 반환하는것은 매우느림. 반환 값 최적화를 무시하므로. 그렇기에 기본적으로 그냥 객체 반환.
- 그럼 std::move는 필요없는 기능이냐하면 아니다. 잘 쓰이지는 않지만 소유권을 이전한다는 개념과 뒤에서 배울 스마트 포인터의 일종인 유니크 포인터에서도 사용할 수 있다.
참고 사이트
https://vansoft1215.tistory.com/27
https://effort4137.tistory.com/entry/Lvalue-Rvalue
https://spikez.tistory.com/305
'게임프로그래밍 > C++' 카테고리의 다른 글
[C++ 11] 스마트 포인터(Smart Pointer) - 2 (0) | 2022.01.26 |
---|---|
[C++ 11] 스마트 포인터(Smart Pointer) - 1 (0) | 2022.01.26 |
[C++ 11] 람다(lambda) 식 (0) | 2022.01.24 |
[C++11] using, enum class (1) | 2022.01.22 |
[C++11] default,delete,final,override 키워드 (0) | 2022.01.22 |