理解移動語義、引用折疊及完美轉發
右值引用
在C++11之前,所有引用都是左值引用,也就是對左值的引用。左值一般放在賦值表達式左邊(當然這樣說并不嚴格,"hello world"這樣的字面值存放在.rodata段,也是左值,具體可參考[??談談C++的左值右值,左右引用,移動語意及完美轉發]這篇文章),是在堆或棧上分配的命名對象,它們有明確的內存地址。而左值的另一位朋友右值,在賦值表達式右邊,沒有可識別的內存地址。如果從硬件層面理解,右值只存在于臨時寄存器中。比如下面這段代碼:
int a = 1;
int &b = a;
很明顯,這里a是左值,1是右值,b是一個左值引用,也就是a的別名。再比如這段:
int &a = 1;
g++編譯,會顯示錯誤如下:
non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
意思是非常量左值引用不能指向右值。大家都不會犯這樣的錯,這里想說的是,我們還可以使用常量左值引用來指向右值,像這樣:
const int &a = 1; //常量左值引用
問題來了,常量左值引用為什么可以指向右值?因為const常量值不可修改,可以理解為內部產生了一個臨時量,可以取到地址。類似于以下:
const int tmp = 1;
const int &a = tmp;
可以看到,const Type &是C++ 中一個常見的習慣,函數的參數使用常量引用const Type &接收,以避免創建不必要的臨時對象:
void func(const std::string& a);
func("hello");
但是這種方式有個缺點,就是沒法修改這個const常量,有一定局限性。C++11引入的這位新朋友,右值引用,一定程度上解決了其中的這個問題。右值引用,Type&&,用來指向右值,并且可以修改右值。
void func(const std::string&& a){
a = "world"; //修改右值
}
func("hello");
OK,到這里簡單總結下:
- 左值可以尋址,右值不可以尋址,這是它們的關鍵區別;
- 函數傳參使用左右值引用可以避免拷貝,但右值引用更為靈活。
那么,右值引用的具體應用場景是什么?
移動語義提升性能
右值引用有一個非常重要的作用是支持移動語義。而相對于移動語義,拷貝語義可能比較好理解。比如下面代碼,我們可以定義拷貝構造函數來實現對象的深拷貝,如果沒有定義,編譯器會有默認實現,是淺拷貝。
class Stack {
public:
Stack(int size = 100) : size_(size) {
cout << "構造函數" << endl;
stack_ = new int[size];
}
Stack(const Stack &src) : size_(src.size_) {
cout << "拷貝構造函數" << endl;
stack_ = new int[src.size_];
//深拷貝
for (int i = 0; i < size_; ++i)
stack_[i] = src.stack_[i];
}
~Stack() {
cout << "析構函數" << endl;
delete[] stack_;
stack_ = nullptr;
}
private:
int size_;
int *stack_;
};
int main() {
Stack stack(10);
Stack stack2 = stack;
}
運行結果為:
構造函數
拷貝構造函數
析構函數
析構函數
除此之外,在某些場景,比如被拷貝者之后不再需要,我們其實可以使用std::move觸發移動語義,避免深拷貝,提升性能。所以在上面代碼中,我們可以加一個移動構造函數,這種方式在STL和自定義類廣泛應用。
Stack(Stack &&src) : size_(src.size_) {
cout << "移動構造函數" << endl;
stack_ = src.stack_;
src.stack_ = nullptr;
}
int main(){
Stack stack(10);
//Stack stack2 = stack; //走拷貝構造
Stack stack2 = std::move(stack); //走移動構造
}
運行的輸出是:
構造函數
移動構造函數
析構函數
析構函數
這里,std::move的作用是把左值轉換為右值引用,而移動構造函數的作用是傳入對象的所有權轉讓給當前對象,然后掏空了傳入對象。
std::move的具體實現
大家可能以為std::move施展了什么神奇的魔法,其實并沒有,僅僅做了static_cast類型轉換而已,真正的移動操作是在移動構造函數或者移動賦值操作符中發生的。可以瞧一瞧代碼,來更具體的看下std::move的實現,在我的GCC 8.5下的std::move源碼如下:
/**
* @brief Convert a value to an rvalue.
* @param __t A thing of arbitrary type.
* @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
上述代碼涉及萬能引用(或叫通用引用),什么是萬能引用?(下文會著重說明,這里大概說一下)
萬能引用可引用任意類型并保留其左右值屬性,一個萬能引用形參的初始值決定了它是代表了右值引用還是左值引用。如果初始被賦予的是一個右值,那么萬能引用就會是對應的右值引用,如果初始被賦予的是一個左值,那么萬能引用就會是一個左值引用。
上述std::move函數的形參為接收一個對象的萬能引用,返回一個指向同對象的引用。可以看到返回值為:
constexpr typename std::remove_reference<_Tp>::type&&
&&表明std::move函數返回的是一個右值引用。但是模板可接收任意類型參數,若_Tp碰巧為一個左值引用,那么根據引用折疊原理,_Tp&&就變成了左值引用。為了避免這種情況的發生,調用std::remove_reference模板方法至_Tp類型,可去除原類型的引用屬性,然后再賦予::type &&于非引用類型之上。這么一來可確保std::move返回的一定是右值引用所引用的右值,這一點十分重要。因此std::move函數最終將實參轉換成了右值,這就是該函數所做的事情。
補充一點,一開始我理解為返回右值引用,可我同時也困惑于此,如果強制轉為
&&這樣的右值引用后并返回,右值引用它本身是左值,返回右值引用相當于返回左值,那為啥說std::move返回的一定是右值呢?帶著疑問思考以后,我突然大悟!其實是我對
static_cast函數本身不夠了解,上述代碼返回的并非是右值引用&&,而是右值引用所引用的那個右值,就像你實現了一段這樣的代碼:int *p1 = static_cast<int *>(&a);這段代碼返回的是int *的指針嗎?其實返回的是a的地址,也就是指針所對應的區域,那同樣的道理,返回右值引用也不是單純的返回右值引用本身,而是右值引用所引用的右值。所以std::move返回的是形參__t,只不過這個形參__t在去除了引用屬性的前提下被一個右值引用所引用,因此這個__t一定是一個右值,所以std::move返回的值就一定是一個右值,而不是右值引用本身,注意區分。關于上面說的,再補充說一下,其實返回的就是右值引用,但是這里返回的右值引用并非具名右值引用,具名右值引用才是左值,就像你的函數返回int類型的a變量的時候,變量本身是左值,但是返回的時候是以右值的形式被接收的,即int b = func();,此時func()返回的是a變量。
我起初在知乎上看到有人說“返回右值引用”,也對此有疑惑,聽聽別人是怎么說的吧。“具名右值引用變量才是左值。函數類型如果不是左值引用那么都應該是右值類型(包括右值引用)。這兒返回類型使用右值引用是對需要移動對象的引用,因為移動函數中需要對移動對象進行修改從而達到資源移動的目的。”
其中,std::remove_reference的作用去除_Tp中的引用部分,無論_Tp是左值還是右值,只獲取其中的類型。我們來簡化一下,當_Tp是string時,這個函數其實就是:
string&& move(string&& __t) {
return static_cast<string&&>(__t);
}
所以,不管傳參是左值右值,最后返回的一定是個右值引用。實際上,std::move運行期不做任何事情,因為編譯后不會生成可執行代碼,內部只是變量地址的透傳,完全可以被優化掉。
當然如果采用C++14標準,std::move就可以用更簡明扼要的方式實現。有了函數返回值類型推導和標準庫中的模板std::remove_reference_t,std::move可以這樣寫:
#if __cplusplus > 201103L
/// Alias template for remove_reference
template<typename _Tp>
using remove_reference_t = typename remove_reference<_Tp>::type;
//-----------------------------------------------------------------//
//move C++14寫法
template<typename _Tp>
decltype(auto) //類型自動推導
move(_Tp&& __t) noexcept
{ return static_cast<std::remove_reference_t<_Tp>&&>(__t); } //省略typename和::type 更為簡潔
右值是可以被移動的,所以在一個對象上實施了std::move,就是告訴編譯器該對象具備可移動的條件。
std::move源碼中參數_Tp &&看起來像是個右值引用,但是在使用時卻可以接收左值。
/**
* @brief Convert a value to an rvalue.
* @param __t A thing of arbitrary type.
* @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
int main(){
std::string a = "hello";
std::string&& b = std::move(a); //參數a是左值
}
實際上,這種情況下參數T &&是一個萬能引用,或稱做通用引用(Universal References)。這塊內容基本上只涉及到模板編程,但是為了能更好的啃下各種C++開源庫,還是得了解下萬能引用以及完美轉發的概念。
萬能引用與引用折疊
如果一個函數模板參數類型為T &&,其中T需要推導,那么T &&就是一個未定義的引用類型,稱為萬能引用,它既能綁定右值,又能綁定左值。 注意,只有當發生自動類型推斷時(比如函數模板的類型自動推導,或者auto關鍵字),&&才是一個萬能引用。
萬能引用說完了,接著來聊引用折疊(Reference collapsing),因為完美轉發(Perfect Forwarding)的概念涉及引用折疊。一個模板函數,根據定義的形參和傳入的實參的類型,我們可以有下面四中組合:
左值-左值 T& & # 函數定義的形參類型是左值引用,傳入的實參是左值引用
左值-右值 T& && # 函數定義的形參類型是左值引用,傳入的實參是右值引用
右值-左值 T&& & # 函數定義的形參類型是右值引用,傳入的實參是左值引用
右值-右值 T&& && # 函數定義的形參類型是右值引用,傳入的實參是右值引用
但是C++中不允許對引用再進行引用,對于上述情況的處理有如下的規則:所有的折疊引用最終都代表一個引用,要么是左值引用,要么是右值引用。規則是:如果任一引用為左值引用,則結果為左值引用。否則(即兩個都是右值引用),結果為右值引用。
即就是前面三種情況代表的都是左值引用,而第四種代表的右值引用。
舉個簡單的例子:
template<typename T>
void func(T &¶m) {
}
int main() {
//例子1
func(1); //1是右值, param是右值引用
int a = 2;
func(a); //a是左值, param是左值引用
//例子2
std::string b = "hello";
auto &&c = b; //auto&&綁定左值
auto &&d = "world"; //auto&&綁定右值
}
例子中,T是一個模板,那么T就可能是int或int &或int &&,最后參數就可能變成(int && &¶m)。由于C++禁止reference to reference的情況,所以編譯器會對L2L、L2R、R2L、R2R這四種引用做處理,折疊為單一引用,也就是引用折疊,具體就是:
T& &、T&& &、T& &&都折疊成T&T&& &&折疊成T &&
這個比較好記,只要出現左值引用,都會最終折疊為左值引用。
完美轉發
有了上面的概念之后,完美轉發(Perfect Forwarding)這一塊就很好理解了。萬能引用 + 引用折疊 + std::forward一起構成了完美轉發的機制。簡單一點講就是,std::forward會將輸入的參數原封不動地傳遞到下一個函數中,如果是左值,傳遞到下一個函數還是左值,如果是右值,傳遞到下一個函數還是右值。所謂perfect,指的就是不僅能準確地轉發參數的值,還能保證其左右值屬性不變。為什么需要這個機制?
先看下面這段代碼會輸出什么:
template<typename T>
void func(T ¶m) {
cout << "傳入左值" << endl;
}
template<typename T>
void func(T &¶m) {
cout << "傳入右值" << endl;
}
template<typename T>
void test(T &&t) { //參數t,萬能引用
func(t);
}
int main() {
int a = 1;
test(a);
test(1);
}
輸出是:
傳入左值
傳入左值
可以發現,無論傳入左值右值,最終都調用了左值那個函數,和預期并不一致。這是因為,無論調用test函數模板傳遞的是左值還是右值,對于函數內部的參數t來說,它有自己的名稱,也可以獲取地址,因此它永遠都是左值。也就是說,傳遞給func函數的參數t一直是左值。(被聲明的左值引用和右值引用本身就是一個左值,可以尋址)
上面這段話理解了之后,我們可以使用std::forward來改造下test函數,讓它足夠perfect。
template<typename T>
void test(T &¶m) {
func(std::forward<T>(param));
}
到這里,應該理解C++出現完美轉發的動機了。在C++很多場景中,是否實現參數的完美轉發,直接決定了這個參數的傳遞過程使用的是移動語義還是拷貝語義。最后,我們再瞄一眼std::forward的函數定義:
/**
* @brief Forward an lvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference_v<T>,
"Cannot forward rvalue as lvalue.");
return static_cast<_Tp&&>(__t);
}
前文已經說過,typename std::remove_reference<_Tp>::type的作用是去掉參數_Tp的引用,只保留類型。 我們根據_Tp的引用類型分別簡化下模板代碼:
//情況1:接收左值,_Tp被推導為string&,那么_Tp&&就是string& &&,折疊為string&
string& forward(string& __t) {
return static_cast<string&>(__t);
}
//情況2:接收右值,_Tp被推導為string&&,那么_Tp&&就是string&& &&,折疊為string&&
string&& forward(string&& __t) {
return static_cast<string&&>(__t);
}
到這里,我們已經講清楚了移動語義和完美轉發,右值引用的作用就是支持這些機制。

浙公網安備 33010602011771號