본문 바로가기
NC University/Advanced C++

5일차 - move semantics

by 날쑤 2015. 9. 14.

Intro

  포인터를 멤버변수로 가지는 간단한 클래스 하나를 생각해보자.

  보통 위와 같이 포인터를 멤버로 가지는 클래스는 복사 생성자에서 깊은 복사(deep copy)를 수행한다. (이하로 이를 일반 복사 생성자라고 칭함) 생성자 내에서의 깊은 복사는 원래 객체의 변경없이 원래 객체와는 완벽하게 독립적인 복사본을 만들어낸다. 하지만 메모리 할당을 수반하는 깊은 복사는 상황에 따라서는 과도한 operation일 수 있다. 대표적인 예가 vector의 capacity doubling에서 수반되는 객체 복사인데, 여기서의 원본 객체들(doubling 이전의 vector 내의 요소들)은 복사 이후에는 더 이상 사용되지 않고 그냥 버려진다. 그러므로 이런 특수한 상황에서는 깊은 복사보다는 소유권(ownership) 이전이 더 효율적일 것이다.

  소유권 이전은 얕은 복사(shallow copy)를 기반으로 하되, 복사 이후에 원본 객체는 소멸상태에 가까운 더미 객체로 만드는 것을 의미한다. 위의 클래스에 대해 소유권 이전 개념을 적용시킨 복사 생성자는 대충 아래와 유사한 모양일 것이다.

  하지만 이 생성자는 C++ 98에서는 일반 복사 생성자와 공존하기 어렵다는 문제점이 있다. 두 생성자가 모두 존재한다고 가정하고, 아래의 코드가 수행되는 방식을 생각해보자.

  위 코드의 두 번째 문장에서 호출되는 복사 생성자는 소유권 이전 방식의 복사 생성자이다. 깊은 복사를 하는 복사 생성자를 사용하려면 c1을 const Cat&으로 캐스팅하면 되지만, 이는 일반적인 복사 생성자를 사용하는 방식이라고 보기는 어렵다. 그러므로 소유권 이전을 하는 복사 생성자는 특수한 상황에서 제한적으로 사용되는 클래스에서만 적용이 가능했다. 하지만 C++ 11에서는 rvalue reference를 이용해서 두 생성자를 모두 취할 수 있게 되었다.

Move constructor & move assignment operator

  일반적으로 rvalue는 값이 포함된 문장의 끝에서 파괴된다. 그리고 rvalue로 객체를 초기화하는 경우에는 같은 이유로 원본인 rvalue는 객체 생성이 끝나면 더 이상 의미를 가지지 못한다. 그러므로 Typename var = rvalue에서 '='는 일반적인 복사 생성자보다는 소유권 이전 방식의 생성자가 더 적합할 것이다. 특히 위와 같이 객체의 멤버변수가 동적 메모리 할당을 필요로 하는 경우에는 그 효율은 더욱 높아진다.

  문제는 함수(생성자)에서 rvalue를 인자로 받는 방식인데 기존 C++과는 달리 C++ 11에서는 rvalue reference로 rvalue를 받을 수 있게 되었다. 또한 lvalue reference와 rvalue reference는 서로 명확히 구분되기 때문에 함수 오버로딩 기법을 적용할 수도 있다. 이 사실에 기반해서 C++ 11에는 rvalue reference를 파라메터로 갖는 새로운 타입의 생성자가 추가되었으며, 이를 이동 생성자(move constructor)라고 한다. 이동 생성자는 이름 그대로 호출되면 원본의 소유권을 대상으로 이전(move)하는 방식으로 객체를 생성한다. 이제 Cat 클래스에 대한 이동생성자를 추가해보자.

  한편 어떤 상황에서는 lvalue 객체에 대해서도 복사 생성자가 아닌 이동 생성자를 적용시켜야 할 필요성이 있을 수 있다. 이 때 가장 간단한 방법이 해당 객체를 rvalue reference로 캐스팅하는 것이다. C++ 11 이후부터 C++ 표준 라이브러리에서는 이러한 기능을 하는 move 함수를 제공한다. 이름과는 다르게 이 함수는 단지 객체를 무조건적으로 캐스팅만 할 뿐인데, EMC++에서는 이 함수의 이름이 이동할 수 있는 객체를 좀 더 쉽게 지정하기 위한 함수라는 점에서 붙었다고 설명하고 있다.

  아래의 코드는 EMC++에서 발췌한 move 함수의 개략적인 구현과 Cat 객체에 move 함수를 적용한 예를 보여준다.

  이동 생성자와 유사하게 이동 대입 연산자도 rvalue reference를 인자로 받도록 정의되어 있으며, 내부에서 소유권 이전이 일어난다는 것 외에는 일반적인 대입 연산자와 동일하다. Cat 클래스에 대해 이동 대입 연산자를 작성해보면 아래와 같다.

Example - swap operation with move semantics

  이러한 move semantics가 의미를 강하게 가질 수 있는 경우는 어떤 작업이 진행되는 동안 임시변수의 생성 및 이동이 수반되는 경우일 것이다. 이러한 작업의 대표적인 예로는 아마도 swap operation을 들수 있을 것이다. 일반적인 swap operation은 아마도 아래와 같이 구현될 것이다.

  위의 함수는 동작 자체는 문제가 없지만, 복사 생성이나 대입의 비용이 큰 객체에 대해서는 비효율적이다. 어차피 재사용되지 않고 파괴될 자원들에 대해 깊은 복사를 수행하기보다는 소유권 이전 방식의 객체 생성/대입을 이용하면 operation을 좀 더 효율적으로 만들 수 있다. 이제는 move semantic을 이용해서 swap operation을 수정하고 위에서 작성한 Cat 객체에 대해 이를 적용시켜보자.

  아마 조금만 생각해본다면 전자보다는 후자가 같은 기능을 하면서 더 적은 비용으로 동작한다는 것을 알 수 있을 것이다. 이처럼 move semantics는 상황에 따라 작게는 특정 operation, 크게는 프로그램 전체의 성능을 개선시키는 데 도움을 줄 수 있다.