閱讀本文需要具有的預備知識:
- 左值和右值的基本概念
- 模板推導的基本規則
- 若無特殊說明,本文中的大寫字母
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()
的各個呼叫結果。
-
傳入左值:
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 & &&
程式碼出來。它的理由就是:我(編譯器)雖然推匯出T
為int&
,但是我在最終生成的程式碼中,利用引用摺疊規則,將int & &&
等價生成了int &
。推匯出來的int & &&
只是過渡階段,最終版本並不存在。所以也不算破壞規定咯。關於有的人會問,我傳入的是一個左值a,並不是一個左值引用,為什麼編譯器會推匯出T 為int &呢。
首先,模板函式引數為 T&& param,也就是說,不管T是什麼型別,T&&的最終結果必然是一個引用型別。如果T是int, 那麼T&& 就是 int &&;如果T為 int &,那麼 T &&(int& &&) 就是&,如果T為&&,那麼T &&(&& &&) 就是&&。很明顯,接受左值的話,T只能推導為int &。
- 明白傳入左值的推導結果,剩下的幾個呼叫結果就很明顯了:
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 ¶m)
{
return static_cast<T&&>(param);
}
我們來仔細分析一下這段程式碼:
我們可以看到,不管T是值型別,還是左值引用,還是右值引用,T&經過引用摺疊,都將是左值引用型別。也就是forward 以左值引用的形式接收引數 param, 然後 通過將param進行強制型別轉換 static_cast<T&&> (),最終再以一個 T&&返回
所以,我們分析一下傳遞給 PrintType 的實參型別,並將推導的 T 型別代入 forward 就可以知道轉發的結果了。
-
傳入 PrintType 實參是右值型別:
根據以上的分析,可以知道T將被推導為值型別,也就是不帶有引用屬性,假設為 int 。那麼,將T = int 帶入forward。
int&& forward(int ¶m) { return static_cast<int&&>(param); }
param
在forward內被強制型別轉換為 int &&(static_cast<int&&>(param)), 然後按照int && 返回,兩個右值引用最終還是右值引用。最終保持了實參的右值屬性,轉發正確。 -
傳入 PrintType 實參是左值型別:
根據以上的分析,可以知道T將被推導為左值引用型別,假設為int&。那麼,將T = int& 帶入forward。
int& && forward(int& ¶m) { return static_cast<int& &&>(param); }
引用摺疊一下就是:
int& forward(int& param) { return static_cast<int&>(param); }
看到這裡,我想就不用再多說什麼了。傳遞給 PrintType 左值,forward返回一個左值引用,保留了實參的左值屬性,轉發正確。
到這裡,完美轉發也就介紹完畢了。
總結一下就是,通過引用摺疊,我們實現了萬能模板。在萬能模板內部,利用forward函式,本質上是又利用了一遍引用摺疊,實現了完美轉發。其中,模板推導扮演了至關重要的角色。