(작성 중)R-Value Reference 그리고 std::move 와 move sementic
C++/C++ 2021. 3. 17. 02:25 |R-Value Reference
C++11부터 R-Value Reference라는 것을 제공한다.
이 R-Value Reference가 해결할 수 있는 몇가지 문제들을 살펴보자.
R-Value와 L-Value
C 언어에서 말하는 L-Value와 R-Value 차이
"An lvalue is an expression e that may appear on the left or on the right hand side of an assignment,
whereas an rvalue is an expression that can only appear on the right hand side of an assignment."
C 언어의 L-Value와 R-Value 차이 예시
int a = 42;
int b = 43;
// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok
// a * b is an rvalue:
int c = a * b; // ok, rvalue on right hand side of assignment
a * b = 42; // error, rvalue on left hand side of assignment
코드의 마지막 줄에있는 a*b 는 R-value이기 때문에 메모리 주소를 가지지 않으므로 여기에 뭔가를 대입할 수가 없다.
따라서 expression의 좌측에 올 수도 없어 컴파일 에러가 발생한다.
(어셈블리 관점에서 보면 간단하고 당연한 개념임)
즉, L-Value는 address-of operator인 &연산자를 이용하여 메모리 주소를 취할 수 있는 반면
R-Value는 그럴 수가 없다.
다음 예시를 보자.
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues:
int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue
int* p2 = &foobar(); 표현식에서 우측에 있는 foobar함수의 반환 값은 R-Value이다.
R-Value에 address를 취하려고 &는 것은 잘못된 접근이기 때문에 컴파일 에러가 발생한다.
하지만!
C++에서는 Modifiablilty와 Assignability 대한 미묘한 차이점을 가지는 User-defined type이 도입되면서
위의 내용은 약간 틀려진다.
그러나 C++11부터는 R-Value Reference라는 것이 도입되면서 이것이 가능해진다.
R-Value Reference를 설명하기에 앞서 몇가지를 생각해보자.
우선 다음과 같은 클래스가 있다.
class Test
{
public:
int *m_pResource;
int size;
//...
Test& operator=(Test const & rhs)
{
// Make a clone of what rhs.m_pResource refers to.
// Destruct the resource that m_pResource refers to.
// Attach the clone to m_pResource.
}
}
class Test는 대용량의 자원에 대한 포인터를 멤버로 가지고 있다.
따라서 Test는 생성/복사/파괴 시 상당한 비용이 들게 된다.
이제 다음 코드를 수행하면 어떤 작업이 발생하게 되는지 생각해보자.
Test foo();
Test test;
test = foo();
위 코드의 마지막 줄은 다음 작업을 한다.
- foo함수의 반환 시 임시 객체 Test생성(R-Value)
- test 선언 시 Test객체 생성
- test가 기존 데이터를 소멸시키고, 임시 객체를 test에 복사
- 임시 객체 소멸
위 과정을 살펴보면 임시 객체를 생성 후 복사하고 다시 소멸시키는 과정은 낭비인 것은 명백하다.
낭비를 해결하려면?
- foo함수가 반환한 Test 임시객체의 멤버 포인터(m_pResource)만 test에 넘겨주면 될 것이다.
그리고 임시객체의 멤버 포인터는 nullptr로 변경하여 소멸자가 호출되어도
test에게 넘겨준 자원이 delete되지 않도록 해주면 된다.
이런 동작을 move semantics이라고 한다.
move semantics를 제공하는 이동 생성자(Move constructor)와 이동 할당자(Move assignment)를 정의해보자.
Move Semantics 예제
Test(Test&& test)
{
if(m_pResource)
delete m_pResource;
size = test.size;
m_pResource = test.m_pResource;
test.m_pResource = nullptr;
}
Test& operator=(Test&& rhs)
{
if(m_pResource)
delete m_pResource;
size = test.size;
m_pResource = test.m_pResource;
test.m_pResource = nullptr;
return *this;
}
C++은 move semantics을 표현하기 위한 장치로 &&를 제공한다.
템플릿으로도 move semantics을 구현할 수 있지만
&&를 사용하여 간략화한 것이다.
따라서 타입 뒤에 &&가 붙으면 해당 타입의 R-Value Reference 타입을 의미한다고 생각하면 된다.
Test&& 이라면 Test의 R-value를 참조하는 타입이라고 생각하면 된다.
R-value Reference 타입은 R-Value를 참조할 수 있다.
move semantics를 구현하였을 때와 하지 않았을 때의 차이를 살펴보자.
아래 코드를 실행해보자.
Test Foo(int _size)
{
return Test(_size);
}
int main() {
Test test1(10);
// 임시객체 생성 후 m_pResource포인터 전달해주고 소멸
test1 = Foo(20);
// test2는 Foo에서 생성한 임시객체(R-Value)를 참조하는 R-Value Reference 타입이다. 따라서 소멸자가 호출되지 않는다.
Test&& test2 = Foo(30);
}
test1 = Foo(20); 부분을 살펴보자.
test1은 GL-Value이다.
Foo함수의 결과물로 Test 임시객체가 생성되고 test1에 복사된 후 소멸자가 호출된다.
반면 Test&& test2 = Foo(30); 부분을 보자.
test2는 R-value Reference 타입이다.
Foo함수가 반환한 값을 참조하겠다는 의도이다.
어셈블리를 까보면 알 수 있다.
7FF7FF46501A 라인에서 스택 프레임을 올린다.
7FF7FF46504B 라인을 보면 Foo함수가 반환할 값을 rax레지스터 넣는다.
7FF7FF465050 에서 스택 프레임을 내린다.
Move Semantics 구현하기 전 test1 = Foo(20); 쪽 설명
- 7FF62ED951CC : edx레지스터에 20을 저장 ( Foo함수에서 사용할 인자 )
- 7FF62ED951D1 : rcx레지스터에 현재 스택 포인터(rsp+0c0)를 저장 ( Foo함수로 이동하기 전 위치 저장 )
- 7FF62ED951D9 : Foo함수 호출
- 7FF62ED951DE : rax레지스터에 들어있는 Foo함수의 반환 값(임시 객체)을
아직 사용하고 있지 않은 스택 공간(rsp+0f0)에 임시로 저장
- 7FF62ED951E6 : 임시로 스택에(rsp+0f0) 저장 했던 임시 객체를 rax레지스터에 다시 불러온다.
- 7FF62ED951EE : 불러온 임시 객체를 다시 스택 공간(rsp+0f8)에 임시로 저장.
- 7FF62ED951F6 : rsp+0f8에 있는 임시 객체를 rdx레지스터에 저장.
- 7FF62ED95203 : 복사 할당자 호출
- 7FF62ED95208 : no operation
- 7FF62ED95209 : 처음에 저장했던 스택 포인터(rsp+0c0) 값을 rcx레지스터에 저장.
- 7FF62ED95211 : 임시 객체에 대한 소멸자를 호출 .
Test&& test2 = Foo(30); 설명
- 7FF62ED95216 : edx레지스터에 30을 저장( Foo함수에 사용할 인자 )
- 7FF62ED9521B : rcx레지스터에 현재 스택 포인터(rsp+58)를 저장( Foo함수로 이동하기 전 위치 저장 )
- 7FF62ED95220 : Foo함수 호출
- 7FF62ED95225 : no operation
- 7FF62ED95226 : rax레지스터에 현재 스택 포인터(rsp+58)를 저장
- 7FF62ED9522B : Foo함수가 반환한 임시 객체를 test2에 저장
주황색으로 표시된 부분이 임시 객체가 생성되고 복사되는 과정이다.
만약 이동 할당자(Move assignment)를 정의한다면 다음과 같이 바뀐다.
Move Semantics 구현된(이동 할당자 정의) 상태에서의 test1 = Foo(20); 쪽 설명
- 7FF6BC2E51CC : Foo함수에서 사용할 인자 20을 edx레지스터에 저장
- 7FF6BC2E51D1 : 현재 스택 포인터(rsp+0c0)을 rcx레지스터에 저장
- 7FF6BC2E51D9 : Foo함수 호출
- 7FF6BC2E51DE : Foo함수의 반환 값을 아직 사용하지 않은 스택 공간(rsp+0f0)에 저장
- 7FF6BC2E51E6 : 스택에 저장한 값을 rdx레지스터에 백업
- 7FF6BC2E51EE : test1을 rcx에 저장
- 7FF6BC2E51F3 : 이동 생성자 호출
- 7FF6BC2E51F8 : 저장해두었던 스택 포인터 위치(rsp+0c0)로 이동
- 7FF6BC2E51F3 : 이동 생성자 호출
std::move
함수의 인자로 객체를 전달하거나, 대입할 경우 임시객체가 생성되고 복사된 다음 소멸된다.
임시 객체를 생성시키지 않고 원본을 바로 전달해주는 것을 Move Semantics라 한다고 하였다.
string str = "asdf";
string str2 = std::move(str);
string str3 = std::move(str2);
위의 코드에서 std::move를 통해 str2에 str을 이동시키면 str="" 가 되고, str2 = "asdf"가 된다.
또 다시 str3에 str2를 이동시키면 str2 = ""가 되고, str3 = "asdf"가 된다.
std::move는 인자를 R-Value Reference 타입으로 바꾸어준다.
template <class _Ty>
constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
_Ty라는 타입의 원형으로 변환한 다음에 &&를 붙여 반환하는 것이다.
remove_reference는 어떤 타입을 넣어도 R-Value Reference Type으로 반환해주는 템플릿이다.
template <class _Ty>
struct remove_reference {
using type = _Ty;
using _Const_thru_ref_type = const _Ty;
};
template <class _Ty>
struct remove_reference<_Ty&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&;
};
template <class _Ty>
struct remove_reference<_Ty&&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&&;
};
template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;
참고로 move로 인해 껍데기만 남아버린 객체일지라도 move시킨 것과는 별개로
소멸 시점에 소멸자가 호출된다는 점을 혼동하지 말자.
코드 예시 1
class Test
{
public:
Test() = delete;
Test(int _size) :size(_size), m_pResource(new int[_size] {_size, })
{
std::cout << "Test(" << size << ")\n";
}
Test(const Test& x)
{
std::cout << "Test(const Test&)\n";
size = x.size;
m_pResource = new int[size];
std::copy(x.m_pResource, x.m_pResource + size, m_pResource);
}
~Test()
{
if (m_pResource)
{
std::cout << "~Test(" << size << ")\n";
delete m_pResource;
}
else
std::cout << "~Test(" << size << ") : No resource\n";
}
Test(Test&& x) noexcept
{
std::cout << "Test(Test&&)\n";
m_pResource = std::exchange(x.m_pResource, nullptr);
size = x.size;
m_pResource = x.m_pResource;
x.m_pResource = nullptr;
}
Test& operator=(const Test& rhs)
{
std::cout << "operator=(const Test&)\n";
if (m_pResource)
delete m_pResource;
size = rhs.size;
m_pResource = new int[size];
std::copy(rhs.m_pResource, rhs.m_pResource + size, m_pResource);
return *this;
}
Test& operator=(Test&& rhs) noexcept
{
std::cout << "operator=(Test&&)\n";
if (m_pResource)
delete m_pResource;
size = rhs.size;
m_pResource = rhs.m_pResource;
rhs.m_pResource = nullptr;
return *this;
}
private:
int* m_pResource;
int size;
};
Test Foo(int _size)
{
return Test(_size);
}
Test* Foo2(int _size)
{
return new Test(_size);
}
int TestAssem(int t)
{
return t;
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
Test test0(10);
// move semantics 정의 전 : Foo함수가 임시 객체 생성 > 임시 객체 복사하여 test0에 대입 > 임시 객체 소멸
// move semantics 정의 후 : Foo함수가 임시 객체 생성 > m_pResource포인터 전달해주고 소멸
test0 = Foo(20);
// move semantics 정의 전 : 복사 생성자 호출
// move semantics 정의 후 : 이동 생성자 호출
Test test1(std::move(test0));
// test2는 Foo에서 생성한 임시객체(R-Value)를 참조하는 R-Value Reference 타입이다. 따라서 소멸자가 호출되지 않는다.
Test&& test2 = Foo(30);
/* Foo2가 반환하는 것은 heap allocation된 객체가 있는 메모리 주소 값(포인터)이다.
따라서 R-Value는 주소 값(포인터)일뿐 heap allocation된 메모리는 소멸대상이 아니다.
그러므로 포인터 값만 test3에 복사된다.*/
Test* test3 = Foo2(40);
// Test 포인터 타입에 대한 R-Value Reference이다. test3와 마찬가지로 동작한다
Test*&& test4 = Foo2(50);
Test& test5 = test0; // 그냥 test1을 참조한다
Test&& test6 = std::move(test5); // 좌측 이 R-Value Reference이기 때문에 move semantics가 동작하지 않고 그냥 참조
delete test3;
delete test4;
return 0;
}
위의 코드를 실행해보고, 어셈블리를 분석해보면서 어떻게 동작하는지 확인해보자.
어셈블리 코드를 분석해보면 이동이 어려운 개념이 아니라
효율적인 면에서 너무나 당연하고 간단한 개념이라는 것을 알게 될 것이다.
R-Value Reference, Move Semantics, std::move 이런 것들의 개념을 이해하기가 어려운 이유는
많은 사람들이 실제 동작이 아닌 C++ 문법적인 개념과 C++코드를 가지고 이해하려고 하기 때문이다.
이 글을 작성하면서 어셈블리어를 좀더 깊이 공부해야겠다는 생각이 들었다.
C++ 개발자라고 말하려면 어셈블리어를 잘 이해하고 있어야 할 것 같다.
'C++ > C++' 카테고리의 다른 글
Visual Studio에서 특정 컴파일 경고를 오류로 처리하기 (0) | 2021.03.17 |
---|---|
가상함수(작성 중) (0) | 2021.03.17 |
헤더의 순환 참조(Circular dependency)와 전방 선언(forward declaration) (0) | 2021.03.16 |
Vector (작성 중) (0) | 2021.03.15 |
C++ 에서 C 스타일 코딩을 고집하지 말자 (0) | 2021.03.15 |