引用摺疊和完美轉發

ReFantasy發表於2018-11-27

閱讀本文需要具有的預備知識:

  • 左值和右值的基本概念
  • 模板推導的基本規則
  • 若無特殊說明,本文中的大寫字母T泛指任意的資料型別

引用摺疊

我們把 引用摺疊 拆解為 引用摺疊 兩個短語來解釋。

首先,引用的意思眾所周知,當我們使用某個物件的別名的時候就好像直接使用了該物件,這也就是引用的含義。在C++11中,新加入了右值的概念。所以引用的型別就有兩種形式:左值引用T&和右值引用T&&

其次,解釋一下摺疊的含義。所謂的摺疊,就是多個的意思。上面介紹引用分為左值引用和右值引用兩種,那麼將這兩種型別進行排列組合,就有四種情況:

  • 左值-左值 T& &
  • 左值-右值 T& &&
  • 右值-左值 T&& &
  • 右值-右值 T&& &&

這就是所謂的引用摺疊!引用摺疊的含義到這裡就結束了。

但是,當我們在IDE中敲下類似這樣的程式碼:

// ...
int a = 0;
int &ra = a;
int & &rra = ra;  // 編譯器報錯:不允許使用引用的引用!
// ...

WTF ! 既然不允許使用,為啥還要有引用摺疊這樣的概念存在 ?!

原因就是:引用摺疊的應用場景不在這裡!!

下面我們介紹引用摺疊在模板中的應用:完美轉發。在介紹完美轉發之前,我們先介紹一下萬能引用

萬能引用

所謂的萬能引用並不是C++的語法特性,而是我們利用現有的C++語法,自己實現的一個功能。因為這個功能既能接受左值型別的引數,也能接受右值型別的引數。所以叫做萬能引用。

萬能引用的形式如下:

template<typename T>
ReturnType Function(T&& parem)
{
    // 函式功能實現
}

接下來,我們看一下為什麼上面這個函式能萬能引用不同型別的引數。

為了更加直觀的看到效果,我們藉助Boost庫的部分功能,重寫我們的萬能引用函式:

如果不瞭解Boost庫也沒關係,Boost庫主要是為了幫助大家看到模板裡引數型別)

#include <iostream>
#include <boost/type_index.hpp>

using namespace std;
using boost::typeindex::type_id_with_cvr;  

template<typename T>
void PrintType(T&& param)
{
    // 利用Boost庫列印模板推匯出來的 T 型別
    cout << "T type:" << type_id_with_cvr<T>().pretty_name() << endl; 
    
    // 利用Boost庫列印形參的型別
    cout << "param type:" << type_id_with_cvr<decltype(param)>().pretty_name() << endl;
}

int main(int argc, char *argv[])
{
    int a = 0;                              // 左值
    PrintType(a);                           // 傳入左值

    int &lvalue_refence_a = a;              // 左值引用
    PrintType(lvalue_refence_a);            // 傳入左值引用

    PrintType(int(2));                      // 傳入右值
}

通過上面的程式碼可以清楚的看到,void PrintType(T&& param)可以接受任何型別的引數。嗯,真的是萬能引用!到這裡的話,萬能引用的介紹也就結束了。但是我們只看到了這個東西可以接受任何的引數,卻不知道為什麼它能這麼做。

下面,我們來仔細觀察並分析一下main函式中對PrintType()的各個呼叫結果。

  1. 傳入左值:

    int a = 0;                              // 左值
    PrintType(a);                           // 傳入左值
    /***************************************************/
    輸出:T type      : int &
          param type  : int &

    我們將T的推導型別int&帶入模板,得到例項化的型別:

    void PrintType(int& && param)
    {
       // ...
    }

    重點來了!編譯器將T推導為 int& 型別。當我們用 int& 替換掉 T 後,得到 int & &&。

    MD,編譯器不允許我們自己把程式碼寫成int& &&,它自己卻這麼幹了 =。=

    那麼 int & &&到底是個什麼東西呢?!(它是引用摺疊,剛開始就說了啊 =。=)

    下面,就是引用摺疊的精髓了

    所有的引用摺疊最終都代表一個引用,要麼是左值引用,要麼是右值引用。

    規則就是:

    如果任一引用為左值引用,則結果為左值引用。否則(即兩個都是右值引用),結果為右值引用。

    ​ 《Effective Modern C++》

    也就是說,int& &&等價於int &void PrintType(int& && param) == void PrintType(int& param)

    所以傳入右值之後,函式模板推導的最終版本就是:

    void PrintType(int& param)
    {
       // ...
    }

    所以,它能接受一個左值a

    現在我們重新整理一下思路:編譯器不允許我們寫下類似int & &&這樣的程式碼,但是它自己卻可以推匯出int & &&程式碼出來。它的理由就是:我(編譯器)雖然推匯出Tint&,但是我在最終生成的程式碼中,利用引用摺疊規則,將int & &&等價生成了int &。推匯出來的int & &&只是過渡階段,最終版本並不存在。所以也不算破壞規定咯。

    關於有的人會問,我傳入的是一個左值a,並不是一個左值引用,為什麼編譯器會推匯出T 為int &呢。

    首先,模板函式引數為 T&& param,也就是說,不管T是什麼型別,T&&的最終結果必然是一個引用型別。如果T是int, 那麼T&& 就是 int &&;如果T為 int &,那麼 T &&(int& &&) 就是&,如果T為&&,那麼T &&(&& &&) 就是&&。很明顯,接受左值的話,T只能推導為int &。

    1. 明白傳入左值的推導結果,剩下的幾個呼叫結果就很明顯了:
    int &lvalue_refence_a = a;              //左值引用
    PrintType(lvalue_refence_a);            // 傳入左值引用
    /*
     * T type      : int &
     * T &&        : int & &&
     * param type  : int &
    */
    
    PrintType(int(2));                      // 傳入右值
    /*
     * T type      : int
     * T &&        : int &&
     * param type  : int &&
    */

    以上就是萬能引用的全部了。總結一下,萬能引用就是利用模板推導和引用摺疊的相關規則,生成不同的例項化模板來接收傳進來的引數。

完美轉發

好了,有了萬能引用。當我們既需要接收左值型別,又需要接收右值型別的時候,再也不用分開寫兩個過載函式了。那麼,什麼情況下,我們需要一個函式,既能接收左值,又能接收右值呢?

答案就是:轉發的時候。

於是,我們馬上想到了萬能引用。又於是興沖沖的改寫了以上的程式碼如下:

/*
 *  Boost庫在這裡已經不需要了,我們將其拿掉,可以更簡潔的看清楚轉發的程式碼實現
 */

#include <iostream>
using namespace std;

// 萬能引用,轉發接收到的引數 param
template<typename T>
void PrintType(T&& param)
{
    f(param);  // 將引數param轉發給函式 void f()
}

// 接收左值的函式 f()
template<typename T>
void f(T &)
{
    cout << "f(T &)" << endl;
}

// 接收右值的函式f()
template<typename T>
void f(T &&)
{
    cout << "f(T &&)" << endl;
}

int main(int argc, char *argv[])
{
    int a = 0;
    PrintType(a);//傳入左值
    PrintType(int(0));//傳入右值
}

我們執行上面的程式碼,按照預想,在main中我們給 PrintType 分別傳入一個左值和一個右值。PrintType將引數轉發給 f() 函式。f()有兩個過載,分別接收左值和右值。

正常的情況下,PrintType(a);應該列印f(T&),PrintType(int());應該列印f(T&&)

但是,真實的輸出結果是

f(T &);
f(T &);

為什麼明明傳入了不同型別的值,但是void f()函式只呼叫了void f(int &)的版本。這說明,不管我們傳入的引數型別是什麼,在void PrintType(T&& param)函式的內部,param都是一個左值引用!

沒錯,事實就是這樣。當外部傳入引數給 PrintType 函式時,param既可以被初始化為左值引用,也可以被初始化為右值引用,取決於我們傳遞給 PrintType 函式的實參型別。但是,當我們在函式 PrintType 內部,將param傳遞給另一個函式的時候,此時,param是被當作左值進行傳遞的。 應為這裡的 param 是個具名的物件。我們不進行詳細的探討了。大家只需要己住,任何的函式內部,對形參的直接使用,都是按照左值進行的。

WTF,萬能引用內部形參都變成了左值!那我還要什麼萬能引用啊!直接改為左值引用不就好了!!

別急,我們可以通過一些其它的手段改變這個情況,比如使用 std::forward 。

在萬能引用的一節,我們應該有所感覺了。使用萬能引用的時候,如果傳入的實參是個右值(包括右值引用),那麼,模板型別 T 被推導為 實參的型別(沒有引用屬性),如果傳入實參是個左值,T被推導為左值引用。也就是說,模板中的 T 儲存著傳遞進來的實參的資訊,我們可以利用 T 的資訊來強制型別轉換我們的 param 使它和實參的型別一致。

具體的做法就是,將模板函式void PrintType(T&& param)中對f(param)的呼叫,改為f(std::forward<T>(param));然後重新執行一下程式。輸出如下:

f(T &);
f(T &&);

嗯,完美的轉發!

那麼,std::forward是怎麼利用到 T 的資訊的呢。

std::forward的原始碼形式大致是這樣:

/*
 *  精簡了標準庫的程式碼,在細節上可能不完全正確,但是足以讓我們瞭解轉發函式 forward 的了
 */ 

template<typename T>
T&& forward(T &param)
{
    return static_cast<T&&>(param);
}

我們來仔細分析一下這段程式碼:

我們可以看到,不管T是值型別,還是左值引用,還是右值引用,T&經過引用摺疊,都將是左值引用型別。也就是forward 以左值引用的形式接收引數 param, 然後 通過將param進行強制型別轉換 static_cast<T&&> (),最終再以一個 T&&返回

所以,我們分析一下傳遞給 PrintType 的實參型別,並將推導的 T 型別代入 forward 就可以知道轉發的結果了。

  1. 傳入 PrintType 實參是右值型別:

    根據以上的分析,可以知道T將被推導為值型別,也就是不帶有引用屬性,假設為 int 。那麼,將T = int 帶入forward。

    int&& forward(int &param)
    {
     return static_cast<int&&>(param);
    }

    param在forward內被強制型別轉換為 int &&(static_cast<int&&>(param)), 然後按照int && 返回,兩個右值引用最終還是右值引用。最終保持了實參的右值屬性,轉發正確。

  2. 傳入 PrintType 實參是左值型別:

    根據以上的分析,可以知道T將被推導為左值引用型別,假設為int&。那麼,將T = int& 帶入forward。

    int& && forward(int& &param)
    {
     return static_cast<int& &&>(param);
    }

    引用摺疊一下就是:

    int& forward(int& param)
    {
     return static_cast<int&>(param);
    }

    看到這裡,我想就不用再多說什麼了。傳遞給 PrintType 左值,forward返回一個左值引用,保留了實參的左值屬性,轉發正確。

到這裡,完美轉發也就介紹完畢了。

總結一下就是,通過引用摺疊,我們實現了萬能模板。在萬能模板內部,利用forward函式,本質上是又利用了一遍引用摺疊,實現了完美轉發。其中,模板推導扮演了至關重要的角色。

相關文章