본문 바로가기
프로그래밍/C & C++

[C++] Perfect Forwarding

by 별준 2021. 8. 13.

References

Contents

  • Perfect Forwading
  • Universal Reference (보편 참조)
  • 참조 축약(Reference Collapsing)
  • std::forward

우측값 참조와 우측값 참조를 도입함으로써 해결할 수 있었던 Move Semantics의 관한 글에 이어서 이번 글에서는 Perfect Forwading에 대해서 알아보겠습니다.

2021.08.11 - [C & C++] - [C++] 우측값 참조(rvalue reference)

 

[C++] 우측값 참조(rvalue reference)

References 씹어먹는 C++ (https://modoocode.com/227) http://thbecker.net/articles/rvalue_references/section_07.html Contents 복사 생략(Copy Elision) 좌측값(lvalue)와 우측값(rvalue) 우측값 레퍼런스(rv..

junstar92.tistory.com

2021.08.12 - [C & C++] - [C++] Move Semantics


Perfect Forwarding

우측값 참조를 도입해서 해결할 수 있었던 또 다른 문제인 Perfect Forwading에 대해서 알아보기 위해서 C++11에 우측값 레퍼런스가 도입되기 전까지 해결할 수 없었던 wrapper 함수 예시로 시작해보겠습니다.

template <typename T>
void wrapper(T u) {
	g(u);
}

위 함수는 인자로 받은 u를 그대로 g라는 함수에 인자로 전달해주게 됩니다. 당연히 굳이 wrapper로 감싸지 않고, 직접 g(u)를 호출해도 되지만, 위와 같은 형태의 전달 방식이 사용되는 경우는 종종 있습니다.

예를 들자면, vector에는 emplace_back이라는 함수가 있는데, 이 함수는 객체의 생성자에 전달하고 싶은 인자들을 함수에 전달하면, 알아서 생성하여 vector에 추가합니다.

 

예를 들어서 클래스 A를 원소로 가지는 벡터의 뒤에 원소를 추가하기 위해서

v.push_back(A(1,2,3));

위와 같이 A 객체를 생성한 뒤에 인자로 전달해주어야 합니다. 하지만 이 과정에서 불필요한 이동 혹은 복사가 발생하게 됩니다.

push_back 대신 emplace_back 함수를 사용하게 되면,

v.emplace_back(1,2,3); // v.push_back(A(1,2,3)) 과 동일

emplace_back 함수가 인자를 직접 전달받아, 내부에서 A의 생성자를 호출한 뒤에 이를 벡터 원소 뒤에 추가하게 됩니다.

(사실 push_back 함수를 사용할 경우 컴파일러가 알아서 최적화를 해주기 때문에 불필요한 복사/이동을 수행하지 않고 emplace_back을 사용했을 때와 동일한 어셈블리를 생성합니다. 따라서 그냥 push_back을 사용하는 것이 훨씬 낫습니다. - emplace_back은 예상치 못한 생성자가 호출될 위험이 있음)

 

문제는 emplace_back 함수가 받은 인자들을 A의 생성자에 제대로 전달해야한다는 점인데, 그렇지 않을 경우 사용자가 의도하지 않은 생성자가 호출될 수 있기 때문입니다. 

아래 예시를 살펴봅시다.

#include <iostream>

template <typename T>
void wrapper(T u) {
	g(u);
}

class A {};

void g(A& a) { std::cout << "좌측값 레퍼런스 호출\n"; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출\n"; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출\n"; }

int main(void) {
	A a;
	const A ca;

	std::cout << "----- g 함수 직접 호출 -----\n";
	g(a);
	g(ca);
	g(A());

	std::cout << "----- Wrapper 함수 호출 ------\n";
	wrapper(a);
	wrapper(ca);
	wrapper(A());

	return 0;
}

위 코드를 컴파일 후 실행하면,

위와 같이 결과가 나옵니다.

 

처음 원본 g 함수를 호출할 때에는 예상대로 좌측값/좌측값 상수/우측값 레퍼런스가 각각 호출되었습니다. 

반면에 wrapper 함수를 거쳐서 g함수가 호출된 경우에는 위의 경우가 모두 좌측값 레퍼런스를 인자로 받는 g 함수가 호출되었습니다.

 

이와 같은 일이 발생한 이유는 C++ 컴파일러가 템플릿 타입을 추론할 때, 템플린 인자 T가 레퍼런스가 아닌 일반적인 타입이라면 const를 무시하기 때문입니다. 즉, wrapper 함수 정의에서 T가 전부 class A로 추론됩니다. 따라서, wrapper 함수를 호출한 경우 전부 다 좌측값 레퍼런스를 인자로 받는 g 함수가 호출되게 됩니다.

 

아래의 경우는 어떻게 될까요?

template <typename T>
void wrapper(T& u) {
	g(u);
}

wrapper 함수를 좌측값 레퍼런스를 인자로 받도록 변경하고 컴파일하면 아래의 에러가 발생합니다.

이 에러는 아래 코드에서 발생하는데,

wrapper(A());

위 코드에서 A()는 우측값이고, wrapper 함수에서 T는 class A로 추론됩니다. 하지만 wrapper의 인자 A&우측값의 레퍼런스가 될 수 없기 때문에 컴파일 에러가 발생하게 됩니다.

 

이러한 문제를 해결하기 위해서 아예 우측값을 레퍼런스로 받을 수 있도록 const A&와 A&를 따로 만들어주는 방법이 있습니다.

template <typename T>
void wrapper(T& u) {
	std::cout << "T&로 추론됨\n";
	g(u);
}

template <typename T>
void wrapper(const T& u) {
	std::cout << "const T&로 추론됨\n";
	g(u);
}

class A {};

void g(A& a) { std::cout << "좌측값 레퍼런스 호출\n"; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출\n"; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출\n"; }

int main(void) {
	A a;
	const A ca;

	std::cout << "----- g 함수 직접 호출 -----\n";
	g(a);
	g(ca);
	g(A());

	std::cout << "----- Wrapper 함수 호출 ------\n";
	wrapper(a);
	wrapper(ca);
	wrapper(A());

	return 0;
}

위 코드를 실행하면, 

위의 결과를 확인할 수 있습니다.

 

일단 a와 ca의 경우 각각 T&와 const T&로 잘 추론되어서 올바른 함수가 호출되고 있음을 확인할 수 있습니다. 하지만 A()의 경우 const T&로 추론되면서 g(const T&)의 함수를 호출하게 됩니다. 이는 우리가 무슨 짓을 하더라도 wrapper안의 u가 좌측값이라는 사실은 변하지 않기 때문에 언제나 좌측값 레퍼런스를 받는 함수들이 오버로딩될 것입니다.

 

이것뿐만 아니라 문제는 또 있습니다. 만약 함수 g가 인자를 한 개가 아니라 두 개를 전달받는 경우를 생각해봅시다.

이런 경우라면 우리는 아래의 모든 조합의 템플릿 함수를 정의해야만 합니다.

template <typename T>
void wrapper(T& u, T& v) {
	g(u, v);
}
template <typename T>
void wrapper(const T& u, T& v) {
	g(u, v);
}

template <typename T>
void wrapper(T& u, const T& v) {
	g(u, v);
}
template <typename T>
void wrapper(const T& u, const T& v) {
	g(u, v);
}

매우 귀찮은 일이 될 것입니다. 이렇게 구현해야하는 이유는 단순히 일반적인 레퍼런스가 우측값을 받을 수 없기 때문인데, 그렇다고 디폴트로 상수 레퍼런스만 받게 된다면, 상수가 아닌 레퍼런스도 상수 레퍼런스로 캐스팅되어서 들어가게 됩니다.

 

위와 같은 문제는 C++11에서는 쉽게 해결할 수 있습니다.


Universal Reference 보편 참조

#include <iostream>

template <typename T>
void wrapper(T&& u) {
	g(std::forward<T>(u));
}

class A {};

void g(A& a) { std::cout << "좌측값 레퍼런스 호출\n"; }
void g(const A& a) { std::cout << "좌측값 상수 레퍼런스 호출\n"; }
void g(A&& a) { std::cout << "우측값 레퍼런스 호출\n"; }

int main(void) {
	A a;
	const A ca;

	std::cout << "----- g 함수 직접 호출 -----\n";
	g(a);
	g(ca);
	g(A());

	std::cout << "----- Wrapper 함수 호출 ------\n";
	wrapper(a);
	wrapper(ca);
	wrapper(A());

	return 0;
}

위 코드를 실행시키면, 

위와 같이 잘 동작한다는 것을 확인할 수 있습니다.

template <typename T>
void wrapper(T&& u) {
	g(std::forward<T>(u));
}

위 코드에서 wrapper 함수는 인자로 아예 T&&을 받고 있습니다. 이렇게 템플릿 인자 T에 대해서, 우측값 레퍼런스를 받는 형태를 보편 참조(Universal Reference)라고 합니다. 이 보편 참조는 우측값만 받는 레퍼런스와는 다릅니다.

 

다음의 예시를 살펴봅시다.

#include <iostream>

void show_value(int&& t) {
	std::cout << "우측값 : " << t << std::endl;
}

int main(void) {
	show_value(5);	// ok

	int x = 3;
	show_value(x);	// error

	return 0;
}

위 코드를 컴파일하면, 

위와 같은 컴파일 에러가 발생합니다. 즉 show_value의 인자인 int&& t의 형태는 우측값만을 인자로 받을 수 있습니다.

template <typename T>
void wrapper(T&& u)

하지만, 위에서 템플릿 타입으로 정의된 wrapper 함수의 우측값 참조는 다릅니다. 이 보편 참조는 우측값뿐만 아니라 좌측값 역시 받을 수 있습니다.

 

C++11에서는 아래와 같은 참조 축약(reference collapsing)에 따라서 T의 타입을 추론하게 됩니다.

typedef int& T;
T& r1;	// int& & r1 -> int& r1
T&& r2;	// int&& & r2 -> int& r2

typedef int&& U;
U& r3;	// int&& & r3 -> int& r3
U&& r4;	// int&& && r4 -> int&& r4

컴파일러는 참조에 대한 참조를 보면, 그 결과를 하나의 참조로 나타냅니다. 이 결과는 원래의 두 참조 중 하나라도 좌측값이라면 결과는 좌측값이고, 그렇지 않으면(즉, 둘 다 우측값이라면) 우측값이 됩니다.

따라서 위에서 T&은 int& &, 즉, 좌측값 참조의 좌측값 참조이므로 결과는 좌측값 참조, T&&의 경우에는 int&& & 이므로 r2는 마찬가지로 좌측값이 됩니다.

반면에 마지막 U&&의 경우에는 int&& && 이므로, r4는 우측값 참조가 됩니다.

(쉽게 생각하면 &는 1이고 &&은 0이라 둔 뒤에 OR 연산을 한다고 생각하면 됩니다.)

 

따라서, 

wrapper(a);
wrapper(ca);

는 T가 각각 A&와 const A&로 추론되고, (T&&와 좌측값(A&/const A&)의 참조 축약 결과)

wrapper(A());

에서는 T가 단순히 A로 추론될 것입니다. (T&&와 우측값(A&&)의 참조축약 결과)

 

이제 문제는 직접 g에 이 인자를 전달하는 방법입니다.

g(u);

만약 wrapper 함수 내에서 아무런 조치없이 그대로 위 코드를 사용하게 되면, u는 좌측값이므로 A&&을 오버로딩하는 g를 호출할 것이라고 생각하지만 실제로는 const A&를 오버로딩하는 g가 호출됩니다. 따라서 이 경우에는 move 함수를 통해서 u를 다시 우측값으로 변환해야합니다.

하지만, 당연히 move 함수를 아무렇게나 사용하면 안됩니다. 인자로 받은 u가 우측값 레퍼런스일 때만 move 함수를 사용해주어야 하며, 만약 좌측값 레퍼런스일 때 move를 사용한다면 좌측값을 오버로딩하는 g가 아니라 우측값을 오버로딩하는 g가 호출될 것입니다.

 

이 문제를 해결해주는 것이 바로 forward 함수입니다.

g(std::forward<T>(u));

이 함수는 u가 우측값 레퍼런스일 때만 마치 move 함수를 적용한 것처럼 동작합니다. 

실제로 forward는 아래와 같이 생겼습니다. (type_traits 헤더 내 정의; VS2019)

// FUNCTION TEMPLATE forward
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
    return static_cast<_Ty&&>(_Arg);
}

만약 _Ty가 A& 라면 (편의를 위해 _NODISCARD / constexpr는 생략)

A&&& forward(typename std::remove_reference_t<A&>::type& a) noexcept {
	return static_cast<A&&&>(a);
}

이 되고 참조 축약에 의해서 (std::remove_reference_t 는 타입의 레퍼런스를 지워주는 템플릿 메타 함수 입니다.)

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

가 됩니다. 따라서 좌측값을 인자로 받는 경우에는 forward는 똑같이 좌측값 레퍼런스를 반환하게 됩니다.

 

만약 _Ty가 A 라면

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

가 되어서 우측값으로 캐스팅해주게 됩니다.

따라서 성공적으로 인자를 전달하게 됨으로 wrapper 함수를 사용했을 때에 호출되는 함수가 모두 원본 g 함수를 호출했을 때와 동일하게 됩니다.

 

이렇게 우측값 참조가 도입됨으로써 Perfect Forwading 문제를 해결할 수 있습니다.

댓글