[Modern C++] 항목 28: 참조 축약을 숙지하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 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로 추론된다.
    • 참조 축약이 적용된다.

③ 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 참조다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기