[C++] Perfect forwarding

6 분 소요

Perfect forwarding은 원본 함수(func)와 원본 함수의 인자를 인자로 받는 래퍼 함수(templateFunc)가 있을 때, 원본 함수의 인자를 완벽하게 전달하는 것을 의미한다.

perfect forwarding이 무엇인지 확인하기 전에 다음과 같은 상황을 먼저 확인해보자.

#include <iostream>

template <typename T>
void templateFunc(T param) {
    func(param);
}

class Base {
public:
    Base() = default;
};

void func(Base& param) {
    std::cout << "lvalue reference" << std::endl;
}
void func(const Base& param) {
    std::cout << "lvalue constant reference" << std::endl;
}
void func(Base&& param) {
    std::cout << "rvalue reference" << std::endl;
}

int main() {
    Base b;
    const Base cb;

    std::cout << "=========Func=========" << std::endl;
    func(b);
    func(cb);
    func(Base());

    std::cout << "=====TemplateFunc=====" << std::endl;
    templateFunc(b);
    templateFunc(cb);
    templateFunc(Base());

    return 0;
}
=========Func=========
lvalue reference
lvalue constant reference
rvalue reference
=====TemplateFunc=====
lvalue reference
lvalue reference
lvalue reference

func 함수를 곧바로 호출한 경우에는 각각 “lvalue reference”, “lvalue constant reference”, “rvalue reference” 메시지가 출력되었다. 하지만 래퍼 함수인 templateFunc 함수를 거친 경우에는 세 가지 경우 모두 void func(Base& param)함수가 호출되어 “lvalue reference” 메시지가 출력되었다.

  • templateFunc(cb);의 결과가 “lvalue reference”로 나오는 이유(“lvalue constant reference”가 아닌 이유)는 C++ 컴파일러가 template 타입 T를 추론할 때, T가 레퍼런스(reference) 타입이 아니라면 const가 없는 것으로 간주하기 때문이다.

즉, 아래의 코드에서 T가 전부 class Base로 추론되어 func(Base& param) 함수만 호출되었다.

template <typename T>
void templateFunc(T param) {
    func(param);
}

templateFunc의 매개변수 T를 아래와 같이 레퍼런스 형태로 바꾸면, non-const 참조는 lvalue에만 바인딩 할 수 있기 때문에 templateFunc(Base()); 문장에서 에러가 발생한다.

template <typename T>
void templateFunc(T& param) {
    func(param);
}

그래서 templateFunc(T& param)templateFunc(const T& param) 2개의 래퍼 함수를 각각 생성하는 방법이 있다.

template <typename T>
void templateFunc(T& param) {
    func(param);
}

template <typename T>
void templateFunc(const T& param) {
    func(param);
}
=========Func=========
lvalue reference
lvalue constant reference
rvalue reference
=====TemplateFunc=====
lvalue reference
lvalue constant reference
lvalue constant reference

상수 레퍼런스만 받게 된다면, 상수가 아닌 레퍼런스도 상수 레퍼런스로 인식되므로 templateFunc(Base());의 호출 결과가 “lvalue constant reference”가 되었다.

그렇다면 rvalue reference를 받는 래퍼 함수(templateFunc(T&& param))를 하나 더 추가하여 func(Base&& param)가 호출되도록 할 수 있을 것이라는 생각이 들 수 있으나, 아래의 templateFunc 함수 내부에 진입했을 때 매개변수 param는 항상 lvalue이다.

template <typename T>
void templateFunc(T& param) {
    func(param);
}

template <typename T>
void templateFunc(const T& param) {
    func(param);
}

template <typename T>
void templateFunc(T&& param) {
    func(param);
}
=========Func=========
lvalue reference
lvalue constant reference
rvalue reference
=====TemplateFunc=====
lvalue reference
lvalue constant reference
lvalue reference

그래서 이 상태로 실행을 해도 templateFunc(T&& param)를 호출한 결과에서는 “rvalue reference” 메시지를 볼 수가 없다. 래퍼 함수의 매개변수는 항상 lvalue이므로 lvalue reference를 받는 func(Base& param) 함수가 호출되기 때문이다.

그리고 만약에 템플릿 인자가 하나가 아니라면, 호출될 수 있는 상황을 고려하여 모든 조합의 템플릿 함수를 정의해야 하는 불편함이 있다.

하지만 C++11부터는 universal reference 개념을 통해 이 문제를 해결할 수 있다.

Universal reference

템플릿 인자 rvalue reference(T&&)는 그 표현 자체로는 rvalue reference를 의미하지만 때로는 rvalue 또는 lvalue reference로 해석될 수 있다. 문법상으로는 rvalue reference이지만, 실제 의미는 rvalue reference가 아닐 수 있다는 것이다. 이렇게 템플릿 인자 T에 대해서 우측값 레퍼런스를 받는 형태는 특이한 유연성을 갖으며 이러한 reference를 가리켜 universal reference라고 한다.


즉, 이전까지는 아래처럼 3가지 형태의 래퍼 함수를 모두 정의하였지만

template <typename T>
void templateFunc(T& param) {
    func(param);
}

template <typename T>
void templateFunc(const T& param) {
    func(param);
}

template <typename T>
void templateFunc(T&& param) {
    func(param);
}

universal reference 개념을 적용한다면 위의 코드를 아래와 같이 하나의 형태로만 사용할 수 있고, 실행 결과는 다음과 같다.

template <typename T>
void templateFunc(T&& param) {
    func(param);
}
=========Func=========
lvalue reference
lvalue constant reference
rvalue reference
=====TemplateFunc=====
lvalue reference
lvalue constant reference
lvalue reference

templateFunc(T&& param)의 매개변수 타입은 rvalue reference(T&&)이지만, lvalue reference와 lvalue constant reference를 잘 구분하였다.

여기서 중요한 것은 universal reference우측값만 받는 레퍼런스와는 다르다는 것이다.

#include <iostream>

class Base {
public:
    Base() = default;
};

void func(Base&& param) { // 우측값만 받는 레퍼런스
    std::cout << "rvalue reference" << std::endl;
}

int main() {
    func(Base()); // valid

    Base b;
    func(b); // error : lvalue를 rvalue 참조에 바인딩할 수 없음
    return 0;
}

Base&& param는 우측값만을 인자로 받을 수 있기 때문에, lvalue 형태로 호출할 수 없다.

Reference collapsing rule

아래 코드와 같이 템플릿에 사용된 우측값 레퍼런스(universal reference)는 우측값 뿐만 아니라 좌측값도 받아 낼 수 있다.

template <typename T>
void templateFunc(T&& param) {
    func(param);
}

C++11에서는 reference collapsing rule에 따라 T의 타입을 추론하여 universal reference가 가능하게 한다.

#include <iostream>

class Base {
public:
    Base() = default;
};

typedef Base&  LR; // Lvalue Reference
typedef Base&& RR; // Rvalue Reference

int main() {

    Base b;

    LR ref1 = b;
    RR ref2 = Base();

    LR& ref3 = b;       // Base&   & -> Base&
    LR&& ref4 = b;      // Base&  && -> Base&

    RR& ref5 = b;       // Base&&  & -> Base&
    RR&& ref6 = Base(); // Base&& && -> Base&&

    // 아래와 같이 사용자가 명시적으로 reference collapsing를 일으키는 것은 불가능하다.
    // reference collapsing는 컴파일러가 타입을 추론할 때 사용한다.
    Base& & r7 = b;
    Base& && r7 = b;
    Base&& & r7 = b;
    Base&&&& r7 = Base();

    return 0;
}

lvalue referencervalue reference 두 개의 레퍼런스 타입에 의해 4가지의 레퍼런스의 레퍼런스 조합이 존재할 수 있다. 편의상으로 L과 R로 정의한다.

  • L : lvalue reference
  • R : rvalue reference
reference reference result
& & L + L
& && L + R
&& & R + L
&& && R + R

두 가지의 reference collapsing 규칙이 다음과 같이 존재한다.

  • && && (R + R)인 경우에만 &&

  • 그 외 (L이 하나라도 포함된) 조합은 &
  • 즉, 각 reference를 & = 1, && = 0으로 생각하고 OR 연산한 결과와 같다.

reference collapsing rule을 적용한 결과는 다음과 같다.

reference reference result  
& & L + L &
& && L + R &
&& & R + L &
&& && R + R &&

Perfect forwarding

위에서 살펴보았던 universal reference를 통해서 다음과 같은 템플릿 하나로도 실행을 할 수 있었지만

template <typename T>
void templateFunc(T&& param) {
    func(param);
}

templateFunc(Base());의 호출 결과로 “lvalue reference”가 출력되었다.

templateFunc(T&& param)의 함수로 진입했을 때 매개변수 b는 항상 lvalue이므로 lvalue reference를 받는 func(Base& param) 함수가 호출되기 때문이다.

우리가 의도한 대로 인자를 전달하고 “rvalue reference” 메시지를 출력하기 위해서 std::forward<T>(param)를 사용할 수 있다.

이 경우에 templateFunc(Base()); 호출 결과가 “lvalue reference”로 출력되지 않는다.

#include <iostream>

template <typename T>
void templateFunc(T&& param) {
    func(std::forward<T>(param));
}

class Base {
public:
    Base() = default;
};

void func(Base& param) {
    std::cout << "lvalue reference" << std::endl;
}
void func(const Base& param) {
    std::cout << "lvalue constant reference" << std::endl;
}
void func(Base&& param) {
    std::cout << "rvalue reference" << std::endl;
}

int main() {
    Base b;
    const Base cb;

    std::cout << "=========Func=========" << std::endl;
    func(b);
    func(cb);
    func(Base());

    std::cout << "=====TemplateFunc=====" << std::endl;
    templateFunc(b);
    templateFunc(cb);
    templateFunc(Base());

    return 0;
}
=========Func=========
lvalue reference
lvalue constant reference
rvalue reference
=====TemplateFunc=====
lvalue reference
lvalue constant reference
rvalue reference

실행을 해보면 의도대로 잘 작동함을 확인할 수 있다.

templateFunc(b);  // T: Base&, param: Base&
templateFunc(cb); // T: const Base&, param: const Base&

위의 두 가지 함수 호출은 reference collapsing rule에 의하여 templateFunc(T&& param)에서 T가 각각 Base&, const Base&로 추론되고,

templateFunc(Base()); // T: Base, param: Base&&

rvalue 형태로 호출하는 경우에는 TBase로 추론된다.

즉, 이전에 forward 함수를 사용하지 않았을 때에는

template <typename T>
void templateFunc(T&& param) {
    func(param);
}

paramBase&& 타입으로 추론되어도 lvalue이기 때문에 func(Base& param) 함수가 호출되었다.

이것을 방지하기 위해 Base&& 타입으로 추론된 경우에는

template <typename T>
void templateFunc(T&& param) {
    func(std::forward<T>(param));
}

우측값으로 변환하기 위하여 forward 함수를 사용하여 func(Base&& param) 함수가 호출되도록 할 수 있다.

forward 함수는 다음과 같이 <utility>에 정의되어 있다. 두 가지 형태의 forward 함수가 존재하지만, 여기서는 일반적으로 가장 많이 사용되는 형태만 확인한다.

template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) noexcept {
    return static_cast<T&&>(t)
}
  • std::remove_reference는 타입의 레퍼런스를 제거하는 템플릿 메타 함수이다.

여기서 forward 함수 매개변수 타입이 &인 이유는 다음과 같다.

첫 번째는 <class T>에서 TBase&인 경우이다.

Base&&& forward( typename std::remove_reference<Base&>::type& t ) noexcept {
    return static_cast<Base&&&>(t)
}

이것은 reference collapsing rule에 의해 다음과 같이 변한다.

Base& forward( typename std::remove_reference<Base&>::type& t ) noexcept {
    return static_cast<Base&>(t)
}

즉, templateFunc(T&& param)에서 param 타입이 lvalue인 경우에는 그대로 lvalue 형태를 반환한다.

두 번째는 <class T>에서 TBase인 경우이다.

Base&& forward( typename std::remove_reference<Base>::type& t ) noexcept {
    return static_cast<Base&&>(t)
}

이 경우 T 위치에 Base가 그대로 들어가게 되며, rvalue 형태로 반환된다.

이 과정을 순서대로 나타내면 아래와 같다.

templateFunc(Base()); // T: Base, param: Base&&

template <typename T>
void templateFunc(T&& param) { // T: Base, param: Base&&
    func(std::forward<T>(param));
}

Base&& forward( typename std::remove_reference<Base>::type& t ) noexcept {
    return static_cast<Base&&>(t) // return rvalue 
}

void func(Base&& param) {
    std::cout << "rvalue reference" << std::endl;
}

결과적으로 템플릿을 통한 래퍼 함수에서도 universal reference 개념과 std::forward 함수를 사용하여 인자가 잘 전달되도록 만들어 호출되는 함수가 동일해지도록 만들 수 있다.