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

5일차 - 완벽한 전달자 (perfect forwarding) (2)

by 날쑤 2015. 9. 6.

Intro

  이전 글에서 다루었던 우측값(rvalue)은 주로 두가지로 활용된다. 하나는 완벽한 전달자이고, 다른 하나는 move semantics이다. 이번 글에서는 첫번째 항목인 완벽한 전달자를 만드는 것에 초점을 맞추고자 한다.

  우선 예전에 완벽한 전달자 (1)에서 다뤘던 lockAndCall 예제를 다시 살펴보자.

  완벽한 전달을 위해서는 전달함수(여기서는 lockAndCall)가 인자를 참조로 받아야한다. 그러나 기존 C++에서는 일반 참조는 상수값을 받을 수 없는 문제가 있었기 때문에 일반 참조와 상수 참조를 인자로 받는 함수를 각각 구현해서(즉, 함수 오버로딩을 통해) 완벽한 전달자를 구현했었다. 이제는 상수 참조가 아닌 우측값 참조를 통해 rvalue인 상수를 참조할 수 있음을 알고 있다. 그런데 lockAndCall의 인자는 실제 타입에 대한 참조가 아닌 템플릿 타입인자에 대한 참조이며, 표준에서는 템플릿 타입인자에 대한 &&에 일반적인 우측값 참조와는 다른 의미를 부여하고 있기 때문에 이를 적용할 때에는 주의가 필요하다.

Universal reference

  Effective Modern C++에서 저자는 universal reference를 다음과 같이 설명하고 있다. (item 24)

  위와 같은 함수 선언에서 T&&을 universal reference(rvalue reference가 아님)라고 한다. 그리고 이 특수한 참조는 함수 호출에 사용된 표현식(expr)에 따라 인스턴스화된 함수에서 다른 타입의 참조가 된다는 특징이 있다. 여기에는 reference collapsing이라는 개념이 적용되는데 이에 대한 설명은 일단 뒤로 미루고 결과만 정리하면 다음과 같다.

  • expr이 lvalue - T와 T&&, 모두 lvalue reference
  • expr이 rvalue - T는 non-reference, T&&는 rvalue reference

이제는 universal reference를 도입해서 lockAndCall을 다시 정의해보자.

  universal reference는 변수와 상수 모두 받을 수 있기 때문에 이제는 별도의 함수 오버로딩이 필요없다. 그리고 수정된 lockAndCall 함수를 적용해서 처음의 main 함수를 수행하면 결과값도 정상적으로 나온다. 이걸로 모든 것이 해결된 것 같지만 아직 남은 문제가 존재한다.

Perfect forwarding

  이제는 universal reference를 이용해서 함수 오버로딩없이 상수와 변수를 모두 받을 수 있는 lockAndCall 함수를 만들 수 있게 되었다. 그런데 아직 수정해야 할 부분이 남아있다. 인자로 상수(즉, rvalue)를 전달했을 때를 생각해보자. 위의 설명에 따르면 A&&는 rvalue reference가 된다.그런데 이전 글에서 rvalue reference 자체는 lvalue라고 언급했었다. 이 부분이 바로 문제의 소지가 된다. 예제를 통해 이를 확인해보자.

  우선 문제상황을 명확히 하기 위해 대상함수를 foo 함수로 고정시켰다. 이를 실행하면 첫번째 lockAndCall 호출은 정상적으로 (1)을 호출한다. 자연스럽게 상수를 인자로 넘긴 두번째 호출은 (2)가 호출되기를 기대할 것이다. 하지만 실제로 호출되는 함수는 (1)임을 결과를 통해 확인할 수 있다. 위에서 언급한 내용을 생각해보면 왜 이런 결과가 나왔는지를 알 수 있다.

  상수가 전달되면서 a는 rvalue reference가 되지만, 이것 자체는 rvalue가 아닌 lvalue이다. 그러므로 전달함수 내에서는 오버로딩 규칙에 의해서 lvalue 인자를 받는 (1)이 호출된 것이다. 이를 해결하기 위한 해결책은 무엇일까? 답은 생각보다 간단하다. 만약 a가 rvalue reference라면 a를 rvalue로 casting한 다음에 foo로 전달하면 된다. 이러한 조건부 casting을 lockAndCall 함수 내에서 직접 구현할 수도 있지만, 이미 표준에는 이를 쉽게 할 수 있는 방법이 존재한다. std::forward가 바로 그것이다.

  Effective Modern C++ (item 28)에서는 std::forward의 개략적인 구현을 아래와 같이 소개하고 있다.

  이제 이를 이용해서 lockAndCall 함수를 다음과 같이 수정한 후, 처음의 두 호출들을 다시 생각해보자.

  우선 universal reference의 규칙에 따라 처음의 호출을 통해 인스턴스화되는 lockAndCall 함수에서 A는 int&로 추론(deduce)된다. 그러므로 lockAndCall 함수 내부의 forward는 forward<int&>로 인스턴스화된다. 정의에 따라 인스턴스화된 forward는 아래와 같다.

  결국 x는 lvalue reference로서 foo로 전달됨을 알 수 있고 exact matching인 (1)을 호출한다. 그럼 이번에는 두번째 호출에서의 forward를 살펴보자. 두번째 호출에서 lockAndCall은 인자로 상수인 0을 받았다. 그러므로 인스턴스화된 lockAndCall에서 A는 int로 추론되며, 따라서 forward는 forward<int>로 인스턴스화 된다. 위와 같은 방식으로 forward를 풀어보자.

  lockAndCall에서 a는 rvalue reference(즉, lvalue)이므로 lvalue reference에 bind될 수 있다. 그리고 이는 forward를 통해 rvalue로 casting되어 foo로 전달되므로 최종적으로 호출되는 foo는 (2)가된다.

  요약하자면 함수 템플릿에서 오버로딩없이 변수와 상수 모두를 인자로 받기 위해서는 universal reference를 사용해야 하며, 이를 다시 다른 함수로 전달하는 경우에는 완벽한 전달을 위해 인자에 std::forward를 적용해서 대상 함수로 넘겨야 한다.

lockAndCall의 완성형 버전

  이전 글에서 variadic template을 이용해서 인자의 개수에 제약을 받지않는 lockAndCall 함수를 아래와 같이 작성했다.

  더불어 이 함수의 문제점이 return값 보존과 perfect forwarding이라는 것도 언급했었다. 이제 이 문제점을 하나씩 해결해서 lockAndCall 함수의 최종버전을 만들어보자. 우선 perfect forwarding을 위해서 위에서 언급한 universal reference와 std::forward를 적용시켜보자. 가변인자 템플릿인 경우에는 표현방식이 약간 다르긴 하지만 큰 차이는 없다.

  다음으로는 f의 return type이 void가 아닐 수도 있는데, 이 경우에는 return값을 외부로 전달받을 수 있어야한다. 이를 위해서는 f의 return 타입을 알아야하는데 이때 가장 깔끔한 방법이 C++ 14에서 도입된 decltype(auto)이다. decltype(auto)는 이번 글의 주제와는 무관하니 컴파일러를 통해 함수의 return 타입을 추론할 수 있는 방법이라는 것 정도만 알아두자. 이제 이것을 위의 함수에 적용하면 lockAndCall 함수의 최종버전을 얻을 수 있다. (동기화 포함)

  위의 lockAndCall 함수는 인자의 타입이나 개수에 관계없이 'lock -> function call -> unlock' 이라는 기능을 충실히 수행한다. 그리고 대상 함수가 값을 반환하는 경우에는 이 값을 외부에서 전달받을 수도 있다. 기존 C++에서는 이를 완벽하게 구현하는 것이 어려웠지만 C++ 11/14의 새로운 기능들은 이러한 구현을 쉽고 깔끔하게 할 수 있게 해준다.