모두의 코드
씹어먹는 C++ 토막글 ① - Rvalue(우측값) 레퍼런스에 관해

작성일 : 2012-11-03 이 글은 57133 번 읽혔습니다.

이 글은 http://thbecker.net/articles/rvalue_references/section_01.html 에서 가져왔고 한국말로 번역되었습니다. 또한 저의 개인적인 C++ 능력 향상과 ' 저의 모토인 지식 전파'를 위해 모든 이들에게 공개하도록 하겠습니다.

안녕하세요? 이 글은 씹어먹는 C++ 과 이어지는 강좌는 아니고, 이번 새로나온 C++ 표준안 (C++11) 에 새로 포함되어 있는 r-value 레퍼런스, 흔히 말해 우측값 참조라는 새로운 기능에 대해 잘 정리 되어 있는 글을 번역한 내용 입니다. 원문은 여기 에서 확인하실 수 있습니다.

서론

우측값 참조(R value reference) 는 C++ 11 표준안에 새롭게 추가된 C++ 의 새 기능 인데 상당히 이해하기 까다롭습니다. 저는 C++ 커뮤니티에서 많은 사람들이 다음과 같이 말하는 것을 매우 자주 보았습니다.

  • "내가 우측값 참조를 이해했다고 생각했었는데... 또 잘 모르겠네"

  • "망할 우측값 참조들! 들을 때 마다 뭔 소린지 잘 모르겠어"

  • "우측값 참조를 어떻게 가르칠지... 공포스럽다"

우측값 참조를 이해하기 위해 가장 짜증나는 부분은, 도대체 우측값 참조를 왜 도입하였으며, 이를 통해 무엇을 해결하고자 하는지가 명확하지가 않기 때문입니다. 따라서 는 우측값 참조를 이해하기 위해서는 막무가내로 그 정의부터 보는 일은 올바르지 않다고 생각합니다.

더 나은 방법은, 우측값 참조 이전에 C++ 에서 어떠한 문제들이 제기되어 왔으며, 우측값 참조 기능의 도입으로 이 문제를 어떻게 해결하였는지 알아가는 것이 우선이라고 생각합니다. 이를 통해 이 글을 읽는 여러분들은 우측값 참조의 정의가 조금 더 마음에 와닿고 더 자연스러울 것입니다.

사실 우측값 참조를 통해 적어도 다음 두 개의 문제를 해결 할 수 있었습니다.

  1. move 의 구현 (move semantics)

  2. 완벽한 전달(perfect forwarding)

위 두 개의 문제에 대해 잘 모르겠더라도 걱정하실 필요 없습니다. 모두 아래에 다 잘 설명하였으니까요. 먼저, move 라는 것이 무엇인지 부터 살펴보도록 할 것인데요, 본론으로 들어가기 전에 C++ 에서 좌측값(lvalue) 와 우측값(rvalue) 가 무엇인지 부터 잠깐 동안 살펴보도록 합시다. 이들에 대한 완벽한 정의를 내리는 것은 조금 복잡한데요, 아래 예들을 보면 대충 무엇인지는 감이 잡힐 것입니다.

좌측값과 우측값의 대한 이전의 정의는 C 에서 부터 내려옵니다.

좌측값은 대입(assignment) 시에 왼쪽 혹은 오른쪽에 오는 식(expression)이고, 우측값은 대입 시에 오직 오른쪽에만 오는 식이다

예를 들어

int a = 42;
int b = 43;

// a 와 b 는 모두 좌측값이다.
a = b;      // ok
b = a;      // ok
a = a * b;  // ok

// a * b 는 우측값이다.
int c = a * b;  // ok. 우측값이 대입 연산에서 우측에 있으니까
a* b = 42;  // error. 우측값이 대입 연산에서 좌측에 있으니까

C++ 에서도 위와 같이 간단하게 생각 할 수 는 있지만, C++ 에서 여러가지 사용자 정의의 타입 때문에 C 에서의 정의가 직접적으로 들어맞지는 않습니다. 그래서 C++ 에서는 조금 다른 방법으로 정의를 하였는데, (물론 논쟁의 여지는 있지만) 이는 아래와 같습니다.

좌측값은 어떠한 메모리 위치를 가리키는데, & 연산자를 통해 그 위치를 참조할 수 있다. 우측값은 좌측값이 아닌 값들이다

예를 들면

// 좌측값들
//
int i = 42;
i = 43;            // ok, i 는 좌측값
int* p = &i;       //&i 를 쓸 수 있다.
int& foo();        // int& 을 리턴하는 함수
foo() = 42;        // ok, foo() 는 좌측값
int* p1 = &foo();  // ok, &foo() 를 할 수 있다.

// 우측값들
//
int foobar();  // int 를 리턴하는 함수
int j = 0;
j = foobar();         // ok. foobar() 는 우측값이다
int* p2 = &foobar();  // error. 우측값의 주소는 참조할 수 없다.
j = 42;               // 42 는 우측값이다.

Move 의 구현 (Move Semantics)

X 를 어떠한 리소스에 대한 포인터(예를 들어 m_pResource) 를 담고 있는 클래스라고 생각합니다. 참고로 여기서 리소스 라고 말하는 것은, 생성 또는 복사, 소멸 하기에 많은 시간이 걸리는 거대한 어떤 무언가를 의미합니다. 클래스 X 의 가장 좋은 예로 std::vector 를 들 수 있습니다.

std::vector 는 동적으로 할당 된 배열에 객체들을 보관하는 것인데요, 여태까지 C++ 을 충실히 배우신 분이라면 이 X 의 복사 대입 연산자는 아마 이렇게 구현하였을 것입니다.

X& X::operator=(X const& rhs) {
  // [...]
  // m_pResource 가 가리키는 리소스를 소멸한다.
  // rhs.m_pResource 의 복제된 버전을 생성한다.
  // m_pResource 가 복제된 버전을 가리키게 한다
  // [...]
}

그리고 비슷한 방법으로 복사 생성자를 구현하였다면 아래 코드에서;

X foo();  // foo 는 X 타입의 객체를 리턴하는 함수 이다!
X x;
x = foo();

위 소스의 마지막 줄에서는 만일 위의 X::operator= 의 과정을 따른다면 다음과 같은 일들이 진행됩니다.

  1. x 가 가지고 있는 리소스가 소멸된다.

  2. (foo 가 리턴한) 임시 객체의 리소스의 복제된 버전이 생성된다.

  3. x 가 복제된 리소스를 가리키고, 임시로 생성된 객체의 리소스는 소멸된다.

하지만 위 과정은 단순히 생각해보아도, 임시로 생성된 객체의 리소스의 복사된 버전을 굳이 만들 필요가 없습니다. 그냥,임시로 생성된 객체가 가리키고 있는 m_pResourceXm_pResource 와 서로 교환(swap) 만 해주면 됩니다.

그러면 어차피 나중에 임시 객체는 위 문장이 끝나면 알아서 소멸되니까 X 가 원래 가리키고 있던 객체도 소멸될 것이고, 또 임시 객체의 복사 버전을 만드는 고생스러운 일을 안해도 되니까 시간의 거의 50% 줄어들게 되는 것이지요.

이와 같은 일이 가능했던 것은 위 x = foo() 에서 foo() 가 바로 우측값 이기 때문이입니다. 우측값을 대입하는 경우 다음과 같이 단순하게 복사 연산자를 구현할 수 있습니다.

// [...]
// m_pResource 와 rhs.m_pResource 를 교환
// [...]

이를 바로 move 연산 이라고 하는 것입니다. 0x C++ 초기 버전에서는 위와 같은 과정이 템플릿 메타프로그래밍으로 가능했다는 이야기를 들었는데 분명 매우 복잡했을 것입니다. C++ 11 에서는 아래와 같은 함수의 오버로딩으로 구현할 수 있습니다.

X& X::operator=(<미지의 타입> rhs) {
  // [...]
  //  m_pResource 와 rhs.m_pResource 를 교환
  // [...]
}

지금 우리는 복사 대입 연산자의 오버로드(overload)를 정의하고 있는 것입니다. 따라서 우리의 '미지의 타입' 은 레퍼런스 임에 분명하지요 (그리고 반드시 그렇게 되어야만 합니다)

또한 우리의 미지의 타입이 지켜야 할 것은, 기존의 레퍼런스 (&) 와 차별화를 두어서 미지의 타입과 보통의 레퍼런스 타입을 사용한 두 함수 사이에서 좌측값의 경우 보통의 레퍼런스 타입을, 우측값의 경우 미지의 타입을 택하도록 만들어야 할 것입니다.

그래서 C++ 개발자들은 이 미지의 타입에 우측값 참조 (rvalue reference)라는 이름을 붙였습니다.

우측값 참조 (rvalue reference)

임의의 타입 X 에 대해 X&&X 의 우측값 참조라고 정의합니다. 또, 쉽게 구별하기 위해 기존의 레퍼런스 X& 를 좌측값 참조 라고 부르도록 합시다.

우측값 참조는 기존의 레퍼런스 X& 와 몇 가지 예외를 제외하고는 유사하게 작동합니다. 다만 둘의 가장 큰 차이점은 함수 오버로딩에서 좌측값은 좌측값 레퍼런스를, 우측값은 우측값 레퍼런스를 선호한다는 것이지요.

void foo(X& x);   // 좌측값 참조 오버로드
void foo(X&& x);  // 우측값 참조 오버로드

X x;
X foobar();

foo(x);  // 인자에 좌측값이 들어 갔으므로 좌측값 참조 함수가 오버로딩
foo(foobar());  // 인자에 우측값이 들어 갔으므로 우측값 참조 함수가 오버로딩

내용을 요약해보자면

우측값 참조는 컴파일러로 하여금 컴파일 시에 자신의 인자로 좌측값이 오는지, 우측값이 오는지에 따라 오버로딩을 할 수 있도록 도와주는 것이라고 볼 수 있습니다.

어떠한 함수라도 인자로 우측값을 받도록 할 수 있지만, 대다수의 경우 move 연산을 위해서는 우측값 참조로 인자를 받는 경우는 복사 생성자나 대입 연산자들 밖에 없습니다.

X& X::operator=(X const& rhs);  // 기존의 구현 방법
X& X::operator=(X&& rhs) {
  // Move 연산: this 와 rhs 의 내용을 swap 한다.
  return *this;
}

우측값 참조를 인자를 받는 복사 생성자를 구현하는 일도 역시 비슷합니다. 주의할 점은 위와 같이 우측값 참조하는 대입 연산자를 구현하는 것이 완벽하게 구현한 것은 아닙니다. 뒤에서 그 이유를 이야기 하겠지만, thisrhs 의 내용을 단순히 swap 하는 것 만으로 충분하지 않습니다.

한 가지 참고할 사항은 만일 여러분이

void foo(X&);

만 구현하고

void foo(X&&);

를 구현하지 않는다면, 예전부터 해왔던 것 처럼 foo 는 인자로 좌측값만 받을 수 있고 우측값을 받을 수 없습니다. 반면에 여러분이

void foo(X const &);

만 구현하고

void foo(X&&);

를 구현하지 않는다면, foo 는 좌측값 및 우측값 모두 인자로 받을 수 있지만, 좌측값과 우측값일 때를 구별해서 처리할 수 없기 때문에 move 연산시 많은 불필요한 작업이 수행됩니다. 따라서 이를 해결할 수 있는 유일한 방법은

void foo(X&&);

를 구현하는 것인데, 만일 아래 두 함수들 중 어느 하나라도 정의하지 않는다면

void foo(X &);
void foo(X const &);

오히려 좌측값을 제대로 처리할 수 없게 됩니다.

강제적으로 `move` 하기

우리가 잘 알고 있듯이 C++ 표준의 첫 번째 십계명에 따르면 "우리 C++ 위원회는 C++ 프로그래머의 (때론 무모하더라도) 자유를 막는 규칙을 만들면 안된다 - The committee shall make no rule that prevents C++ programmers from shooting themselves in the foot 라고 명시되어 있습니다.

조금 더 진지하게 말하자면, 만일 C++ 위원회에서 프로그래머에게 조금 더 많은 자유를 주는 것과, 많은 자유로 인해 실수를 하는 일을 막기 위해 자유를 억제하는 일 사이에서 고른다면, C++ 은 프로그래머의 실수를 야기할 수 있더라도 좀 더 많은 자유를 주는 것을 선호합니다.

따라서, 이러한 신념을 바탕으로 C++ 11 에서는 move 연산을 우측값에만 제공하는 것이 아닙니다. 프로그래머가 사용 시에 조금 더 주의를 기울여야 하겠지만, 좌측값에서도 사용할 수 있게 하였습니다. 가장 좋은 예로 표준 라이브러리의 swap 함수를 들 수 있습니다. 클래스 X 를 우측값에 대한 move 연산들이 적용된 복사 생성자와 대입 생성자가 있는 어떤 클래스로 생각합시다.

template <class T>
void swap(T& a, T& b) {
  T tmp(a);
  a = b;
  b = tmp;
}

X a, b;
swap(a, b);

위 소스에는 우측값이 없습니다. 따라서 swap 코드의 3 줄 모두 move 연산을 사용하고 있지 않습니다. 하지만 우리는 이 과정에서 move 연산을 사용하더라도 무방하다는 사실을 잘 알고 있습니다. 왜냐하면 swap 의 소스에서 복사 및 대입 과정에서의 source 는 다시는 사용되지 않거나 다른 대입 및 복사연산의 target 으로 사용되기 때문이지요.

(※ target = source; ) 다시 말해 tmp(a) 에서 tmpa 를 아예 move 해도 상관 없는 것이고 a = b; 에서 b 를 굳이 a 에 복사할 필요 없이 그냥 abmove 해도 괜찮다는 것입니다.

따라서 C++ 11 에서는 표준 라이브러리 함수인 std::move 가 이를 위해 등장하였습니다. 이 함수는 인자로 받은 것을 우측값으로 바꿔주는 역할을 합니다. 따라서 C++ 11 에서 표준 라이브러리의 swap 함수는 아래와 같이 생겼습니다.

template <class T>
void swap(T& a, T& b) {
  T tmp(std::move(a));
  a = std::move(b);
  b = std::move(tmp);
}

X a, b;
swap(a, b);

이를 통해 swap 코드의 세 줄에서 모두 move 연산을 할 수 있게 되었습니다. 한 가지 명심할 점은 만일 클래스 T 에 대해 move 연산을 구현하지 않았더라면 (즉, T 의 우측값 참조를 하는 복사 생성자와 대입 연산자를 제공하지 않음), 위 새로운 swap 함수는 그냥 예전의 swap 처럼 작동할 것입니다.

std::move 는 매우 단순한 함수 입니다. 하지만 일단은 어떻게 구현했는지는 나중에 설명하도록 하겠습니다.위 swap 함수 에서 처럼 어디에서든지 std::move 를 사용할 수 있는 일은 아래와 같은 장점들이 있습니다.

  • move 연산을 구현하는 일은 여러 표준 알고리즘에 엄청난 속도 향상을 가져 옵니다. 예를 들어서 내부 정렬을 들을 수 있는데, 내부 정렬 알고리즘(퀵정렬, 버블 정렬 등등)은 오직 원소들 간의 swap 연산만을 하는데, move 연산 덕분에 swap 연산이 매우 빨라졌으므로 상당한 속도 향상이 되었습니다.

  • 기존의 STL 에서는 많은 경우 특정 타입의 복사 가능(copyability)을 요구하였습니다. 하지만 자세한 조사 결과 많은 경우에 굳이 복사 가능 까지 보다 move 가능(moveability) 정도로 충분하다는 것을 밝혀내었습니다. 따라서 이제 우리는 copy 가능하지 않고 move 만 가능해서 사용 불가능 하였던 타입들에게 STL 을 사용할 수 있게 됩니다. 이렇게 새롭게 사용할 수 있게된 것으로는, 예를 들어 STL Containter 가 있습니다.

이제 우리는 std::move 에 대해 조금 알게 되었으니, 이전에 복사 대입 연산자의 우측값 레퍼런스를 이용한 구현이 조금 문제가 있었는지 이해할 수 있는 수준이 되었습니다. 아래와 같은 두 개 변수 사의 단순한 대입 연산을 생각해봅시다.

a = b;

여기서 어떠한 일이 발생할까요? 아마 여러분은 a 에 보관되어 있던 객체가 b 의 복사본으로 교체될 것이고, 이 교체 과정에서 a 가 이전에 보관하였던 객체는 파괴될 것이라고 생각하실 수 있습니다. 그렇다면 아래의 예는 어떨까요?

a = std::move(b);

일단 앞서 많은 경우 대입 연산자가 swap 으로 구현된다고 하였으므로 ab 가 가리키는 객체들은 서로 교환되게 됩니다. 아직 이 과정에서 소멸되는 객체는 없습니다. 하지만 a 가 이전에 가리키고 있었던 객체는 언젠가는 소멸되는데, 정확히 말하자면 b 가 범위(scope) 를 벗어날 때 라고 볼 수 있습니다.

물론 만일 b 가 다시 move 연산을 수행하게 된다면 a 가 이전에 가리키는 객체 역시 교환되게 됩니다. 따라서, 복사 대입 연산자를 만든 사람 입장에서 볼 때 a 가 이전에 가리키는 객체가 언제 소멸될 지 예측할 수 없게 됩니다.

우리는 언제 객체가 소멸될지 모르는 끔찍한 상황에 직면하였습니다. 물론 객체가 소멸되지 않아도 별 문제를 야기하지 않는다면 상관이 없습니다만, 어떤 경우에는 객체의 소멸이 다른 영향을 끼칠 수 도 있다는 것입니다.

예를 들어서 소멸자 안에서 threadlock 을 푼다 든지 말이지요. 따라서, 객체의 소멸 시에 수행되는 작업들이 외부에 다른 영향을 주게 된다면, 이는 반드시 우측값 참조 복사 생성자 내부에서 수행되어야만 합니다.

X& X::operator=(X&& rhs) {
  // 소멸자에서 수행되는 내용들 중 외부에 영향을 줄 수 있는 것들은
  // 여기서 수행해야만 합니다. 물론 객체를 실제로 파괴하면 안되고
  // 언제나 대입 가능한 상태로 유지해야만 합니다.

  // Move 연산: this 와 rhs 의 내용을 swap 한다.

  return *this;
}

우측값 레퍼런스는 우측값 일까요?

이전처럼 move 를 사용하는 오버로딩 된 복사 생성자와 복사 대입 연산자가 있는 클래스라고 생각합시다. 그렇다면 아래와 같은 함수를 살펴봅시다.

void foo(X&& x) {
  X anotherX = x;
  // ...
}

그렇다면 여기서 어떠한 X 의 복사 생성자가 foo 내부에서 호출이 되는 것일까요? 우측값을 인자로 받는 것일까요, 좌측값을 인자로 받는 것일까요? 여러분이 언뜻 보시기에 xX 의 우측값 참조로 정의되어 있으니 아마도 우측값을 인자로 받는 복사 생성자가 호출이 될 것이라고 생각 하실 것입니다. 왜냐하면

X(X&& rhs);

우측값을 인자로 받는 복사 생성자는 딱 x 에 들어 맞게 위 처럼 생겼기 때문이죠. 즉 여러분들은 어떤 변수가 우측값 참조라 선언되었다면, 그 자체 만으로 우측값이라 생각하시는 것입니다. 하지만 우측값 참조를 설계한 사람들은 다음과 같이 정의하였습니다.

우측값 참조라 정의한 것들도 좌측값 혹은 우측값이 될 수 있다. 이를 판단하는 기준은, 만일 이름이 있다면 좌측값, 없다면 우측값이다.

무슨말인지 잘 모르겠다면 아래의 예를 보면 이해가 더 빠릅니다. 아래 함수 내에서 x 는 우측값 레퍼런스로 정의되었고 이름이 있기 때문에 (x 라는 이름이 있잖아요!) 위 정의에 따라 좌측값 입니다.

void foo(X&& x) {
  X anotherX = x;  // 좌측값 이므로 X(X const & rhs) 가 호출됨
}

반면에 아래의 goo 함수의 경우 X&& 타입의 데이터를 리턴하고 그 것의 이름은 없기 때문에 이는 우측값이 됩니다.

X&& goo();
X x = goo();  // 이름이 없으므로 우측값. 즉 X(X&& rhs) 가 호출됨

여기서 우리는 왜 이러한 방식으로 설계를 하였는지 알 수 있습니다. 만일 아래와 같은 코드에서 이름이 있는 것에 move 연산을 적용하게 되면

X anotherX = x;
// 만일 여기서 실수로 x 를 이용한 코드를 작성하면 ???

아직 xscope 안에 있기 때문에 x 를 사용할 수 있지만 x 에는 지금 아무것도 들어있지 않은 상태이기에 실수로 사용하기라도 하면 어떠한 오류가 발생할지 아무도 모르는 것입니다. 즉 C++ 에서는 move 연산을 오직 '사용해도 상관 없는 곳' 에서만 사용하도록 되어 있기 때문에 (이동 즉시 소멸되서 아래에서 접근이 불가능 하게 된다) 위와 같이 "이름이 있다면 좌측값이다" 라고 정의한 것이지요.

그렇다면 "이름이 없다면 우측값이다" 는 어떨까요. 위의 goo() 를 살펴봅시다. 사실 드물게도 goo() 가 가리키는 객체가 move 이후에도 접근 가능한 것일 수 도 있는 것입니다. 그런데, 이전의 내용을 상기해보세요 - 우리가 종종 이 기능을 필요로 하지 않았나요? 우리는 앞에서 좌측값에 대해 강제적으로 move 연산을 적용시키기를 원했습니다. 그런데, 규칙에 따르면 "이름이 없다면 우측값이다" 를 통해 이를 성공적으로 수행할 수 있었지요. 이것이 바로 std::move 가 작동하는 원리 입니다. 사실 정확한 구현을 보여주기에는 아직도 갈길이 남아있지만, 우리는 std::move 를 이해하는데 한 발짝 더 가까이 다가갔습니다. 이 함수는 레퍼런스로 인자를 받은 뒤에 아무것도 하지 않고, 다시 이를 우측값 참조로 리턴하게 되는 것이지요. 따라서

std::move(x)

는 우측값 참조로 정의되었고, 이름을 가지지 않습니다. 따라서 이는 우측값이 됩니다. 즉, std::move 는 "이름을 가리기" 를 통해 우측값이 아닌 인자 조차도 우측값으로 바꿔주는 역할을 합니다. 사실 이러한 '이름의 유무' 에 대한 규칙은 매우 중요하므로 잘 알고 계셔야 합니다. 예를 들어서 여러분이 Base 라는 클래스를 만들었고, move 연산을 Base 의 복사 생성자와 대입 연산자에 구현하였다고 해봅시다.

Base(Base const& rhs);  // move 연산 아님
Base(Base&& rhs);       // move 연산

이제 여러분이 Base 를 상속 받은 Derived 라는 클래스를 만들었다고 생각합시다. Derived 클래스의 Base 클래스 부분에서 move 연산이 잘 작용하려면, 여러분은 반드시 Derived 에 복사 생성자와 대입 연산자를 오버로딩 해야 할 것입니다. 그럼 복사 생성자를 어떻게 구성하였는지 살펴볼까요. 좌측값을 사용한 버전은 단순합니다.

Derived(Derived const& rhs) : Base(rhs) {
  // Derived 에 관련된 작업들
}

그렇다면 우측값을 사용한 버전은 어떨까요. 만일 여러분이 앞선 '이름에 관한 규칙' 을 잘 이해하지 못했더라면 아래와 같이 구현했을 것입니다.

Derived(Derived&& rhs)
    : Base(rhs)  // wrong: rhs 는 좌측값!
{
  // Derived 에 관련된 작업들
}

위와 같이 한다면, Base 의 좌측값 버전의 복사 생성자가 호출될 것입니다. 왜냐하면 rhs 는 명백하게도 'rhs' 라는 이름이 있기에 좌측값이기 때문이죠. 우리가 Basemove 복사 생성자를 호출하려면 아래와 같이 해야 할 것입니다.

Derived(Derived&& rhs)
    : Base(std::move(rhs))  // good: Base(Base&& rhs) 를 호출
{
  // Derived 에 관련된 작업들
}

Move 연산과 컴파일러 최적화

아래와 같은 함수의 정의를 살펴봅시다.

X foo() {
  X x;
  // x 에 어떤 작업을 한다
  return x;
}

이전처럼 X 를 move 연산이 적용된 복사 생성자와 복사 대입 연산자가 있는 클래스라고 생각해봅시다. 위 함수를 잘 살펴본 여러분은, move 연산에 너무나 심취해버린 나머지 foo 의 리턴값과 x 와의 값 복사가 일어나고 있기 때문에 다음과 같이 move 연산을 적용해서 바꿀 것이라고 생각됩니다.

X foo() {
  X x;
  // perhaps do something to x
  return std::move(x);  // making it worse!
}

사실 위와 같이 바꾸어 버리면 오히려 작업 속도를 더 늦추게 할 뿐입니다. 현대의 컴파일러들은 '리턴값 최적화(return value optimization)' 라는 작업을 통해서 x 를 생성한 뒤에 리턴값에 복사하는 것이 아니라, x 자체를 함수의 리턴값 부분에 생성해버리기 때문이지요.

사실 이렇게 되면 오히려 move 연산 보다 더 빠른 수행 시간 향상이 있게 됩니다. 따라서, 위 예에서도 볼 수 있듯이, 우측값 참조와 move 연산을 효율적으로 사용하기 위해서는 여러분은 현대의 컴파일러의 여러가지 기법들 (리턴값 최적화나 복사 생략(copy elision) 등등)을 잘 알고 계셔야만 할 것입니다.

완벽한 전달(perfect forwarding)

맨 처음에도 말했지만 우측값 참조를 통해 해결할 수 있는 문제로 move 연산 뿐만이 아니라 완벽한 포워딩 문제도 있습니다. 아래와 같은 간단한 factory 함수를 살펴보도록 합시다.

template <typename T, typename Arg>
shared_ptr<T> factory(Arg arg) {
  return shared_ptr<T>(new T(arg));
}

명백하게도, 여기서 하고자 하는 일은 인자 argT 의 생성자에게 전달(forward) 하는 일입니다. 이상적으로 생각해볼 때, 인자 arg 가 생성자에게 마치 factory 함수가 없이 직접 전달되는 것처럼 전달되면 가장 좋겠지요. 즉 완벽하게 전달(perfect forwarding) 된다는 말입니다. 하지만 불행히도 위 코드는 이를 성공적으로 수행할 수 없습니다. 왜냐하면 위 factory 함수는 call-by-value 를 하기 때문인데, 특히 T 의 생성자가 인자를 레퍼런스로 가진다면 더욱 안좋지요.

가장 널리 쓰이는 해결책은 (boost::bind 에서 사용한 해결책) 바깥의 함수가 인자를 레퍼런스로 가지면 되는 것입니다.

template <typename T, typename Arg>
shared_ptr<T> factory(Arg& arg) {
  return shared_ptr<T>(new T(arg));
}

위 방법은 좀 더 낫지만 완벽하지는 않습니다. 왜냐하면 이제 factory 함수는 우측값에 대해 성공적으로 호출이 되지 않기 때문이죠.

factory<X>(hoo());  // error.
factory<X>(41);     // error.

이 문제는 인자를 const 참조로 바꾼다면 해결할 수 있습니다.

template <typename T, typename Arg>
shared_ptr<T> factory(Arg const& arg) {
  return shared_ptr<T>(new T(arg));
}

그런데 이 방법 역시 두 개의 문제를 내포하고 있습니다. 만일 factory 가 한 개가 아니라 여러개의 인자를 가지고 있다면, 여러분은 constnon-const 참조 인자들의 모든 조합들에 대해 오버로드 함수를 제공해야만 할것입니다.

따라서 위와 같은 해결책은 만일 함수의 인자가 여러개라면 좋지 않음을 알 수 있습니다. 두 번째 문제로 위와 같은 전달은 move 연산을 할 수 없기 때문에 완벽하다고 볼 수 없습니다. 왜냐하면 factory 함수에서 T 의 복사생성자에 전달되는 인자는 좌측값이기 때문입니다.

이 문제들은 우측값 참조를 통해 해결할 수 있었습니다. 즉, 우측값 참조를 통해 오버로드 없이도 완벽한 전달 문제를 완벽하게 해결할 수 있게 되었지요. 이를 이해하기 위해서는 우측값 참조에 대한 두 개의 규칙을 더 살펴보도록 합시다.

완벽한 전달 문제 해결책

두 개의 규칙 중 첫번째 것은 이전의 좌측값 참조와 관련된 것입니다. C++ 11 이전에는 레퍼런스의 레퍼런스를 취하는 것은 불가능 했다라고 알고 계셨을 것입니다. 즉 A& & 는 컴파일 오류 였죠. 하지만 C++ 11 에서는 다음과 같은 & 겹침에 관한 규칙을 도입하였습니다.

A&&                // -> A&
    A&&&           // -> A&
        A&&&       // -> A&
            A&&&&  // -> A&&

두 번째로 템플릿 인자로 우측값 참조를 받는 함수 템플릿에는 아래와 같은 특수 템플릿 인자 유추 규칙(special template argument deduction rule) 이 있습니다.

template <typename T>
void foo(T&&);

위 템플릿을 예를 들어 설명하자면, 규칙은 다음과 같습니다.

  • 만일 fooA 의 좌측값으로 호출된다면, TA& 로 변환되고, 따라서 위의 & 겹침 규칙에 따라 인자 타입은 A & 가 된다.

  • 만일 fooA 의 우측값으로 호출된다면, TA 로 변환되고, 따라서 위의 & 겹침 규칙에 따라 인자 타입은 A&& 가 된다.

이러한 규칙을 바탕으로 우리는 이제 완벽한 전달 문제를 위해 우측값 참조를 사용할 수 있게 됩니다. 아래는 그 해결책 입니다.

template <typename T, typename Arg>
shared_ptr<T> factory(Arg&& arg) {
  return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}

이 때 std::forward 는 아래와 같이 정의되어 있습니다.

template <class S>
S&& forward(typename remove_reference<S>::type& a) noexcept {
  return static_cast<S&&>(a);
}

(※ noexcept 키워드에 대해서는 아래에서 자세히 설명할 것이니 잠시동안만 무시하도록 합시다. 간략하게 설명하자면 noexcept 키워드는 이 함수가 절대로 예외를 throw 하지 않을 것이라고 말해주는 것입니다)

위 코드가 어떻게 완벽한 전달 문제를 해결하는지 살펴보기 위해 factory 함수가 각각 좌측값과 우측값으로 호출 될 때 어떻게 작동하는지 살펴보도록 합시다.AX 를 타입이라고 하고, factory<A>X 의 좌측값 타입으로 호출되었다고 합시다. 아래와 같이요.

X x;
factory<A>(x);

앞서 특수 템플릿 인자 유추 규칙에 따라 factory 의 템플릿 인자 ArgX& 로 변환됩니다. 따라서, 컴파일러는 아래와 같이 factorstd::forward 의 템플릿 인스턴스화 (instantiation) 을 수행하게 됩니다.

shared_ptr<A> factory(X&&& arg) {
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
}

X&&& forward(remove_reference<X&>::type& a) noexcept {
  return static_cast<X&&&>(a);
}

remove_reference 를 수행하고 & 겹침 규칙을 적용하게 되면,

shared_ptr<A> factory(X& arg) {
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
}

X& std::forward(X& a) { return static_cast<X&>(a); }

와 같이 됩니다. 좌측값에 대한 완벽한 전달이 잘 수행되었다고 볼 수 있죠. factory 함수의 인자 argA 의 생성자로 2 번의 좌측값 참조를 거쳐서 전달됩니다.

이번에는 factory<A>X 의 우측값으로 호출되었다고 생각해봅시다.

X foo();
factory<A>(foo());

그러면 위의 특수 템플릿 인자 유추 규칙에 따라 factory 의 템플릿 인자 ArgX 로 변환되게 됩니다. 따라서 컴파일러는 아래와 같은 함수 인스턴스화 된 함수 템플릿을 생성하게 되겠지요.

shared_ptr<A> factory(X&& arg) {
  return shared_ptr<A>(new A(std::forward<X>(arg)));
}

X&& forward(X& a) noexcept { return static_cast<X&&>(a); }

이는 실제로 우측값에 대한 완벽한 전달이라고 볼 수 있습니다. factory 함수의 인자가 A 의 생성자에 두 개의 단계를 거쳐서 전달됩니다. 특히 A 의 생성자는 자신의 인자로 전달된 것을 '이름이 없기에' 우측값 이라고 생각하기 때문에 성공적으로 우측값에 대한 복사 생성자 호출을 수행할 수 있게 됩니다.

위 완벽한 전달 과정에서 가장 중요한 것은 바로 std::forward 라고 볼 수 있는데, std::forward 없이는 A 의 생성자로 '이름이 언제나 있기에' 좌측값이 전달되게 됩니다. 따라서 std::forward 의 역할은 처음의 템플릿 인자로 우측값이냐 좌측값이냐에 따라서 생성자에 우측값을 전달할지, 좌측값을 전달할지 결정하는 일을 한다고 볼 수 있습니다.

forward 함수를 조금 더 깊게 파고든다면, 아마 여러분은 "왜 굳이 remove_referencestd::forward 정의에 필요할까?" 라고 생각하실 수 있습니다. 그리고 그 답은 "사실은 필요 없다" 입니다. 여러분은 그냥 remove_reference<S>::type& 대신에 그 자리에 S& 를 사용하셔도 무방합니다. 하지만 이는 오직 우리가 Argstd::forward 의 템플릿 인자로 명시적으로 사용하고 있을 때 만 잘 작동하겠지요. remove_referencestd::forward 의 정의에 넣은 것도 강제로 그렇게 하기 위해서 입니다.

자. 그럼 이제 우리의 여정은 끝에 다다랐습니다. 이제 해야될 것은 std::move 의 정의를 살펴보는 일입니다. std::move 는 레퍼런스로 받은 인자를 우측값 처럼 행동하게 하는 것임을 기억하고 계시죠? 아래는 그 구현입니다.

template <class T>
typename remove_reference<T>::type&& std::move(T&& a) noexcept {
  typedef typename remove_reference<T>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

만일 우리가 좌측값 X 에 대해 std::move 를 호출하였다고 해봅시다.

X x;
std::move(x);

그리고 우리의 특수 템플릿 인자 유추 규칙에 따라서 템플릿 인자 TX& 로 바뀔 것이고, 따라서 컴파일러는 아래와 같이 템플릿 인스턴스화를 수행하게 됩니다.

typename remove_reference<X&>::type&& std::move(X&&& a) noexcept {
  typedef typename remove_reference<X&>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

remove_reference& 겹침 규칙을 적용하고 나면

X&& std::move(X& a) noexcept { return static_cast<X&&>(a); }

와 같이 됩니다. 바로 우리가 원하는 작업이군요. 우리의 좌변값 x 는 인자인 좌변값 참조를 통해 인자로 전달되어서 이름없는 우변값 참조로 변환될 것입니다. std::move 가 우변값에서도 작동하는지 확인하는 일은 여러분의 몫으로 남겨두겠습니다.

그런데, std::move 가 받은 인자를 우변값으로 바꿔주는 일이라면 도대체 왜 사람들이 우변값에 대해 std::move 를 호출할까요?

std::move(x);

로 쓰는 대신에 그냥

static_cast<X&&>(x);

위와 같이 하면 되니까요. 하지만 std::move 가 더 보기도 좋고 잘 표현하고 있기 때문에 std::move 로 표현하는 것을 권장합니다.

우측값 참조와 예외

보통의 경우, C++ 로 소프트웨어를 개발 할 때, 여러분의 코드에서 예외 처리를 하는 것은 여러분의 몫입니다. 우측값 참조의 경우는 여기서 사실 살짝 다릅니다. 만일 여러분이 복사 생성자와 복사 대입 연산자를 move 연산을 위해 오버로드 할 때, 아래와 같이 수행하는 것을 매우 권장합니다.

오버로드 함수가 예외를 throw 하지 않도록 함수를 구성하세요. 사실, move 연산은 두 객체의 포인터와 리소스 간의 단순한 swap 이기 때문에 어려운 일은 이닙니다.

만일 위와 같이 throw 하지 않도록 구성하였다면, 이를 noexcept 키워드를 이용하여 명확히 나타내도록 합니다.

만일 위 두 작업을 하지 않는다면, 종종 move 연산이 적용될 것이라 예상했음에도 불구하고 move 연산이 적용되지 않는 경우들이 있을 것입니다. 예를 들어 std::vector 의 크기가 변경될 때, 여러분은 아마 move 연산을 통해 이미 존재하는 벡터의 원소들을 새로운 메모리 블록으로 이동시키려고 할 텐데요, 위 조건 1,2 가 모두 충족되지 않는다면 이러한 move 연산은 수행되지 않습니다.

이러한 일이 발생하는 이유는 꽤 복잡한데, 자세한 내용을 알기 위해서는 Dave Abrahams 의 글을 참조하시기 바랍니다. 참고로 이 글은 noexcept 키워드가 도입되기 이전이니, noexcept 키워드가 어떻게 문제를 해결하였는지 보고 싶다면 글 상단의 update #2 를 클릭하시기 바랍니다.

암시적(implicit) Move

우측값 참조에 관한 토의에서, 표준 위원회는 우측값 참조를 사용하는 복사 생서자와 복사 대입 연산자들이 사용자가 제공하지 않는다면 컴파일러가 스스로 제공하도록 결정하였습니다. 사실 컴파일러가 보통의 복사 생성자나 복사 대입 연산자들을 디폴트로 제공하고 있었다는 것을 생각해보면 매우 타당한 일입니다.

그런데 2010년 8월에 Scott Meyers (그 유명한 Effective C++ 의 저자) 는 comp.lang.c++ 에 컴파일러가 제공하는 move 생성자가 원래의 코드를 심각하게 손상 시킬 수 있다는 것을 밝혀내었습니다. Dave Abrahams 는 그 내용을 자신의 블로그에 잘 정리해놓았습니다.

따라서 위원회는 기존의 방식으로 move 생성자와 move 대입 연산자들을 컴파일러가 생성하는 것을 제한하고,코드를 손상시키는 것이 거의 불가능하도록 (아예 불가능 한 것은 아님) 결정하였습니다. 그 내용은 Herb Sutter 의 블로그에 잘 설명되어 있습니다.

사실 명시적이지 않은 move 에 관한 문제는 계속 해서 논쟁 거리로 남아서 표준 최종 결정의 마지막 단계 까지 남아 있었습니다. 애초에 위원회가 명시적이지 않은 move 를 도입한 이유는 웃기게도, 위에서 말한 우측값 문제와 예외 처리 때문인데, 이 문제는 noexcept 키워드를 도입함으로써 좀 더 만족스러운 방법으로 해결되었습니다. 만일 noexcept 키워드가 몇 달 앞서 도입되었더라면 이 문제는 수면 위로 떠오르지도 않았을 것입니다.

아무튼. 그리하여, 컴파일러가 제공하는 암시적 move 는 사라졌습니다.

자 이게 우측값에 관한 이야기의 끝입니다. 여러분이 보았듯이, 우측값으로 인한 이점은 상당합니다. 여러분이 C++ 을 능숙하게 다루기 위해서는 이러한 세세한 사항들을 모두 이해하고 있어야만 할 것입니다. 그렇지 않다면 여러분은 C++ 의 모든 기능을 사용하고 있다고 볼 수 없지요. 이 많은 것들을 기억하기에는 벅차다고요? 그렇다면 우측값에 관렪나 아래 3 개 내용만 꼭 기억하시면 되겠습니다.

뭘 배웠지?

함수의 오버로딩에서 void foo(X& x); 는 좌측값 참조 오버로딩, void foo(X&& x); 은 우측값 참조 오버로딩

여러분은 이를 통해 foo 가 좌측값에, 혹은 우측값에 호출됨에 따라서 상황을 적절하게 처리할 수 있게 됩니다. 또한 특히 우측값 처리시에, 예외 처리에 신경을 써주어서, 예외를 throw 하지 않도록, 그리고 마지막에는 꼭 noexcept 키워드를 넣어 주어야만 합니다.

std::move 는 받은 인자를 우측값으로 변환한다.

std::forward 는 완벽한 전달을 할 수 있도록 도와준다.

감사의 말

이 글을 제공해주신 Thomas Becker 님에게 감사의 말을 드립니다.

댓글이 27 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..