左值與右值 C++中左值與右值的概念是從C中繼承而來,一種簡單的定義是左值能夠出現再表達式的左邊或者右邊,而右值只能出現在表達式的右邊。 int a = 5; // a是左值,5是右值 int b = a; // b是左值,a也是左值 int c = a + b; // c是左值,a + b是右值 ...
左值與右值
C++中左值與右值的概念是從C中繼承而來,一種簡單的定義是左值能夠出現再表達式的左邊或者右邊,而右值只能出現在表達式的右邊。
int a = 5; // a是左值,5是右值
int b = a; // b是左值,a也是左值
int c = a + b; // c是左值,a + b是右值
另一種區分左值和右值的方法是:有名字、能取地址的值是左值,沒有名字、不能取地址的值是右值。比如上述語句中a,b, c是變數可以取地址,所以是左值,而5和a + b無法進行取地址操作,因此是右值。C++中左值與右值的一個主要的區別是:左值可以被修改,而右值不可修改。
左值引用與右值引用
瞭解了左值與右值的概念後,接下來介紹下C++中的左值引用與右值引用。左值引用很簡單,就是一個變數的別名,綁定到一個左值上:
int a = 1;
int& b = a; //a = 1,b = 1
b = 2; // a = 2,b = 2
這裡b就等於a,在彙編層面其實和普通的指針一樣,對引用的修改(b)也會修改到被引用的對象(a),需要註意的是,因為引用實際是一個別名,因此必須初始化,即告訴編譯器是那個具體對象的別名。因此下列左值引用都是錯誤的:
int& a; // 錯誤!左值引用必須初始化
int& b = 10; // 錯誤!左值引用不能以臨時變數初始化(臨時變數沒有地址)
右值引用是C++11中新增的特性,顧名思義,右值引用就是用來綁定到右值的引用,一個右值被綁定到右值引用之後,原本需要被銷毀的此右值生命周期會延長至綁定它的右值引用的生命周期。在彙編層面,右值引用和const引用所做的事情是一樣的,即產生臨時量來存儲常量。但是右值引用可以進行讀寫操作,而const引用只能進行讀操作。綁定右值引用使用&&,具體使用如下:
int a = 5;
int& b = a; // 正確!b是一個左值引用
int&& c = 6; // 正確!c是一個右值引用,綁定到右值6
int&& d = a * 2; // 正確!d是一個右值引用,綁定到右值a * 2
int&& e = i; // 錯誤!不能將左值綁定到右值引用
int& f = 7; // 錯誤!不能將右值綁定到左值引用
const int& g = a * 3; // 正確!可以將右值綁定到const 左值引用
可以看到我們雖然不能將右值綁定到左值引用,但是可以將右值綁定到const左值引用。
註意: 變數表達式都是左值!。變數可以看作是只有一個運算對象而沒有運算符的表達式,跟其他表達式一樣,變數表達式也有左值/右值屬性。變數表達式都是左值,因此我們不能將一個右值引用綁定到一個右值引用類型的變數上。
int&& a = 5; // 正確!a是一個右值引用
int&& b = a; // 錯誤!a是一個左值,不能綁定到右值引用
這裡雖然a是右值引用類型,但是確實一個左值,因此無法綁定到右值引用b上。因為在C++中,右值一般是臨時對象,但是綁定到右值引用之後,其生命周期變長了,因此a是一個左值。我們不能將一個右值引用直接綁定到一個變數上,即使是這個變數是右值引用類型也不行。具體的這個問題在後續的介紹forward的時候會詳細說明。
左值/右值引用的模板實參推斷
在另一篇文章中介紹了C++的模板類型推斷的幾種類型,可以總結為以下三種:
- ParamType 是一個指針或者引用(&),但是不是通用引用(&&)
- ParamType是一個通用引用(&&)
- ParamType既不是指針,也不是引用(&)或者通用引用(&&)
從左值引用函數參數推斷類型
當一個函數參數是模板的左值引用(T&)時,根據綁定規則,只能傳遞一個左值實參,這個左值實參可以時const類型,也可以不是。如果實參時const的,那麼T就會被推導為const類型
template<typename T>
void func(T& param);
int a = 0;
const int b = a;
func(a); // T被推導為int,param類型為int&
func(b); // T被推導為const int,param類型為const int&
func(5); // 錯誤!實參必須是一個左值!
如果一個函數的類型時const T&,那麼根據綁定規則,可以傳遞任何類型的實參:const或者非const,左值或者右值,由於函數類型本身已經是const,因此T的推導結果不會是一個const,因為const已經是函數參數類型的一部分了。
template<typename T>
void func(const T& param);
int a = 0;
const int b = a;
func(a); // T被推導為int,param類型為const int&
func(b); // T被推導為int,param類型為const int&
func(5); // 正確!const T&可以綁定一個右值,T為int
可以看到,當函數參數類型為const T&時,可以接受一個右值實參,而函數參數類型為 T& 時是不可以的。
從右值引用函數參數推斷類型
當一個函數的參數是一個右值引用(T&&)時,根據綁定規則可以傳遞一個右值實參。類似左值引用推導,右值引用推導得到的T的類型為右值的類型:
template<typename T>
void func(T&& param);
func(5); // 實參5為右值,T被推導為int類型
與不能給右值引用賦值左值不同,右值引用函數的模板實參卻可以接受一個左值的輸入。當我們將一個左值傳遞給函數的右值引用參數時,且此右值引用指向模板參數類型(T&&)時,編譯器推導模板類型參數為實參的左值引用類型:
template<typename T>
void func(T&& param);
int a = 1;
func(a); // T被推導為int&,而不是int
如上述推導所示,當傳入一個左值a時,T被推導為int&,而不是int,對應的param的類型為int& &&,根據引用摺疊的規則,int& &&被摺疊為int&。
引用摺疊規則
T& & ,T& && 和T&& &都會被摺疊為T&
T&& &&被摺疊為T&&
引用摺疊的規則告訴我們:如果一個函數的參數時指向模板參數類型的右值引用(如T&&),則可以傳遞給它任意類型的實參,如果傳遞的左值實參,那麼T將會推導成為一個左值引用,函數參數被實例化為一個普通的左值引用(T&)。這種引用叫做“通用引用”。
右值引用與通用引用
C++中T&&有兩種不同的意思,第一種是右值引用,用於綁定到右值上,它們主要存在的原因是為了聲明某個對象可以被移動。T&&的第二層意思是,它既可以是一個右值引用,也可以是一個左值引用。這種引用在代碼里看起來像是右值引用(T&&),又可以表現的像是左值引用(T&)。它既可以綁定到右值,也可以綁定到左值,還可以綁定到const和no_const對象上,幾乎可以綁定到任何東西,這種引用叫做“通用引用”。在兩種情況下會出現通用引用,最常見的就是函數模板參數:
template<typename T>
void func(T&& param); // param是一個通用引用
第二種情況是auto聲明符:
auto&& a = b; //a是一個通用引用
以上兩種情況的共同之處在於都是類型推導。在func內部,param類型需要被推導,在auto聲明中,a的類型也需要被推導,而如果帶有&&而不需要推導,則就是普通的右值引用:
void func(A&& param); // 沒有類型推導,param是一個右值引用
A&& a = b; // 沒有類型推導,a是一個右值引用
由於引用必須初始化,通用引用也一樣。一個通用引用的初始值決定了其具體代表的是一個左值引用還是右值引用。如果初始值是一個左值,那麼通用引用對應的就是左值引用,如果初始值是一個右值,那麼通用引用對應的就是一個右值引用。
template<typename T>
void func(T&& param); // param是一個通用引用
int a = 1;
func(a); // a是左值,T被推導為int&,參數param的類型是int&,是一個左值引用
func(5); // 5是右值,T被推導為int,參數param的類型是int&&,是一個右值引用
需要註意的是,判斷一個引用是不是通用引用,類型推導是必要的,但是並不是類型推導就是通用引用,還需要看是不是準確的T&&,如:
template<typename T>
void func(std::vector<T>&& param); // param是一個右值引用
template<typename T>
void func(const T&& param); // param是一個右值引用
上述模板函數func被調用的時候,類型T也會被推導,但是參數param的類型並不是T&&,而是一個std::vector
理解std::move()
有了上述的知識基礎之後,C++中的move函數功能就很好理解了,std::move的主要作用是將一個左值/右值無條件的轉換為右值,但是函數本身並不移動任何東西,只是進行類型的轉換,那麼這種轉換是如何做到的呢?我們來看下std::move具體實現的代碼:
template<class T>
typename remove_reference<T>::type&& move(T&& param)
{
using returnType = typename remove_reference<T>::type&&;
return static_cast<returnType>(param);
}
通過源碼可以看到,std::move接受一個通用引用的參數,函數返回一個&&表明std::move函數返回的是一個右值引用,這裡remove_reference
template<typename T>
decltype(auto) move(T&& param)
{
using returnType = remove_reference_t<T>&&;
return static_cast<returnType>(param);
}
讓我們通過以下的代碼示例具體分析下std::move是如何工作的:
string s1("hello"),s2;
s2 = std::move(string("world")); // 從右值移動數據
s2 = std::move(s1); // 將左值轉換為右值
在第一個賦值中,傳遞給move的實參是一個右值,當向一個右值引用傳遞一個右值時,推導的類型即被引用的類型,因此在std::move(string("world"))中:
- T被推導為string
- returnType為string
- move的返回類型為string&&
- move的函數參數param的類型為string&&
則函數std::move被推導為:
string&& move(string&& param)
{
return static_cast<string&&>(param);
}
由於param已經時右值引用類型,因此實際上move函數什麼也沒做。
在第二個賦值中,傳給std::move的參數是一個左值,則在std::move(s1)中:
- T被推導為string&
- returnType為string
- move的返回類型為string&&
- move的函數參數param的類型為string&
則函數std::move被推導為:
string&& move(string& param)
{
return static_cast<string&&>(param);
}
可以看到參數param被static_cast轉換為sting&&,在C++中,從一個左值static_cast到一個右值引用時允許的。
從以上的示例可以看到,不管傳入的是左值還是右值,最終move都會返回一個右值。
理解std::forward()
std::forward與std::move實現的功能是類似的,只不過std::move總是無條件的將它的參數轉換為右值,而std::forward只有在滿足一定的條件下才會執行轉換。std::forward最常見的使用場景是一個模板函數,接受一個通用引用參數,並將其傳遞給另外的函數:
void Process(const A& lvalue); // 處理左值
void Process(A&& rvalue); // 處理右值
template<typename T>
void PrintAndProcess(T&& param)
{
Print("Some Log");
process(std::forward<T>(param))
}
現在考慮兩次對PrintAndProcess的調用,一次參數為左值,一次參數為右值
A a;
PrintAndProcess(a); // 左值參數
PrintAndProcess(std::move(a)); // 右值參數
在PrintAndProcess函數內部,參數param被傳遞給process函數,process函數分別對左值和右值進行了重載,傳入PrintAndProcess左值參數時希望process左值版本被調用,傳入PrintAndProcess右值參數時,process右值版本被調用。但是前面我們提過,一個右值引用的變數,其本身時一個左值,因此無論傳給PrintAndProcess函數的實參時左值還是右值,最終調用process函數都是左值版本。為瞭解決這個問題,我們就需要一種機制:當傳入PrintAndProcess函數的實參是右值時,調用的時process的右值版本。這就是std::forward的使用場景:只把由右值初始化的參數,轉換為右值。
那麼std::forward如何知道param參數是被一個左值還是一個右值給初始化的呢?我們來看下std::forward實現的源碼:
template<class T>
constexpr T&& forward(std::remove_reference_t<T>& arg) noexcept{
// forward an lvalue as either an lvalue or an rvalue
return (static_cast<T&&>(arg));
}
template<class T>
constexpr T&& forward(std::remove_reference_t<T>&& arg) noexcept{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
// forward an rvalue as an rvalue
return (static_cast<T&&>(arg));
}
對於左值的轉發,首先通過獲取類型type,定義args為左值引用的左值變數,然後通過static_cast<T&&>進行強制轉換,這裡T&&會發生引用摺疊,當T被推導為左值引用時,則為T&& &,摺疊為T&,當推導為右值引用時,則本身為T&&,forward返回值與static_cast都為T&&。
對於右值的轉發不同於左值,只有當類型時右值時才進行static_cast轉換,arg為右值引用的左值變數,通過cast轉換為T&&。
對應到上述PrintAndProcess函數中我們進行分析:
- 當PrintAndProcess(a),傳入的為左值A時,T被推導為A&,std::forward返回值和static_cast被推導為A& &&,摺疊為A&,返回一個左值。
- 當PrintAndProcess(std::move(a)),傳入為右值時,T被推導為A,在std::forward返回值和static_cast被推導為T&&,返回一個右值。
std::move 和 std::forward對比
- std::move執行到右值的無條件轉換。就其本身而言,它沒有move任何東西。
- std::forward只有在它的參數綁定到一個右值上的時候,它才轉換它的參數到一個右值。
- std::move和std::forward只不過就是執行類型轉換的兩個函數;std::move沒有move任何東西,std::forward沒有轉發任何東西。在運行期,它們沒有做任何事情。它們沒有產生需要執行的代碼,一個byte都沒有。
- std::forward
()不僅可以保持左值或者右值不變,同時還可以保持const、Lreference、Rreference、validate等屬性不變;