이동 의미론으로 이동 처리하기
객체에 이동 의미론(move semantic)을 적용하려면
이동 생성자와 이동 대입 연산자를 정의해야 한다.
그러면 컴파일러는 원본 객체가 임시 객체로 되어있어서
연산을 수행한 후 자동으로 제거되거나
사용자가 명시적으로 std::move()를 호출하여 삭제될 때
앞서 정의한 이동 생성자와 이동 대입 연산자를 이용한다.
즉 메모리를 비롯한
리소스의 소유권을 다른 객체로 이동시킨다.
이 과정은 멤버 변수에 대한 얕은 복제와 비슷하다.
또한 할당된 메모리나 다른 리소스에 대한 소유권을
전환함으로써 댕글링 포인터나 메모리 누수를 방지한다.
이동 생성자와 이동 대입 연산자는 원본 객체에 있는
데이터 멤버를 새 객체로 이동시키기 때문에
그 후 원본 객체는 정상이긴 하나 미확정된 상태로 남게 된다.
흔히 이러한 원본 객체의 데이터 멤버의
널값으로 초기화하지만 꼭 그래야 하는 것은 아니다.
안전을 생각하면 이동되고 남은 객체를 사용하지 않는 것이 좋다.
예측하지 못한 동작이 발생할 수 있기 때문이다.
단, std::unique_ptr과 std::shared_ptr은 예외다.
표준 라이브러리는 이러한 스마트 포인터를 이동하고 나서
반드시 내부적으로 nullptr로 초기화하도록 명시하고 있다.
이동 후 남은 스마트 포인터를 다시 사용하는 일을 방지하기 위해서이다.
이동 의미론을 구현하는 방법을 배우기 전에
먼저 우측값과 우측값 레퍼런스부터 알 필요가 있다.
우측값과 좌측값
C++에서 말하는 좌측값이란 이름이 있는 변수처럼
주소를 가질 수 있는 대상을 가르킨다.
좌측값이라고 부르는 이유는
대입문의 왼쪽에 나오기 때문이다.
반면 우측값은 리터럴, 임시 객체, 좌측값이 아닌 나머지를 가리킨다.
일반적으로 우측값은 대입문의 오른쪽에 나온다.
우측값 레퍼런스
우측값 레퍼런스라는 개념도 있다.
말 그대로 우측값에 대한 레퍼런스다.
특히 우측값이 임시 객체이거나 std::move()로
명시적으로 이동된 객체일 때 적용된다.
우측값 레퍼런스는 오버로딩된 여러 함수 중에서
우측값에 대해 적용할 대상을 결정할 때 사용된다.
우측값 레퍼런스를 구현하면
크기가 큰 값(객체)를 복사하는 연산이 나오더라도
이 값이 나중에 삭제될 임시 객체라는 점을 이용하여
그 값에 우측값에 대한 포인터를 복사하는 방식으로 처리할 수 있다.
함수의 매개변수에 &&를 붙여서 우측값 레퍼런스를 만들 수 있다.
일반적으로 임시 객체는 const type&로 취급하지만
함수의 오버로딩 버전 중에서
우측값 레퍼런스를 사용하는 것이 있다면
그 버전으로 임시객체를 처리한다.
예를 들면 다음 코드와 같다.
여기에서는 먼저 handleMessage()함수를 두 버전으로 정의한다.
하나는 좌측값 레퍼런스로 받고,
다른 하나는 우측값 레퍼런스로 받는다.
void handleMessage(string& message) // 좌측값 레퍼런스
{
cout << format("handleMessage with lvalue reference : {}" , message) << endl;
}
void handleMessage(string&& message) // 우측값 레퍼런스
{
cout << format("handleMessage with lvalue reference : {}" , message) << endl;
}
다음과 같이 이름 있는 변수를 인수로 전달하여 handleMessage()를 호출할 수 있다.
string a {"Hello"};
handleMessage(a); // handleMessage(string& message) 호출
전달한 인수가 a라는 이름을 가진 변수이므로
handleMessage 중에서 좌측값 레퍼런스를 받는 버전이 호출된다.
이 함수 안에서 매개변수로 받은
레퍼런스로 변경한 사항은 a 값에도 똑같이 반영된다.
이번에는 handleMessage() 함수를
다음과 같이 표현식을 인수로 전달해서 호출하여 보자.
string b {"World"};
handleMessage(a + b); // handleMessage(string&& message) 호출
이때는 좌측값 레퍼런스를 인수로 받는 버전의
handleMessage() 를 적용할 수 없다.
a + b란 표현식의 결과로 생성되는 임시객체로
좌측값이 아니기 때문이다.
따라서 우측값 레퍼런스 버전의 handleMessage()가 호출된다.
전달된 인수는 임시 객체이므로 함수 안에서
매개변수의 레퍼런스로 변경한 사항들은 함수가 리턴된 후에는 사라진다.
handleMessage() 함수의 인수로 리터럴을 전달할 수도 있다.
리터럴은 좌측값이 아니기 때문에 이 때도
우측값 레퍼런스 버전의 handleMessage()가 호출된다.
물론, 리터럴을 const 레퍼런스 매개변수에 대한 인수로 전달할 수는 있다.
좌측값을 쓰면어 우측값 레퍼런스 버전의 handleMessage()를 호출하려면,
std::move()를 사용하면 된다.
std::move를 사용하면 좌측값을 우측값 레퍼런스로 캐스팅하는 것이다.
주의
우측값 레퍼런스 매개변수와 같이 이름 있는
우측값 레퍼런스는 타입이 우측값 레퍼런스일 뿐
이름이 있으므로 매개변수 자체는 좌측값인 점을 유의하자.
이러한 우측값 레퍼런스에 임싯값을 대입하면
우측값 레퍼런스가 스코프에 있는 동안 계속 존재할 수 있다.
즉, 수명이 연장되는 효과가 발휘된다.
이동 생성자와 이동 대입 연산자 활용 코드
이동 의미론 구현 방법
이동 의미론은 우측값 레퍼런스로 구현해야 한다.
클래스에 이동 의미론을 적용하려면
이동 생성자와 이동 대입 연산자를 구현해야 한다.
이때 이동 생성자와 이동 대입 연산자를 noexcept로 설정하여
두 메서드에서 익셉션을 절대 던지지 않는다고 컴파일러에게 알려줘야 한다.
특히 표준 라이브러리와 호환성을 유지하기 위해서는
반드시 이렇게 구현해야 한다.
예를 들어 표준 라이브러리 컨테이너의 완벽한 호환성 구현은
이동 의미론을 구현하고 익셉션도 던지지 않는다고 보장해야
저장된 객체를 이동시키기 때문이다.
다음 두 헬퍼 메서드를 한 클래스에 적용한다고 가정해보자.
cleanup()은 소멸자와 이동 대입 연산자에서 사용하고,
moveFrom()은 원본 객체의 멤버 변수를
대상 객체로 이동시킨뒤 원본 객체를 리셋시킨다.
export class Spreadsheet
{
public:
Spreadsheet(Spreadsheet&& src) noexcept; // 이동 생성자
Spreadsheet& operator=(Spreadsheet&& src) noexcept // 이동 대입 연산자
// 나머지 코드 생략
private:
void cleanup() noexcept;
void moveFrom(Spreadsheet &src) noexcept;
// 나머지 코드 생략
}
void Spreadsheet::cleanup() noexcept
{
for (size_t i {0}; i < m_width; i++)
{
delete[] m_cells[i];
}
delete[] m_cells;
m_cells = nullptr;
m_width = m_height = 0;
}
void Spreadsheet::moveFrom(Spreadsheet& src)
{
// 데이터에 대한 얕은 복제
m_width = src.m_width;
m_height = src.m_height;
m_cells = src.m_cells;
// 소유권이 이전되었기에 소스 객체 리셋
m_width = 0;
m_height = 0;
m_cells = nullptr;
}
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
moveFrom(src);
}
//이동 대입 연산자
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept
{
// 자기 자신을 대입하는지 확인한다.
if(this==&rhs)
return *this;
// 예전 메모리를 해제한다
cleanup();
moveFrom(rhs);
return *this;
}
이동 생성자 이동 대입 연산자는 모두 m_cells에 대한
메모리 소유권을 원본 객체에서 새로운 객체로 이동시킨다.
그리고 원본 객체의 소멸자가 메모리를 해제하지 않도록
원본 객체의 m_cells를 널 포인터로 리셋한다.
이 시점에는 그 메모리에 대한 소유권이 새 객체로 이동한 상태이기 때문이다.
당연한 말이지만,
이동 의미론은 원본 객체가 더 이상 필요 없어 삭제할 때만 유용하다.
참고로 방금 구현한 코드를 보면
이동 대입 연산자가 자기 자신을 대입하는지 검사한다.
이러한 검사는 현재 클래스의 종류나
그 클래스의 인스턴스를 다른 인스턴스로 이동시키는 방법에 따라서
필요 없을 수도 있다.
하지만, 개인적인 추천 방식으로는
자기 자신을 검사하여
프로그램이 실행 중에 뻗어버리지 않도록 방지하는 것을 추천한다.