[Modern C++] 항목 28: 참조 축약을 숙지하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 5. rvalue 참조, 이동 개념, 완벽 전달
👉🏻 항목 28: 참조 축약을 숙지하라
🔍 도입 — lvalue/rvalue 부호화의 조건
- 항목 23에서 언급했듯이, 인수가 템플릿에 전달되면 템플릿 매개변수에 대해 추론된 타입에는 그 인수가 lvalue인지 rvalue인지에 대한 정보가 부호화되어 있다. 그런데 이는 오직 인수가 보편 참조 매개변수를 초기화하는 데 쓰일 때에만 일어난다.
template<typename T>
void func(T&& param);
- 이 부호화 메커니즘은 간단하다: lvalue 인수가 전달되면
T는 lvalue 참조로 추론되고, rvalue가 전달되면T는 비참조(non-reference) 타입으로 추론된다.- 비대칭성 주의: lvalue는 lvalue 참조로 부호화되지만, rvalue는 참조가 아닌 타입으로 추론된다.
Widget widgetFactory(); // rvalue를 돌려주는 함수
Widget w; // 변수(lvalue)
func(w); // func를 lvalue로 호출한다; T는 Widget&로 추론된다
func(widgetFactory()); // func를 rvalue로 호출한다; T는 Widget으로 추론된다
- 이 구분이 바로 보편 참조가 rvalue 참조가 될지 lvalue 참조가 될지를 결정하며,
std::forward가 작동하는 바탕 메커니즘이기도 하다.
🔍 참조에 대한 참조는 위법 — 그런데 특정 문맥에서는 예외
int x;
auto& & rx = x; // 오류! 참조에 대한 참조는 선언할 수 없음
- 그런데 보편 참조를 받는 함수 템플릿에 lvalue를 넘겨주면 어떻게 될까?
T가Widget&로 추론되어 이를 대입하면:
void func(Widget& && param); // 참조에 대한 참조!
- 컴파일러는 불평하지 않는다. 최종적으로 만들어지는 함수 시그니처는 다음과 같다.
void func(Widget& param);
- 이는 참조 축약(reference collapsing) 덕분이다. 참조에 대한 참조가 위법인 것은 사실이지만, 특정 문맥(템플릿 인스턴스화 등)에서는 컴파일러가 참조에 대한 참조를 산출하는 것이 허용되며, 그런 경우 참조 축약 규칙이 적용된다.
🔍 참조 축약 규칙
- 참조는 두 종류(lvalue, rvalue)이므로 참조에 대한 참조로 가능한 조합은 네 가지다(lvalue-lvalue, rvalue-lvalue, lvalue-rvalue, rvalue-rvalue).
만일 두 참조 중 하나라도 lvalue 참조이면 결과는 lvalue 참조다. 그렇지 않으면(즉, 둘 다 rvalue 참조이면) 결과는 rvalue 참조다.
- 앞의 예제에서 추론된 타입
Widget&를 템플릿func에 대입하면 lvalue 참조에 대한 rvalue 참조가 되며, 축약 규칙을 적용한 결과는 lvalue 참조다.
🔍 std::forward의 구현 원리
std::forward가 작동하는 것은 바로 이 참조 축약 덕분이다.std::forward는 보편 참조 매개변수에 적용되며, 일반적인 용례는 다음과 같다.
template<typename T>
void f(T&& fParam)
{
… // 어떤 작업을 수행
someFunc(std::forward<T>(fParam)); // fParam을 someFunc로 전달
}
fParam은 보편 참조이므로,f에 전달된 인수가 lvalue인지 rvalue인지에 대한 정보가T에 부호화된다.std::forward의 임무는f에 전달된 인수가 rvalue라는 점이T에 부호화되어 있으면(즉T가 비참조 타입이면), 그리고 오직 그럴 때에만,fParam(lvalue)을 rvalue로 캐스팅하는 것이다.
template<typename T> // std 이름공간 안에서
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}
① lvalue 인수가 전달된 경우 (T = Widget&)
Widget& && forward(typename remove_reference<Widget&>::type& param)
{ return static_cast<Widget& &&>(param); }
remove_reference<Widget&>::type은Widget을 산출하므로:
Widget& && forward(Widget& param)
{ return static_cast<Widget& &&>(param); }
- 참조 축약을 반환 타입과 캐스팅에 모두 적용하면 최종 버전은 다음과 같다.
Widget& forward(Widget& param) // 여전히 이름공간 std 안에서
{ return static_cast<Widget&>(param); }
- 즉, lvalue 인수가 전달되면
std::forward는 lvalue 참조를 받아서 lvalue 참조를 돌려준다. lvalue 참조는 정의상 lvalue이므로,std::forward에 lvalue를 넘겨주면 lvalue가 반환된다 — 마땅히 그래야 하는 결과.
② rvalue 인수가 전달된 경우 (T = Widget)
Widget&& forward(typename remove_reference<Widget>::type& param)
{ return static_cast<Widget&&>(param); }
remove_reference<Widget>::type은 그대로Widget이므로:
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }
-
여기에는 참조에 대한 참조가 없으므로 참조 축약도 필요 없다. 함수가 돌려준 rvalue 참조는 정의상 rvalue이므로,
f에 전달된 rvalue 인수는 하나의 rvalue로서 대상 함수에 전달된다 — 이 역시 마땅히 그래야 하는 결과. -
C++14에서는
std::remove_reference_t덕분에 좀 더 간결하게 구현할 수 있다.
template<typename T> // C++14
T&& forward(remove_reference_t<T>& param) // 여전히 이름공간 std 안에서
{
return static_cast<T&&>(param);
}
🔍 참조 축약이 일어나는 네 가지 문맥
① 템플릿 인스턴스화 (위에서 설명)
② auto 변수의 타입 추론
auto변수의 타입 추론은 템플릿의 타입 추론과 본질적으로 같다(항목 2 참고).
auto&& w1 = w; // w1을 lvalue로 초기화; auto는 Widget&로 추론
// → Widget& && w1 = w; → 축약 → Widget& w1 = w;
auto&& w2 = widgetFactory(); // w2를 rvalue로 초기화; auto는 Widget으로 추론
// → Widget&& w2 = widgetFactory(); (참조 대 참조 없음)
- 결과적으로
w1은 lvalue 참조,w2는 rvalue 참조다. - 이제 보편 참조의 진면모가 밝혀진 셈이다. 보편 참조는 새로운 종류의 참조가 아니다. 다음 두 조건이 만족되는 문맥에서 사실상 rvalue 참조인 것뿐이다.
- 타입 추론에서 lvalue와 rvalue가 구분된다:
T타입의 lvalue는T&로 추론되고,T타입의 rvalue는T로 추론된다. - 참조 축약이 적용된다.
- 타입 추론에서 lvalue와 rvalue가 구분된다:
③ typedef와 별칭 선언의 지정 및 사용 (항목 9 참고)
template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;
…
};
Widget<int&> w;
Widget의T를int&로 대체하면:
typedef int& && RvalueRefToT;
- 참조 축약에 의해 다음으로 줄어든다.
typedef int& RvalueRefToT;
typedef에 붙인 이름(RvalueRefToT)이 오히려 혼란을 줄 수 있음이 드러난다.Widget을 lvalue 참조 타입으로 인스턴스화하면RvalueRefToT는 rvalue 참조가 아니라 lvalue 참조에 대한 typedef가 된다.
④ decltype 사용
- 컴파일러가
decltype에 관여하는 타입을 분석하는 도중에 참조에 대한 참조가 발생하면 참조 축약이 등장해서 그것을 제거한다(decltype은 항목 3 참고).
🧐 정리
- 참조 축약은 템플릿 인스턴스화,
auto타입 추론,typedef와 별칭 선언의 지정 및 사용,decltype의 지정 및 사용이라는 네 가지 문맥에서 일어난다. - 컴파일러가 참조 축약 문맥에서 참조에 대한 참조를 만들어 내면, 그 결과는 하나의 참조가 된다. 원래의 두 참조 중 하나라도 lvalue 참조이면 결과는 lvalue 참조이고, 그렇지 않으면 rvalue 참조다.
- 타입 추론이 lvalue와 rvalue를 구분하는 문맥과 참조 축약이 일어나는 문맥에서 보편 참조는 rvalue 참조다.
댓글남기기