[Modern C++] 항목 24: 보편 참조와 rvalue 참조를 구별하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 5. rvalue 참조, 이동 개념, 완벽 전달

👉🏻 항목 24: 보편 참조와 rvalue 참조를 구별하라

🔍 “T&&”의 두 가지 의미

  • 소스 코드에서 "T&&" 형태로 보인다고 해서 항상 rvalue 참조인 것은 아니다. "T&&"에는 서로 다른 두 가지 의미가 있다.
    1. rvalue 참조: 예상한 그대로 행동한다. 오직 rvalue에만 묶이며, 존재 이유는 이동의 원본이 될 수 있는 객체를 지정하는 것이다.
    2. 보편 참조(universal reference): 소스 코드에서는 rvalue 참조("T&&")처럼 보이지만, 때로는 lvalue 참조("T&")인 것처럼 행동한다. rvalue에도, lvalue에도 묶일 수 있고, const/비const, volatile/비volatile 객체 모두에 묶일 수 있다. 즉, 거의 모든 것에 묶일 수 있다.
      • 항목 25에서 설명하듯이 보편 참조에는 거의 항상 std::forward를 적용해야 하므로, C++ 공동체에서는 이를 전달 참조(forwarding reference)라고도 부른다.
template<typename T>
void f(T&& param);          // param은 보편 참조

🔍 보편 참조가 나타나는 두 가지 문맥

① 함수 템플릿 매개변수

template<typename T>
void f(T&& param);       // param은 보편 참조

Widget w;
f(w);                    // f에 lvalue가 전달됨; param의 타입은
                          // Widget&(즉, lvalue 참조)

f(std::move(w));         // f에 rvalue가 전달됨; param의 타입은
                          // Widget&&(즉, rvalue 참조)

② auto 선언

auto&& var2 = var1;      // var2는 보편 참조
  • 두 문맥의 공통점은 타입 추론(type deduction)이 일어난다는 점이다. f에서는 param의 타입이 추론되고, var2 선언에서는 var2의 타입이 추론된다.
  • 타입 추론이 일어나지 않는 문맥에서 "T&&"를 발견했다면, 그것은 rvalue 참조다.
void f(Widget&& param);        // 타입 추론 없음
                                // param은 rvalue 참조

Widget&& var1 = Widget();      // 타입 추론 없음
                                // var1은 rvalue 참조

🔍 보편 참조의 성질 판별 — 초기값이 결정

  • 보편 참조는 참조이므로 반드시 초기화해야 한다. 초기값이 rvalue이면 보편 참조는 rvalue 참조에 해당하고, 초기값이 lvalue이면 lvalue 참조에 해당한다.
  • 보편 참조가 함수의 매개변수인 경우, 초기값은 그 함수를 호출하는 지점에서 제공된다.

🔍 형태(form)가 정확해야 한다 — 딱 “T&&”이어야 함

  • 타입 추론이 일어나는 것은 필요조건일 뿐 충분조건은 아니다. 참조 선언의 형태도 반드시 정확히 "T&&"이어야 한다.
template<typename T>
void f(std::vector<T>&& param);  // param은 rvalue 참조
  • f 호출 시 타입 T는 추론되지만, param의 타입 선언 형태가 "T&&"가 아니라 "std::vector<T>&&"이므로 param은 보편 참조가 될 수 없다. 따라서 param은 rvalue 참조다. 이 점은 f에 lvalue를 전달해 보면 컴파일러가 확인해 준다.
std::vector<int> v;
f(v);                    // 오류! lvalue를 rvalue
                          // 참조에 묶을 수 없음
  • const 한정자 하나만 붙어도 참조는 보편 참조가 되지 못한다.
template<typename T>
void f(const T&& param);  // param은 rvalue 참조

🔍 템플릿 안 “T&&”라고 다 보편 참조는 아니다 — 타입 추론이 일어나지 않는 경우

  • std::vector::push_back을 보자.
template<class T, class Allocator = allocator<T>>  // C++ 표준에서 발췌
class vector {
public:
  void push_back(T&& x);
  
};
  • push_back의 매개변수는 형태상 보편 참조가 요구하는 조건과 일치하지만, 이 경우에는 타입 추론이 전혀 일어나지 않는다. push_back은 반드시 구체적으로 인스턴스화된 vector의 일부여야 하고, 그 인스턴스의 타입이 이미 push_back의 선언을 완전히 결정하기 때문이다.
std::vector<Widget> v;
  • 이 선언에 의해 vector 템플릿은 다음과 같이 인스턴스화된다.
class vector<Widget, allocator<Widget>> {
public:
  void push_back(Widget&& x);  // rvalue 참조
  
};
  • 반면, 개념적으로 비슷한 emplace_back 멤버 함수는 실제로 타입 추론을 사용한다.
template<class T, class Allocator = allocator<T>>  // 역시 C++ 표준에서 발췌
class vector {
public:
  template<class... Args>
  void emplace_back(Args&&... args);
  
};
  • Argsvector의 타입 매개변수 T와 독립적이며, emplace_back이 호출될 때마다 추론되어야 한다. (엄밀히는 Args는 타입 매개변수가 아니라 매개변수 팩(parameter pack)이지만, 논의 목적상 타입 매개변수로 간주해도 무방하다.)
  • emplace_back의 타입 매개변수 이름이 T가 아니라 Args이지만 여전히 보편 참조라는 점은, 보편 참조의 형태를 반드시 T&&라고 할 필요가 없음을 보여준다.
template<typename MyTemplateType>
void someFunc(MyTemplateType&& param);  // param은 보편 참조

🔍 auto&& — 람다에서의 보편 참조

  • auto&&를 타입으로 해서 선언된 변수 역시 타입 추론이 일어나고 형태("T&&")가 정확하므로 보편 참조다.
  • C++14에서는 람다 표현식에서 auto&& 매개변수를 선언할 수 있어 더 자주 나타난다.
auto timeFuncInvocation =
  [](auto&& func, auto&&... params)   // C++14
  {
    타이머를 시작한다;
    std::forward<decltype(func)>(func)(   // params로
      std::forward<decltype(params)>(params)...  // func를 호출
    );
    타이머를 정지하고 경과 시간을 기록한다;
  };
  • func는 어떤 호출 가능 객체(lvalue든 rvalue든)와도 묶일 수 있는 보편 참조이고, params는 임의의 타입, 임의의 개수의 객체들과 묶일 수 있는 0개 이상의 보편 참조로 이루어진 매개변수 팩이다. auto 보편 참조 덕분에 timeFuncInvocation은 거의 모든 함수의 실행 시간을 측정할 수 있다(“모든”이 아니라 “거의 모든”인 이유는 항목 30에서 밝혀진다).

🔍 이 항목은 하나의 ‘추상’이다

  • 이 항목 전체가 사실은 하나의 추상(abstraction)이며, 그 바탕에 깔린 진실은 참조 축약(reference collapsing)이라는 것이다(항목 28에서 중점적으로 다룸).
  • 그럼에도 이 추상은 유용하다. rvalue 참조와 보편 참조를 구분하면 소스 코드를 더 정확하게 읽는 데 도움이 되고(“이 T&&가 rvalue에만 묶일까, 아무것에나 묶일까?”), 동료와의 대화에서 중의성을 피할 수 있다(“이 부분은 rvalue 참조가 아니라 보편 참조인데요…”). 또한 항목 25, 26을 이해하는 데에도 필요하다.

🧐 정리

  • 함수 템플릿 매개변수의 타입이 T&& 형태이고 T가 추론된다면, 또는 객체를 auto&&로 선언한다면, 그 매개변수나 객체는 보편 참조다.
  • 타입 선언의 형태가 정확히 타입&&가 아니거나, 타입 추론이 일어나지 않으면, 타입&&rvalue 참조를 뜻한다.
  • rvalue로 초기화되는 보편 참조는 rvalue 참조에 해당한다. lvalue로 초기화되는 보편 참조는 lvalue 참조에 해당한다.

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

댓글남기기