C++ 완벽한 전달(Perfect Forwarding)
출처
https://jongwook.tistory.com/743
완벽한 전달: 문제
이동 시맨틱과는 별개로 r-value 레퍼런스는 완벽한 전달(perfect forwarding) 문제를 해결하기 위해서 만들어졌습니다. 아래와 같은 간단한 팩토리 함수를 생각해 봅시다.
template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
return shared_ptr<T>(new T(arg));
}
보시다시피 이 예제의 의도는 팩토리 함수의 인수로 주어진 arg를 T의 생성자로 전달하는 것입니다. arg의 입장에서 보면 마치 팩토리 함수가 존재하지 않았고 생성자를 상위에서 직접 호출한 것처럼 행동하는 것이 이상적이겠죠. 이것이 완벽한 전달입니다. 이 코드는 그것에 비참하게 실패합니다. 우선 값호출(call by value)을 하는데 이때 생성자가 인자를 레퍼런스로 받는다면 더욱 안좋겠죠.
boost::bind 등이 선택한 가장 일반적인 해결책은 함수가 인자를 레퍼런스로 받도록 하는 것입니다.
template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
return shared_ptr<T>(new T(arg));
}
전보다는 나아졌지만 완벽하진 않습니다. 이번에 생긴 문제는 이 팩토리 함수가 r-value로는 호출할 수 없다는 것입니다.
factory<X>(hoo()); // hoo가 값을 반환한다면 에러
factory<X>(41); // 에러
이것은 상수 레퍼런스를 인자로 받는 오버로드를 만드는 것으로 해결합니다.
template<typename T, typename Arg>
shared_ptr<T> factory(Arg const & arg)
{
return shared_ptr<T>(new T(arg));
}
이 방식에는 두 가지 문제점이 있습니다. 우선 factory가 한 개가 아닌 여러 개의 인자를 받는 경우엔 각각이 상수일 때와 아닐 때의 모든 조합에 대해서 오버로드를 만들어야 합니다. 즉 인자가 많아지면 문제 해결이 엄청나게 힘들어지죠.
또한 이 방법은 이동 시맨틱을 구현할 수 없게 되므로 완벽하지 않습니다. factory함수의 내부에서 T의 복사 생성자로 전달되는 인자는 l-value이고, 따라서 래퍼 함수가 없었던 것처럼 이동 시맨틱이 일어날 수가 없습니다.
R-value 레퍼런스를 사용하면 이 두가지 문제를 모두 해결할 수 있습니다. R-value 레퍼런스로 오버로드 없이 완벽한 전달을 달성할 수 있습니다. 이 해결책을 이해하기 위해서 r-value 레퍼런스에 관한 두 가지 규칙을 더 살펴볼 필요가 있습니다.
완벽한 전달: 해결
남은 두 가지 규칙 중 첫번째는 기존의 l-value 레퍼런스에도 적용됩니다. C++11 이전의 C++에서는 레퍼런스의 레퍼런스를 취하는 것이 허용되지 않았습니다. 즉 A& &같은 걸 쓰면 컴파일 에러가 났죠. C++11에서는 반면 다음과 같은 레퍼런스 합침 규칙(reference collapsing rule)이 존재합니다.
- A& &는 A&이 된다
- A& &&는 A&이 된다
- A&& &는 A&이 된다
- A&& &&는 A&&이 된다
둘째로 템플릿 인자 유추 규칙(template argument deduction rule)이라는 템플릿 인자에 r-value 레퍼런스를 취하는 함수들을 위한 특별한 규칙이 있습니다.
template<typename T>
void foo(T&&);
여기서 두 가지 규칙이 적용됩니다.
- foo를 타입 A의 l-value로 호출한 경우 T는 A&로 처리되어 위의 레퍼런스 합침 규칙에 의해 결과적으로 함수 인자의 타입은 A&가 된다.
T=A& T&& -> foo(A& &&) -> A& 예를 들어, A a; foo(a);와 같은 호출에서는 T는 A&가 되므로 foo의 인자 타입은 A&가 된다.
- foo를 타입 A의 r-value로 호출한 경우 T는 A로 처리되며 함수 인자의 타입은 A&&가 된다.
T=A T&& -> foo(A&& &&) -> A&& 예를 들어, A a; foo(A());와 같은 호출에서는 T는 A가 되므로 foo의 인자 타입은 A&&가 된다.
이 규칙이 있으므로 이제 앞 섹션에 소개된 완벽한 전달 문제를 해결하는 데에 r-value 레퍼런스를 사용할 수 있습니다. 해결책은 아래와 같습니다.
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 키워드에 신경쓰지 않도록 합시다. 이 키워드는 컴파일러가 최적화를 할 동안 예외를 던지지 않도록 합니다. 여기에 대해서는 다음 섹션에서 다시 이야기합니다.)
위의 코드가 어떻게 완벽한 전달을 하게 되는지를 알아보기 위해 팩토리 함수가 l-value를 인자로 받는 경우와 r-value를 인자로 받는 경우를 나누어서 생각해 봅시다. A와 X라는 타입이 있고 factory< A >가 타입 X의 l-value로 호출되었다고 합시다.
X x;
factory<A>(x);
그러면 위에 소개된 템플릿 인자 유추 규칙에 의해 factory의 템플릿 인자 Arg는 X&가 됩니다. 따라서 컴파일러는 다음과 같은 factory와 std::forward의 인스턴스를 생성하게 됩니다.
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& forward(X& a) noexcept
{
return static_cast<X&>(a);
}
이렇게 하면 확실히 l-value에 대해서 완벽한 전달을 수행할 수 있습니다. 팩토리 함수의 인자 arg는 두 단계로 호출되는 동안 기존의 l-value 레퍼런스를 통해 전달됩니다.
다음으로 factory< A >가 타입 X의 r-value로 호출되었다고 해 봅시다.
X foo();
factory<A>(foo());
그러면 템플릿 유추 규칙에 의해 factory의 템플릿 인자 Arg는 X가 되고, 컴파일러는 다음과 같은 템플릿 함수 인스턴스를 생성합니다.
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);
}
이것이 바로 r-value의 완벽한 전달입니다. 팩토리 함수의 인자는 두 단계로 호출되는 동안 레퍼런스로 전달되며, 또한 A의 생성자에 전달되는 인자는 이름이 없는 r-value레퍼런스이기 때문에 "이름유무 규칙"에 의해 r-value입니다. 그러므로 A의 생성자는 r-value로 호출되고 이것은 마치 팩토리 함수가 없었던 것처럼 이동 시맨틱을 처리할 수 있습니다.
사실상 std::forward를 사용하는 것의 유일한 목적이 이동 시맨틱을 보존하기 위함이라는 것에 주목할 필요가 있습니다. std::forward가 없어도 모든 것이A의 생성자의 인수가 l-value로 해석된다는 것을 빼면 모든 것이 제대로 동작할 것입니다. 다시 말하자면 std::forward의 목적은 함수를 호출하는 쪽에서 래퍼가 l-value를 보는지, r-value를 보는지를 전달하기 위함입니다.
좀더 깊이 파고들어가 보자면, 이렇게 질문을 해 봅시다. std::forward의 정의 내부에서 왜 remove_reference가 필요할까요? 답은 그럴 필요가 없다는 것입니다. std::forward의 정의에서 remove_reference< S >::type& 대신 그냥 S&를 사용하더라도 위에서 한 것처럼 각각의 경우에서 완벽한 전달이 일어난다는 것을 확인할 수 있습니다. 그러나 이것은 우리가 Arg를 명시적으로 std::forward의 템플릿 인자로 사용하고 있을 때에만 그렇습니다. std::forward의 정의에 remove_reference가 있는 이유는 강제로 그렇게 하도록 하기 위함입니다.
자, 이제 거의 다 왔습니다. 이제 std::move가 어떻게 구현되었는지를 확인하는 일만 남았습니다. std::move의 목적은 인자를 레퍼런스로 받아서 r-value처럼 행동하도록 만드는 것임을 상기합시다. 구현은 다음과 같습니다.
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);
}
다음과 같이 std::move를 타입 X의 l-value로 호출했다고 합시다.
X x;
std::move(x);
템플릿 인자 유추 규칙에 의해 템플릿 인자 T는 X&로 해석되고, 따라서 컴파일러가 만드는 인스턴스는 다음과 같습니다.
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);
}
이제 다 했습니다. l-value였던 x가 l-value 레퍼런스 인자로 주어져서 함수를 통과한 결과는 이름 없는 r-value 레퍼런스가 되었습니다.
r-value에 대해서도 std::move가 제대로 동작하는지 살펴보겠습니다. 예를 들어,
std::move(X());
와 같이 r-value를 std::move에 전달하면, 템플릿 인자 T는 X가 되고, 컴파일러는 다음과 같은 인스턴스를 생성합니다.
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);
}
즉, r-value가 그대로 r-value로 전달됩니다. 따라서 std::move는 r-value에 대해서도 올바르게 동작하지만, 이미 r-value인 경우에는 std::move를 사용할 필요가 없습니다. std::move의 목적은 l-value를 r-value로 변환하는 것이기 때문입니다. 또
std::move(x);
를 호출하는 대신 그냥
static_cast<X&&>(x);
할 수 있다는 걸 눈치챘을지도 모르겠네요. 하지만 std::move를 사용하는 것이 더 표현력있기 때문에 강하게 권장됩니다.
R-value 레퍼런스와 예외처리
보통 C++로 소프트웨어를 개발하는 경우 예외 문법을 사용하고 예외처리에 신경을 쓰거나 하는 것은 여러분의 자유입니다. R-value 레퍼런스는 이것과 조금 다릅니다. 복사 생성자와 대입 연산자를 오버로드하는 경우에 다음 수칙을 지키는 것이 좋습니다.
- 오버로드한 함수들이 예외를 던지지 않도록 노력한다. 이동 시맨틱은 보통 두 객체의 포인터와 리소스 핸들을 교환하기 때문에 많은 경우 이것은 어렵지 않다.
- 오버로드들이 예외를 던지지 않도록 만드는 데에 성공했으면 noexcept 키워드를 사용해서 사용자에게 그 사실을 알린다.
이 작업을 거치지 않으면 기대와 달리 이동 시맨틱이 작동하지 않을 수 있습니다. 그 중 한가지 흔하게 발생하는 것이 std::vector가 리사이즈될 때인데, 요소들이 새 메모리 블록으로 이동될 때 위의 두가지 작업을 하지 않으면 이동 시맨틱은 일어나지 않습니다.
이렇게 되는 이유는 꽤 복잡한데, 자세한 이야기는 Dave Abraham의 블로그를 참고하세요. 이 블로그 글은 noexcept를 사용한 해결책이 생기기 전에 만들어졌지만 이 문제가 왜 생기는지를 잘 설명해 줍니다. noexcept가 어떻게 문제를 해결하는지에 대해서는 글 위쪽에 있는 update #2 링크를 확인하세요.
암시적인 이동(Implicit Move)의 경우
R-value 레퍼런스에 대한 (복잡하고 논란이 많았던) 회의에서 표준 위원회는 이동 생성자(move constructor)와 이동 대입 연산자(move assignment operator), 즉 복사 생성자와 복사 대입 연산자의 r-value 레퍼런스 오버로드를 사용자가 제공하지 않은 경우 컴파일러가 자동으로 생성되어야 한다고 정한 적이 있습니다. 컴파일러가 보통 복사 생성자와 복사 대입 연산자에 대해 똑같은 작업을 해 주기 때문에, 이 요구사항은 언뜻 보기엔 자연스럽고 이치에 맞는 것처럼 보입니다. 2010년 8월, Scott Meyers는 comp.lang.c++에 올린 글에서 컴파일러가 생성한 이동 생성자가 지금까지 사용하던 코드를 심각하게 망가뜨리는 예시를 들었습니다. Dave Abrahams가 이 문제를 블로그에 요약했습니다.
위원회는 결국 이것이 잘못되었다고 결정했고, 컴파일러가 이동 생성자와 이동 대입 연산자를 자동으로 생성하지 못하도록 해서 항상은 아니지만 대부분의 경우 코드를 망가뜨리지 못하도록 했습니다. 위원회의 이러한 결정은 Herb Sutter의 블로그에 요약되어 있습니다.
암시적인 이동에 관한 이슈는 C++ 표준이 완성될때까지 계속 논란이 되었습니다. (예: Dave Abrahams의 블로그 글과 이어지는 논의) 한가지 아이러니한것은 위원회가 애초에 암시적인 이동을 생각했던 것은 앞 섹션에 소개된 r-value 레퍼런스와 예외처리 때문에 발생하는 문제를 해결하기 위해서였습니다. noexcept를 이용한 해결책이 몇 달 전에만 나왔더라도 암시적인 이동은 세상에 나오지 않았을 지도 모르죠. 어쨌든, 이렇게 해서 암시적인 이동은 없어졌습니다.
자 이제 끝입니다. R-value 레퍼런스에 대한 모든 이야기를 했습니다. 보시는 것처럼 이것으로 인한 이득은 상당합니다만 자세히 들여다보자면 골치아프죠. C++ 전문가인데도 이런 내용을 알지 못한다면 아주 중요한 것을 포기한 것이 됩니다. 하지만 그래도 위안이 되는 건 평소에 프로그래밍 할 때에는 r-value 레퍼런스에 대해서는 다음 내용만 기억하면 된다는 것입니다.
- 다음과 같이 생긴 함수를 오버로드함으로써
컴파일타임에 foo가 l-value로 호출되고 있냐 r-value로 호출되고 있냐에 따라 분기할 수 있게 된다. 이것은 이동 시맨틱을 구현한 복사 생성자와 복사 대입 연산자를 오버로드할 때 주로 (현실적으로는 유일하게) 사용된다. 이것을 사용하는 경우엔 예외처리에 신경을 써야 하고, noexcept 키워드를 최대한으로 사용해야 한다.void foo(X& x); // l-value 레퍼런스 오버로드 void foo(X&& x); // r-value 레퍼런스 오버로드
- std::move는 인자를 r-value로 변환한다
- std::forward을 이용하면 섹션 8의 팩토리 함수 예제에서 본 것처럼 완벽한 전달을 할 수 있다.
'개발 > C++ (98,03,11,14,17,20,23)' 카테고리의 다른 글
Modern C++ : rvalue reference summary (2) | 2025.08.18 |
---|---|
Modern C++ : lvalue, rvalue, value category (2) | 2025.08.17 |
Modern C++ : rvalue reference (1) | 2025.08.16 |
Modern C++ : lvalue rvalue reference (98, 11) (2) | 2025.08.15 |
Modern C++ : dynamic heap memory allocation (98, 11, 14) (1) | 2025.08.14 |
Modern C++ : pointer and address (98, 11) (3) | 2025.08.13 |
Modern C++ : const reference and value semantics (98, 11) (1) | 2025.08.12 |
Modern C++ : string vs std::string vs std::array<char, N> (98, 11, 17) (1) | 2025.08.11 |