[C++] C++의 move semantics (의미론적 이동?)
개요
C++에서 move semantics는 소유권을 이전해 주는 것! 제가 듣던 C++ 강의에서 나왔던 이야기입니다. 이전에 syntax는 문법에 맞는가를 이야기하는 것이고 semantics의 경우 내부적인 의미를 이야기합니다.
이 내용은 스마트 포인터를 보게 되면서 더 많이 알아보게 되는데요. 일단 기본적인 RAII 패턴을 지킨 클래스를 보면서 짚어보겠습니다. 저도 잘 머릿속에 잘 정리가 안된 개념이라 짚어가면서 해야 할 것 같네요.
RAII 패턴 + std::move로 보는 move란
#include <iostream>
#include <utility>
using namespace std;
template<typename T>
class RAII
{
public:
T* pointer;
RAII(T* _pointer)
: pointer(_pointer)
{
cout << "[constructor]\n";
}
~RAII()
{
if(pointer != nullptr)
delete pointer;
cout << "[Destructor]\n";
}
RAII(const RAII& _raii)
{
pointer = new T;
*pointer = *_raii.pointer;
cout << "Copy Constructor\n";
}
RAII(RAII&& _raii)
: pointer(_raii.pointer)
{
_raii.pointer = nullptr;
cout << "Move constructor\n";
}
RAII& operator = (const RAII& _raii)
{
cout << "Copy assignment\n";
if(&_raii == this)
return *this;
if(pointer != nullptr) delete pointer;
pointer = new T;
*pointer = *_raii.pointer;
return *this;
}
RAII& operator = (const RAII&& _raii)
{
cout << "Move assignment\n";
if(&_raii == this)
return *this;
if(pointer != nullptr) delete pointer;
pointer = _raii.pointer;
_raii.pointer = nullptr;
return *this;
}
T* operator *() const { return *pointer; }
T& operator ->() const { return pointer; }
};
위 RAII 클래스의 경우 생성자에서 객체 포인터를 받아오고 이후 copy / move 에 해당하는 생성자, 대입 연산이 정의된 클래스입니다. 여기서 객체 관리 클래스에 담을 빈 크래스 A를 만들고 copy와 move의 동작을 보겠습니다.
int main()
{
RAII<A> ex1(new A);
RAII<A> ex2 = ex1;
cout << ex1.pointer << " " << ex2.pointer << '\n';
RAII<A> ex3 = std::move(ex1);
cout << ex1.pointer << " " << ex3.pointer << '\n';
}
위 ex2 가 만들어 질 때는 Copy Constructor가 만들어지고 ex1과 ex2의 pointer 주소값도 다르게 나옵니다. 반면 아래 std::move를 사용한 문구에서는 기존 ex1의 pointer 주소값이 0으로 되고 ex1의 주소값이 ex3으로 옮겨지게 되었습니다.
위 std::move의 경우 L-value를 R-value로 변경해줍니다. 이를 통해서 이동과 관련한 연산들이 호출되게 됩니다. (함수 인자가 && 인 것들)
이걸 보면 기존의 일반적인 대입은 R-value를 대상으로 복사를 해서 비효율적인 연산이 필요하게 됩니다. 대신 std::move와 R-value reference를 인자로 받는 생성자, 대입 연산자를 정의하면 복사의 과정 없이 바로 이동이 되어 새롭게 복사를 하는 과정이 없게 됩니다. 얕은 복사라고 생각하면 편하게 이해할 수 있을 것 같네요.
따라서 C++ 에서 볼 수 있는 move의 경우 얕은 복사라고 이해하면 될 듯합니다.
왜 move semantics 인가?
기존의 연산이 부족했을까? 사실 생각해 봤을 때 객체 안에 엄청 큰 배열이 있다고 해보자. 이걸 다른 객체로 옮긴다고 하면 복사를 통해서 엄청난 연산을 잡아먹게 될 것입니다. 대신 객체를 가리키는 메모리 주소를 가리키는 포인터만 슬쩍 바꾼다면 복사에 큰 시간이 들지 않고 옮길 수 있게 됩니다.
위 코드에서 이전 객체 관리자에게 nullptr을 주는 건 이 과정에서 같은 메모리에 권한을 여러 곳에 주면 delete시 오류가 날 가능성이 농후하니 이전 객체에서 소유권을 뺏어가는 것이죠. 이게 move semantice에서 필수라는 이야기는 아닙니다.
swap과 관련한 이야기
이는 이펙티브 C++에서도 중요하게 다루게 되었는데 이번에 move와 함께 공부하면서 swap과 연관해 이해하는 내용이 많이 있었습니다.
template <typename T>
void swap(T &a, T &b) {
T tmp(a);
a = b;
b = tmp;
}
보통 swap 함수는 '복사 후 맞바꾸기' 전략을 사용해 구현이 되어 있습니다. 그런데 위에 예시처럼 객체가 너무 크기에 복사보다는 move를 통해서 swap 함수를 구현하고 싶은 경우가 있을 것입니다.
일단 기존 std에 있는 swap 함수가 사용될 수 있으니 이펙티브 C++ 항목 25에 나온 내용처럼 using std::swap;을 사용한 뒤 객체 내부에 swap 함수를 구현, 객체와 같은 네임스페이스 안에 비멤버 swap 함수를 만들어 둡니다.
template <typename T>
void swap(T &a, T &b) {
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
위 경우 대입 이동 연산자를 구현했기 때문에 위 함수의 move가 동작할 수 있게 됩니다. 이런 식으로 기존 swap 함수보다 빠르게 교체를 할 수 있게 됩니다. 여기서 주의할 점은 std::move는 lvalue를 rvalue로 변경하는 것뿐이지 실제 이동을 하는 함수는 아니다 라는 것입니다. 실제 이동은 우리가 정의한 대입 생성자, 대입 이동 연산자에서 하는 것이죠.
마무리 ver 1.0
일단 잠깐의 마무리를 해보겠습니다.
- move semantics는 얕은 복사처럼 동작. 실제 객체가 바뀌지 않고 객체를 가리키는 포인터의 이동만 있는 것
- std::move의 경우 lvalue를 rvalue로 변경해준다. 그 외 동작은 없음
- std::move로 rvalue 변경에 성공하면 이를 사용하기 위해 객체에 이동 생성자, 이동 대입 연산자가 있어야 합니다.
이 정도로 정리를 했는데 더 찾아보면 복잡한 내용의 이야기들이 많습니다. 그래서 이후의 부분들이 머릿속에 정리가 된다면 또다시 글을 써 보도록 하겠습니다. 당장 이후의 자료가 급하신 분들은 아래 참고 자료를 확인하시면 원하는 내용을 얻을 거란 예상이 됩니다.
참고자료
방법: 이동 생성자 정의 및 할당 연산자 이동(C++)
자세한 정보: 생성자 이동 및 할당 연산자 이동(C++)
docs.microsoft.com
씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)>
modoocode.com
What is move semantics?
I just finished listening to the Software Engineering radio podcast interview with Scott Meyers regarding C++0x. Most of the new features made sense to me, and I am actually excited about C++0x now...
stackoverflow.com