안녕하세요, katte입니다.
이번 글에서는 드디어 미루고 미루던 rvalue 참조와 move semantics에 대해 정리해보려고 합니다.
원래 나중에 하려고 계속 미루고 있었는데 이 내용을 알아야 이해할 수 있는 내용이 자꾸 나오길래 그냥 빨리 정리해버리기로 했습니다ㅎㅎ..
rvalue는 우측값, 즉 대입 연산에서 오른쪽에 오는 값으로, 주소를 얻어내기 위해 주소 연산자&를 사용할 수 없는 값을 의미합니다.
예를 들자면 리터럴 상수(C스타일 문자열은 포함X), 레퍼런스가 아닌 함수의 리턴값, x+y와 같은 expression이 있습니다.
반대로 lvalue는 주소를 얻어내기 위해 주소 연산자를 사용할 수 있는 값이며, 이름이 있는 대부분의 객체가 포함됩니다.
가장 흔하게는 변수가 있을 것입니다.
우리가 흔히 써왔던 다음과 같은 형식의 참조는 lvalue 참조로,
int a = 2;
int& b = a;
//int& c = a + 2; error!
const int& c = a + 2;
rvalue에 대해서는 참조할 수 없습니다.
단, 참조 형식이 const인 경우에만 제한적으로 rvalue를 참조할 수 있습니다.
rvalue 참조는 rvalue를 대상으로 하는 참조입니다.
int x = 2;
int y = 3;
int&& rr = x + y; //rr은 값 5로 바인딩됨
x = 3;
std::cout << rr; //5 출력, x나 y가 바뀌어도 rr은 영향받지 않음
4행을 보시면, && 연산자를 통해 rvalue를 참조하고 있습니다.
이때, x+y의 값인 5가 rr에 바인딩 되는데, 흥미로운 점은 rvalue 참조를 통한 바인딩은 값이 주소를 얻을 수 있는 위치에 저장되도록 하는 결과를 가져온다는 것입니다.
즉, rvalue인 x+y에 주소연산자를 적용할 수는 없지만, 대신 x+y의 참조인 rr에는 주소연산자를 적용할 수 있다는 것입니다.
이렇듯 특정 주소에 데이터를 바인딩함으로써, rvalue 참조를 통해 데이터에 접근할 수 있도록 해줍니다.
또한 rvalue x+y와 x, y 변수 각각은 전혀 다른 것이기 때문에 rvalue 참조 후에 x, y 값을 변경하더라도 참조한 값은 변하지 않습니다.
이러한 rvalue 참조를 통해 move semantics를 구현할 수 있습니다.
move semantics, 이동 시맨틱을 이해하기 위해 간단한 상황을 예시로 들어보겠습니다.
만일 어떤 클래스 A가 있고, A를 리턴하는 함수 ftn1와 A를 매개변수로 받는 함수 ftn2가 있다고 가정해 봅시다.
이때, ftn1의 리턴값을 ftn2로 넘기는 경우를 생각해 보면, 다음과 같을 것입니다.
먼저 ftn1의 리턴값을 저장할 임시 객체를 생성하고, 대입 연산자를 사용해 그 임시 객체에 리턴값을 대입(복사)한 후, 복사생성자를 사용하여 ftn2의 매개변수를 만들 것입니다. 일련의 과정이 끝나면 임시객체는 소멸되겠죠.
클래스 A가 멤버가 많지 않고 그리 복잡하지 않다면 이는 문제가 되지 않을 수 있지만, 만약 1000개의 문자를 담고 있는 string 객체를 하나의 요소로 하는 20000개의 요소를 갖는 vector가 멤버라면 어떨까요?
이는 굉장히 비효율적인 과정이 될 것입니다.
사실 잘 생각해보면, 우리의 목적은 ftn1 리턴값을 ftn2 매개변수로 옮기는 것인데, 그러면 이런 비효율적인 작업을 하지 않고 ftn1 리턴값이 있던 자리의 이름을 ftn2 매개변수의 이름으로 바꿔치기해도 되는 것이 아닐까요?
즉, 데이터의 값을 하나하나 복사해 오는 것이 아니라, 데이터는 그 위치에 놔두고 데이터의 라벨링만 갈아치우면 되는 것이 아닐까요?
이러한 개념이 move semantics입니다.
어떤 데이터의 소유권을 a에서 b로 바꾸고 싶을 때, 데이터의 메모리 위치는 그대로 두고 라벨링만 바꾸는 것과 같습니다.
우리는 기존의 복사 생성자와 복사 대입 연산자에 move semantics의 개념을 도입해 이동 생성자와 이동 대입 연산자를 정의할 수 있습니다.
기존의 복사 생성자와 복사 대입 연산자는 const lvalue 참조형으로 매개변수를 받아 깊은 복사를 수행했습니다.
반면 이동 생성자와 이동 대입 연산자는 매개변수로 받은 객체 멤버들이 가리키는 주소를 가로챕니다.
그리고 가로챔을 당한 객체 멤버에는 null 포인터를 할당합니다. (객체 내의 포인터 멤버가 같은 메모리 주소를 동시에 가리키는 것은 이미 해제된 메모리에 접근하는 등의 문제가 발생할 수 있어 좋지 않기 때문입니다. 그리고 move semantics의 정의를 생각하면 이렇게 하는 편이 더 합당하겠죠.)
그런데 이렇게 move semantics를 구현할 때에는 실제로 깊은 복사를 해야하는 경우와 구분을 해야할 것입니다.
따라서 move semantics는 rvalue 참조를 통해 구현하게 됩니다.
말로 설명하면 잘 안 와닿을 수 있으니 코드를 통해 보도록 합시다.
class A
{
int n;
int* parr;
public:
A():n(0) { parr = nullptr; }
A(int len) :n(len) { parr = new int[n]; }
A(const A& copy) //복사 생성자
{
n = copy.n;
parr = new int[n];
for (int i = 0; i < n; ++i) parr[i] = copy.parr[i];
}
A(A&& mov) //이동 생성자
{
n = mov.n;
parr = mov.parr; //주소 가로채기
mov.parr = nullptr; //널 포인터 할당
mov.n = 0;
}
A& operator=(const A& copy) //복사 대입 연산자
{
n = copy.n;
delete[] parr; //원래 가리키던 메모리 해제
parr = new int[n];
for (int i = 0; i < n; ++i) parr[i] = copy.parr[i];
return *this;
}
A& operator=(A&& mov) //이동 대입 연산자
{
if (&mov == this) return *this;
n = mov.n;
delete[] parr; //원래 가리키던 메모리 해제
parr = mov.parr; //주소 가로챔
mov.parr = nullptr; //널 포인터 할당
mov.n = 0;
return *this;
}
~A() { delete[] parr; }
};
클래스 A는 int형 포인터 parr을 가지고 있고, parr에 할당된 메모리 크기에 대한 정보를 담고 있는 int형 변수 n을 가지고 있습니다.
복사 생성자와 이동 생성자, 복사 대입 연산자와 이동 대입 연산자의 차이가 보이시나요?
복사 생성자와 복사 대입 연산자는 parr에 새로운 메모리를 할당하여 깊은 복사를 수행하는 반면, 이동 생성자와 이동 대입 연산자는 매개변수로 전달받은 객체의 parr 값을 가로채고 대신 null 포인터를 할당합니다.
또한 이동 생성자와 이동 대입 연산자는 매개변수로 전달받은 객체의 멤버 값을 변경시키기 때문에 const를 사용하지 않는다는 차이도 있습니다.
그런데 이동 생성자와 이동 대입 연산자는 rvalue에 한정해서만 사용할 수 있다는 단점이 있습니다.
그렇기 때문에 C++에서는 lvalue를 rvalue로 변환시켜 반환해주는 함수 move()를 제공합니다.
함수 move는 그 이름 탓에 move semantics를 직접 수행하는 역할을 할 것 같은 느낌을 주지만, move semantics는 사용자가 직접 정의하는 이동 생성자와 이동 대입 연산자를 통해서만 수행되는 것이고, move 함수는 단지 lvalue를 rvalue로 바꾸어 반환하는 역할만 합니다.
따라서 move 함수를 사용하더라도 이동 생성자와 이동 대입 연산자가 정의되어 있지 않으면 move semantics는 수행되지 않습니다. 그저 const lvalue 참조로 참조되어 복사 대입 연산, 복사 생성을 수행하게 될 뿐입니다.
move 함수는 std에 정의되어 있습니다. 따라서 std::move(lvalue)와 같이 사용합니다.
(추가로 복사 생성자, 복사 대입 연산자와 마찬가지로 이동 생성자와 이동 대입 연산자도 정의되지 않은 경우 컴파일러가 기본 제공합니다.
그러나 소멸자나 복사 생성자, 복사 대입 연산자를 사용자가 정의했다면 컴파일러는 이동 연산자나 이동 대입 연산자를 자동으로 제공하지 않습니다. 마찬가지로 이동 생성자나 이동 대입 연산자를 사용자가 정의했다면 컴파일러는 복사 생성자나 복사 대입 연산자를 자동 제공하지 않습니다.)
'Computer > C++' 카테고리의 다른 글
[C++] 스마트 포인터 클래스 (0) | 2022.11.20 |
---|---|
[C++] value categories (0) | 2022.11.17 |
[C++] 함수 포인터 정리 (0) | 2022.11.11 |
[C++] 배열 포인터 정리 (0) | 2022.11.11 |
[C++] cout 관련(3) - 출력 형식 지정 (0) | 2022.11.06 |