Thursday, January 13, 2022

C++ Reference Collapsing (引用折叠) 及 Forwarding Reference (轉發引用)

1. 引言
在 C++ 98 初推出的時候,C++ 並沒有 Reference of Reference 的概念 (&&),所以,開發者不但不能直接使用 &&,即使用了 typedef 將 reference type 包裝好,然後再加上 &,編譯器都一律會報錯。

後來,C++11 終於開放了對 Reference of Reference (&&) 的支援。不過由於 C++11 也同時引進了 Move Semantics (也是 &&) 的寫法,所以,Reference of Reference 就會跟 Move Semantics 有所衝突。

以下是一個例子,開發者想做的是:func(k) 要跑的,是 lvalue 的版本的 func()(第一個,單 & 的版本),而非 rvalue 的版本( &&,也就是 move semantics 的版本):

#include <iostream>

typedef int& INTR;

template<class T>
void func(T& i) {
       std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void func(T&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i   = 123;
        INTR j  = i;    // int&  j = i
        INTR& k = j;    // int&& k = j

        func(k);        // k is lvalue (should run T(&i))
        func(123);      // 123 is rvalue (should run T(&&i))
        return 0;
}

它的輸出應為:

void func(T&) [with T = int]
void func(T&&) [with T = int]

這裏看起來,明明 k就應該要是 int &&,但編譯器居然知道它不是指 rvalue,而是指 reference of reference。當中的原因是, C++ 11 新增了 Reference Collapsing (引用折叠) 的規則,確保編譯器在在大部分情況下(也就是使用了 typedef / decltype / template 的情況下), 都能辨認出 Reference of Reference 仍然是 "reference",而非 "rvalue" 。 

---

2. Reference Collapsing 四個規則
引用折叠有四個主要的編譯規則(其實有更多,不過不太重要,在此不述):

  • A& & becomes A&
  • A& && becomes A&
  • A&& & becomes A&
  • A&& && becomes A&&

反正重點是,編譯器會在碰到兩次 && 的時候,它才會當它是指 rvalue。其他情況的話,它會把 Reference of Reference (&&) 都降級成 Reference (&)。

---

3. Universal Reference / Forwarding Reference (轉發引用)
既然 && 會在編譯的時候,自動按情況降級成 &,那麼我們在寫 function template 的時候,是否可以寫少一個,然後讓編譯器自行將 function 變成兩個 (&& 和 &) 的版本呢?答案是可以的。

先看以下例子:

#include <iostream>

template<class T>
void function(T&& t) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的執行結果為:

void function(T&&) [with T = int&]
void function(T&&) [with T = int]
void function(T&&) [with T = int]

首先,i 是 lvalue (int &),所以 T = int& = T&&& = T & ,
其餘的 996 和 std::move(i) 則因為本身是 rvalue(int &&),所以直接 T = int。

---

4. Perfect Forwarding 完美轉發
有一個情況要注意:如果在 template function 中,我們想要把變數傳到下一個 function 的話, 正常來講,我們可能會這樣做:

template<class T>
void function(T&& t) {
        subFunction(t);
}

然後,我們會期待傳入 subFunction(t) 的 t 應該是 T&& / rvalue,但其實並不一定 ...

以下有一個例子:

#include <iostream>

void subFunction(int&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

void subFunction(int& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void function(T&& t) {
        std::cout << "Value: " << t << std::endl;
        std::cout << __PRETTY_FUNCTION__ << std::endl;
        subFunction(t);
        subFunction(std::forward<T>(t));
        subFunction(static_cast<T&&>(t));
        subFunction((T&&)(t));
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的輸出為:

Value: 123
void function(T&&) [with T = int&]
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)

Value: 996
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

Value: 123
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

由結果可見,如果只用  subFunction(t),編譯器會認為 t 是一個 lvalue。
在 function 的 scope 之中,t 的確是一個變數,是 T&& 的 reference (T&& & = T&)。

如果要解決這個問題,把 && 正確地傳到 subFunction,最簡單的方法,就是把 t 的型態轉強制換成 T&&。如此,傳入來的 t 就能完全不經處理地傳到 subFunction。在解 subFunction 的時候,編譯器會找回最初的 i / 996 / std::move(i),再決定執行那一個版本的 subFunction。

而 std::forward<T>,其實就是 static_cast<T&&> 的語法糖而已。

---

小結
C++11 為了讓 Reference of Reference 變得簡單,並加上對 Move Semantics 的支援,同時令編譯器做到最大程度的向後兼容(不加新 keyword + 舊程式碼可跑),似乎把事情變得更複雜了:它使用了 Reference Collapsing 將不同情況的 && 分開。但同時,這種做法也導致 template function 如果用了 &&,很大機會要在 function call 時,對 variables 加上 static_cast,才能正常地運作 。

但是,我們只要無腦採用 universal reference (兩個 &&)加上 std::forward<T>,基本上就能解決 99% 的問題。向好的方向去想:使用 move semantics 加上 universal reference,不但能加快執行時間,或許更能節省程式碼行數,加快 debug 效率。