表達式是C++語言的基石。每個表達式都有兩個屬性:類型(type)和值類别(value category)。前者是大家都熟悉的,但是後者卻可能是我們不太在意的。本文的目的是介紹與值類别相關的一些知識。
1. 前言
本文是C++基礎系統文章中的一篇,将介紹C++中的值類别,以及與之相關的一些概念。
1.1. 表達式與值類别
C++的程式由一系列的表達式(expressions)構成。表達式是運算符和操作數的序列,表達式指定一項計算。
例如:
2 + 2
或者
std::cout << "Hello World" << std::endl
都是表達式。
每個表達式有兩個互相獨立但是非常重要的屬性:
- 類型(type)。類型是我們很熟悉的概念,
,int
和double
這些都是類型。類型确定了表達式可以進行哪些操作。std::string
- 除了類型之外,還有一個稱之為值類别(value category)的屬性,卻可能是我們平時不太注意的。
type和category在中文中似乎都可以翻譯成“類型”。但在本文中,為了區分它們,統一将type翻譯成“類型”,category翻譯成“類别”。
1.2. 為什麼要懂這些東西?
不管你在不在意,每個表達式都屬于三種值類别(prvalue,xvalue,lvalue)中的一種。值類别可以影響表達式的含義,例如:你應該知道這個表達式是沒有意義的:
3 = 4
,它甚至編譯不過。但你可能說不出來為什麼編譯器會認為它編譯不過。
如果你使用gcc編譯器,它的報錯如下:
error: lvalue required as left operand of assignment
這個報錯中的
lvalue
就是數字表達式
3
的值類别。
再者,值類别還會影響函數的重載:當某個函數有兩種重載可用,其中之一接受右值引用的形參而另一個接受 const 的左值引用的形參時,右值将被綁定到右值引用的重載之上。如果你不明白這裡提到的“左值引用”和“右值”是指什麼的話請不要擔心,這就是本文所要說明的。
2. 從左值和右值說起
最初的時候,隻有左值(lvalue)和右值(rvalue)這兩個術語。它們源于C++的祖先語言:[CPL](https://en.wikipedia.org/wiki/CPL_(programming_language "CPL"))。
lvalue之是以叫lvalue,是因為它常常出現在等号的左邊(left-hand side of an assignment)。同樣,rvalue是因為它常常出現在等号的右邊(right-hand side of an assignment)。
回顧一下上面的
3 = 4
編譯報錯,就是因為編譯器要求等号的左邊得是一個lvalue,而數字
3
其實是一個rvalue,是以這個是無法通過編譯的。
C語言遵循了相似的分類法,但是否需要等号指派已經不再重要。在C語言中,辨別一個對象的表達式稱之為左值,不過lvalue已經是“locator value”的簡寫,因為lvalue對應了一塊記憶體位址。
你可以簡單的了解為:左值對應了具有記憶體位址的對象,而右值僅僅是臨時使用的值。例如
int a = 1
中,
a
是左值,
1
是右值。
3. C++11中的值類别
C++中對于值類别的定義也經曆一些變化。從C++11标準開始,值類别早以不止是lvalue和rvalue兩種這麼簡單。
但情況也不算太壞,因為主要的值類别有:lvalue,prvalue 和 xvalue三種。加上兩種混合類别:glvalue和rvalue,一共有五種。
我們來看一下它們的定義:
- A glvalue(generalized lvalue) is an expression whose evaluation determines the identity of an object, bit-field, or function.
- A prvalue(pure rvalue) is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.
- An xvalue(eXpiring value) is a glvalue that denotes an object or bit-field whose resources can be reused (usually because it is near the end of its lifetime).
- An lvalue is a glvalue that is not an xvalue.
- An rvalue is a prvalue or an xvalue.
這個定義很難了解,就算翻譯成中文,也一樣不好了解。是以下文會通過一些示例來對它們進行說明。
這五種類别的分類基于表達式的兩個特征:
- 是否擁有身份(identity):可以确定表達式是否與另一表達式指代同一實體,例如比較它們所辨別的對象或函數的(直接或間接獲得的)位址;
- 是否可被移動(具體見下文):移動構造函數、移動指派運算符或實作了移動語義的其他函數重載能夠綁定到這個表達式。
由此,C++11中對于這五種類别定義如下:
- lvalue是指:擁有身份且不可被移動的表達式。
- xvalue是指:擁有身份且可被移動的表達式。
- prvalue是指:不擁有身份且可被移動的表達式。
- glvalue是指:擁有身份的表達式,lvalue和xvalue都是glvalue。
- rvalue是指:可被移動的表達式。prvalue和xvalue都是rvalue。
這麼說起來還是有些拗口,不過其實颠來倒去就是兩個特征的“是”與“否”,是以通過一個2x2的表格就很容易描述清楚了:
擁有身份(glvalue) | 不擁有身份 | |
---|---|---|
可移動(rvalue) | xvalue | prvalue |
不可移動 | lvalue | 不存在 |
注:不存在不擁有身份也不可移動的表達式。
我們可以通過下面這個圖來記憶五種類别的關系:

每種值類别都有其關聯的性質,這些性質決定了表達式可以如何使用。
3.1. glvalue
glvalue是擁有身份的表達式,它對應了一塊記憶體位址。glvalue有lvalue和xvalue兩種形式,具體的示例見下文。
glvalue具有以下一些特性:
- glvalue可以自動轉換成prvalue。例如:
,等号右邊的lvalue會自動轉換成rvalue。int a = b
- glvalue可以是多态的(polymorphic),它所對應了動态類型和靜态類型可以不一樣,例如:一個指向子類的父類指針。
- glvalue可以是不完整類型,隻要表達式允許。例如:由前置聲明但未定義的類類型。
3.2. rvalue
rvalue是指可以移動的表達式。prvalue和xvalue都是rvalue,具體的示例見下文。
rvalue具有以下特征:
- 無法對rvalue進行取位址操作。例如:
,&42
,這些表達式沒有意義,也編譯不過。&i++
- rvalue不能放在指派或者組合指派符号的左邊,例如:
,3 = 5
,這些表達式沒有意義,也編譯不過。3 += 5
- rvalue可以用來初始化const左值引用(見下文)。例如:
。const int& a = 1
- rvalue可以用來初始化右值引用(見下文)。
- rvalue可以影響函數重載:當被用作函數實參且該函數有兩種重載可用,其中之一接受右值引用的形參而另一個接受 const 的左值引用的形參時,右值将被綁定到右值引用的重載之上。
下面是三種具體的值類别:
3.3. lvalue
左值是指擁有身份但不可移動的表達式。
變量,函數或者資料成員的名稱都是左值表達式。下面是一些左值的例子:
"hello world" // lvalue
int a{}; // lvalue
++a; // lvalue
int& get() {return a;}
get(); // lvalue
int b[4]{}; // lvalue
b[2]; // lvalue
int foo();
int &&a { foo() }; // lvalue
struct foo {int a;};
foo f; // lvalue
f.a; // lvalue
int &&c{ 55 }; // lvalue
int &d{ a }; // lvalue
lvalue具有以下特征:
- 所有glvalue具有的特征
- 可以通過取址運算符擷取其位址
- 可修改的左值可用作内建指派和内建符合指派運算符的左操作數
- 可以用來初始化左值引用(見下文)
3.4. prvalue
prvalue是純右值,數字字面值或者函數傳回的是非引用的值都是prvalue。
下面一些prvalue的例子:
42 // prvalue
true // prvalue
int foo();
foo();// prvalue
int a{}, b{}; // both lvalues
a + b; // prvalue
&a; // prvalue
a++ // prvalue
b-- // prvalue
a && b // prvalue
a < b // prvalue
double {}; // prvalue
std::vector<X> {}; // prvalue
prvalue具有以下特征:
- 所有rvalue具有的特征
- prvalue不會是多态的
- prvalue不會是不完全類型
- prvalue不會是抽象類型或數組
3.5. xvalue
xvalue也指向了一個對象,不過這個對象已經接近了生命周期的末尾。這通常和移動語義(見下文)有關。
下面是一些示例:
xvalue與右值引用有很強的關聯性,是以看了下文對于右值引用的說明再回過頭來看xvalue會更好了解。
bool b {true}; // lvalue
std::move(b); // xvalue
static_cast<bool&&>(b); // xvalue
int&& foo();
foo(); // xvalue
struct foo {int a;};
std::move(f).a; // xvalue
foo{}.a; // xvalue
int a[4]{};
std::move(a); // xvalue
std::move(a)[2]; // xvalue
using arr = int[2];
arr{}[0]; // xvalue
xvalue具有所有rvalue和glvalue所有的特征。
4. 左值引用與右值引用
注意:左值引用和右值引用不屬于值類别(value category),它們是表達式的類型(type),并且都是組合類型(compound type)。
我相信每一個C++程式員一定都會知道什麼“引用”,但可能并非每個人都知道什麼是“右值引用”(rvalue reference)。
在C++11之前,引用分為const引用和非const引用。這兩種引用在C++11中都稱做左值引用(lvalue reference)。
無法将非const左值引用指向右值。例如,下面這行代碼是無法通過編譯的:
int& a = 10;
編譯器的報錯是:
error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
它的意思是:你無法将一個非const左值引用指向一個臨時的值。
但是const類型的左值引用是可以綁定到右值上的,是以下面這行代碼是沒問題的:
const int& a = 10;
不過,由于這個引用是const的,是以你無法修改其值的内容。
C++11新增了右值引用,左值引用的寫法是
&
,右值引用的寫法是
&&
。
右值是一個臨時的值,右值引用是指向右值的引用。右值引用延長了臨時值的生命周期,并且允許我們修改其值。
例如:
std::string s1 = "Hello ";
std::string s2 = "world";
std::string&& s_rref = s1 + s2; // the result of s1 + s2 is an rvalue
s_rref += ", my friend"; // I can change the temporary string!
std::cout << s_rref << '\n'; // prints "Hello world, my friend"
在上面這個代碼中,
s_rref
是一個指向臨時對象的引用:右值引用。由于這裡沒有const,是以我們可以借此修改臨時對象的内容。
右值引用使得我們可以建立出以此為基礎的函數重載,例如:
void func(X& x) {
cout << "lvalue reference version" << endl;
}
void func(X&& x) {
cout << "rvalue reference version" << endl;
}
當傳入的參數是一個左值時,會綁定到第一個版本上。當傳入的參數是一個右值時,會綁定到第二個版本上,以下面這段代碼為例:
X returnX() {
return X();
}
int main(int argc, char** argv) {
X x;
func(x);
func(returnX());
}
其輸出是:
lvalue reference version
rvalue reference version
我們整理一下上面的内容:
- 左值引用:即可以綁定到左值(非const),也可以綁定到右值(const)
- 右值引用:隻能綁定到右值
右值引用本身是一個左值還是一個右值?答案是:都有可能。右值引用既可能是lvalue,也可能是rvalue。如果它有名稱,則是lvalue,否則是rvalue。
右值引用是C++11中兩個新增功能的文法基礎,這兩個功能是:
- 移動語義(Move Semantics)
- 完美轉發(Perfect Forward)
下面來逐個介紹。
5. 移動語義
我們知道,在C++中,你可以為類定義拷貝構造函數(copy constructor)和拷貝指派(copy assignment)運算符。
它們看起來像這樣:
class X
{
public:
X(const X& other) // copy constructor
{
m_data = new int[other.m_size];
std::copy(other.m_data, other.m_data + other.m_size, m_data);
m_size = other.m_size;
}
X& operator=(X other) // copy assignment
{
if(this == &other) return *this;
delete[] m_data;
m_data = new int[other.m_size];
std::copy(other.m_data, other.m_data + other.m_size, m_data);
m_size = other.m_size;
return *this;
}
X& operator=(const X& other) // copy assignment
{
if(this == &other) return *this;
delete[] m_data;
m_data = new int[other.m_size];
std::copy(other.m_data, other.m_data + other.m_size, m_data);
m_size = other.m_size;
return *this;
}
private:
int* m_data;
size_t m_size;
};
當然,如果你為類定義了拷貝構造函數和拷貝指派運算符,你通常還應當為其定義析構函數。這稱之為[Rule of Three](https://en.wikipedia.org/wiki/Rule_of_three_(C++_programming "Rule of Three"))。
拷貝意味着會将原先的資料複制一份新的出來。這麼做的好處是:新的資料與原先的資料是獨立的兩份,修改其中一個不會影響另外一個。但壞處是:這麼做會消耗運算時間和存儲空間。例如:你有一個包含了 個元素的集合資料,将其拷貝一份就不那麼輕松了。
而移動操作則輕量了很多,因為它不涉及新資料的産生,僅僅是将原先的資料更改擁有者。
在C++11中,你可以為類定義移動構造函數(move constructor)和移動指派(move assignment)運算符。它們看起來是這樣:
X(X&&);
X& operator=(X&&);
你應該已經看出,這裡用到的都是右值引用。
繼續以上面定義的類型為例,其移動構造函數和移動指派運算符的實作可能是這樣的:
X(X&& other) // <-- rvalue reference in input
{
m_data = other.m_data; // ①
m_size = other.m_size;
other.m_data = nullptr; // ②
other.m_size = 0;
}
X& operator=(X&& other) // <-- rvalue reference in input
{
if (this == &other) return *this;
delete[] m_data; // ③
m_data = other.m_data; // ④
m_size = other.m_size;
other.m_data = nullptr; // ⑤
other.m_size = 0;
return *this;
}
在這段代碼中:
- 擷取
對象所包含的值other
- 處理
的内部結構,防止再次使用other
- 釋放自身包含的指針
- 擷取
對象所包含的值other
- 處理
的内部結構,防止再次使用other
現在,該類有了拷貝和移動兩種操作,那編譯器如何知道該選擇哪個呢?答案是,根據傳入的參數類型:如果是左值引用,則使用拷貝操作;如果是右值引用,則使用移動操作。
X createX(int size)
{
return X(size);
}
int main()
{
X h1(1000); // regular constructor
X h2(h1); // copy constructor (lvalue in input)
X h3 = createX(2000); // move constructor (rvalue in input)
h2 = h3; // assignment operator (lvalue in input)
h2 = createX(500); // move assignment operator (rvalue in input)
}
這裡的兩次移動操作避免了資料複制的資源消耗。
接下來的問題是:如果是左值,也能調用移動操作嗎?
答案是肯定的,借助
std::move()
即可。
std::move()
的名稱具有一定的迷惑性,因為它并沒有進行任何“移動”的操作,它僅僅是:無條件的将實參強制轉換成右值引用,僅此而已。是以C++之父認為它的名字叫做
rval()
應該更合适。但是不管怎麼樣,由于曆史原因,它已經叫做
std::move()
。
是以,下面這個代碼中
x2
構造時調用的也是移動構造函數:
X x1(1000);
X x2(std::move(x1));
不過需要注意的是,由于
x1
其中包含的值已經被移動走了,是以你不應當再使用它了。
有了右值引用和移動操作之後,STL中的集合操作變得更加高效了,例如:
std::string str = "Hello";
std::vector<std::string> v;
v.push_back(str); // ①
v.push_back(std::move(str)); // ②
這裡的①将複制一個字元串添加到集合中,而②是将已有的對象移動進集合中,是以自然是更高效的。
6. perfect forward
在C++11之前,C++語言存在一個稱之為“The Forwarding Problem[1]”的問題。
這個問題直到C++11才得以解決。不過要說清楚這個問題并不那麼容易。下面以一個具體的代碼示例來說明。
一直以來,我們都是通過
push_back
方法往
vector
中添加對象的:
class MyKlass {
public:
MyKlass(int ii_, float ff_) {...}
...
};
some function {
std::vector<MyKlass> v;
v.push_back(MyKlass(2, 3.14f));
}
但看了上面的内容,你應該已經意識到,這樣的方式是通過拷貝的形式完成添加的:要先建立出一個臨時對象來,然後拷貝進集合中,這樣做效率不夠高。更好的方法當時是通過移動。于是C++11為集合類添加了 emplace[2] 和 emplace_back[3] 方法。
emplace_back
用起來像這樣:
v.emplace_back(2, 3.14f);
這個方法接受模闆執行個體類
MyKlass
的構造函數形參,這樣做避免了臨時對象的構造。
但是你有沒有想過
emplace_back
函數是如何實作的呢?我們可以嘗試一下。
我們嘗試的第一個版本可能是這樣:
template <typename T1, typename T2>
void emplace_back(T1 e1, T2 e2) {
func(e1, e2);
}
這個方法存在一個問題,那就是它不支援引用類型。即便
func
的參數是引用類型的,但是外層
emplace_back
的參數已經是複制的值。也就說,這裡會多一次拷貝。
于是我們第二個版本将改成這樣,把外層的參數也改成引用的:
template <typename T1, typename T2>
void emplace_back(T1& e1, T2& e2) {
func(e1, e2);
}
這時又有一個問題:左值引用不能指向右值,是以如果我們這樣調用是無法通過編譯的:
emplace_back(42, 3.14f);
不過const引用是可以指向右值的,是以解決這個問題的辦法就是重載:為每個參數定義const和非const兩種類型的引用版本,于是乎成了這樣:
template <typename T1, typename T2>
void emplace_back(T1& e1, T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void emplace_back(const T1& e1, T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void emplace_back(T1& e1, const T2& e2) { func(e1, e2); }
template <typename T1, typename T2>
void emplace_back(const T1& e1, const T2& e2) { func(e1, e2); }
很顯然,你馬上就意識好像不太對勁。如果是2個參數,需要定義四個重載的版本。那如果是5個參數呢?需要 的版本。如果是10個參數呢???
為了解決這個問題,C++11引入了兩個新的機制:
- Reference Collapsing Rules,我不太确定它的正式中文翻譯是什麼。我們姑且稱之為:引用符号折疊規則。
- 特殊類型推導規則,這個與Universal Reference相關。
先說第一個:在C++11中,不存在引用的引用,是以
A& &
的寫法是無法編譯的。但在模闆類型推導的時候,這是有可能發生的。例如下面這個定義:
template <typename T>
void baz(T t) {
T& k = t;
}
當我們用
int&
去執行個體化的時候:
int ii = 4;
baz<int&>(ii);
将
T
替換成
int&
,于是
k
的類型就變成了
int& &
。甚至于,如果用右值引用
int&&
代替
T
的話,
k
的類型就變成了
int&& &
。
是以C++标準定了一些規則,在這種情況下,編譯器會執行Reference Collapsing Rules,具體的規則如下:
- 如果是
将變成A& &
A&
- 如果是
将變成A& &&
A&
- 如果是
将變成A&& &
A&
- 如果是
将變成A&& &&
A&&
簡單記憶如下:
- 兩個或者三個
都會變成一個&
&
- 四個
都會變成兩個&
&
然後我們再說第二個規則:特殊類型推導規則是在特殊的環境下才會産生的推導規則(這好像是一句話廢話)。要了解這個,還需要再借助另外一個術語:Universal Reference,我們可以簡稱URef。這應該是Scott Meyers(Scott Meyers是世界頂級的C++軟體開發技術權威之一,他的《Effective C++》,《More Effective C++》你應該聽說過)創造的名詞。
關于URef的詳細内容可以閱讀:Scott Meyers 《Universal References in C++11》[4]。
URef的定義如下:
img
就是說:隻有聲明為
T&&
且T需要推導的情況下,才是URef。例如
void f(Widget&& w);
,由于不需要推導,是以它不是URef。
但下面這個代碼中,由于模闆需要推導,是以它是URef:
template<typename T>
void foo(T&&);
如果是URef這種情況,則其具有特殊的推導規則。具體描述如下:
- 如果用類型A的左值初始化URef,則URef會變成左值引用
A&
- 如果用類型A的右值初始化URef,則URef會變成右值引用
A&&
這個規則有些奇怪。不過這是C++标準定義,編譯器執行的規則,是以我們記住它就好。
除了這兩個規則之外,C++還為我們提供了
forward
函數,該函數有兩個重載的版本,定義如下:
template<class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
return static_cast<T&&>(t);
}
template <class T>
T&& forward(typename std::remove_reference<T>::type&& t) noexcept {
return static_cast<T&&>(t);
}
forward
函數依賴
<type_traits>
頭檔案中的另外一個結構體
remove_reference
:
template< class T >
struct remove_reference;
remove_reference
中包含了一個類型成員名稱為
type
:
template< class T >
using remove_reference_t = typename remove_reference<T>::type;
若類型T為引用類型,則成員
type
為T所引用的類型。否則
type
為T本身。例如:
-
得到std::remove_reference<int>::type
int
-
依舊得到std::remove_reference<int&>::type
int
-
仍然得到std::remove_reference<int&&>::type
int
回到
forward
函數,它借助
remove_reference
将傳入的類型強制轉換成對應的
T&&
形式。
回到我們之前的問題上。我們将
emplace_back
定義成下面這樣:
template <typename T1, typename T2>
void emplace_back(T1&& e1, T2&& e2) {
func(forward<T1>(e1), forward<T2>(e2));
}
第一種情況,當我們通過左值去使用它的時候:
int i = 1;
float f = 2.0f;
emplace_back(i, f);
T
替換成
int&
,
emplace_back
會變成下面這樣:
void emplace_back(int& &&e1, float& &&e2) {
func(forward<int&>(e1), forward<float&>(e2));
}
然後我們選取第一個參數為例(第二個參數是類似的),
forward
會變成這樣:
int& && forward(int& t) noexcept {
return static_cast<int& &&>(t);
}
然後執行引用符号折疊規則,會變成這樣:
int& forward(int& t) noexcept {
return static_cast<int&>(t);
}
于是調用成功。
另外一種情況,對于
emplace_back(1, 2.0f);
調用方式,你可以自行推導一下其變化過程。
這個過程通過語言來描述很啰嗦,是以下面通過一幅圖來說明整個過程,也希望幫你對比記憶:
7. 參考資料與推薦讀物
- Working Draft, Standard for Programming Language C++[5]
- Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14[6]
- cppreference: Value categories[7]
- Understanding lvalues and rvalues in C and C++[8]
- Understanding the meaning of lvalues and rvalues in C++[9]
- C++ rvalue references and move semantics for beginners[10]
- jeaye/value-category-cheatsheet[11]
- A Brief Introduction to Rvalue References[12]
- 《C++ Templates》(2nd)附錄B:Value Categories(值類别)[13]
- [VALUE CATEGORIES – L, GL, X, R, PR\]VALUES[14]
- C++ and Beyond 2012: Scott Meyers - Universal References in C++11[15]
- The deal with C++14 xvalues[16]
- C++ Rvalue References Explained[17]
- [Perfect forwarding and universal references in C++]