從C++98到C++17,表達式類別與時俱進地改變著。引用綁定、auto、decltype、拷貝省略等功能與表達式類別息息相關。 ...
目標
以下代碼能否編譯通過,能否按照期望運行?(點擊展開)
#include <utility>
#include <type_traits>
namespace cpp98
{
struct A { };
A func() { return A(); }
int main()
{
int i = 1;
i = 2;
// 3 = 4;
const int j = 5;
// j = 6;
i = j;
func() = A();
return 0;
}
}
namespace cpp11
{
#define is_lvalue(x) std::is_lvalue_reference<decltype((x))>::value
#define is_prvalue(x) !std::is_reference<decltype((x))>::value
#define is_xvalue(x) std::is_rvalue_reference<decltype((x))>::value
#define is_glvalue(x) (is_lvalue(x) || is_xvalue(x))
#define is_rvalue(x) (is_xvalue(x) || is_prvalue(x))
void func();
int non_reference();
int&& rvalue_reference();
std::pair<int, int> make();
struct Test
{
int field;
void member_function()
{
static_assert(is_lvalue(field), "");
static_assert(is_prvalue(this), "");
}
enum Enum
{
ENUMERATOR,
};
};
int main()
{
int i;
int&& j = std::move(i);
Test test;
static_assert(is_lvalue(i), "");
static_assert(is_lvalue(j), "");
static_assert(std::is_rvalue_reference<decltype(j)>::value, "");
static_assert(is_lvalue(func), "");
static_assert(is_lvalue(test.field), "");
static_assert(is_lvalue("hello"), "");
static_assert(is_prvalue(2), "");
static_assert(is_prvalue(non_reference()), "");
static_assert(is_prvalue(Test{3}), "");
static_assert(is_prvalue(test.ENUMERATOR), "");
static_assert(is_xvalue(rvalue_reference()), "");
static_assert(is_xvalue(make().first), "");
return 0;
}
}
namespace reference
{
int&& rvalue_reference()
{
int local = 1;
return std::move(local);
}
const int& const_lvalue_reference(const int& arg)
{
return arg;
}
int main()
{
auto&& i = rvalue_reference(); // dangling reference
auto&& j = const_lvalue_reference(2); // dangling reference
int k = 3;
auto&& l = const_lvalue_reference(k);
return 0;
}
}
namespace auto_decl
{
int non_reference() { return 1; }
int& lvalue_reference() { static int i; return i; }
const int& const_lvalue_reference() { return lvalue_reference(); }
int&& rvalue_reference() { static int i; return std::move(i); }
int main()
{
auto [s1, s2] = std::pair(2, 3);
auto&& t1 = s1;
static_assert(!std::is_reference<decltype(s1)>::value);
static_assert(std::is_lvalue_reference<decltype(t1)>::value);
int i1 = 4;
auto i2 = i1;
decltype(auto) i3 = i1;
decltype(auto) i4{i1};
decltype(auto) i5 = (i1);
static_assert(!std::is_reference<decltype(i2)>::value, "");
static_assert(!std::is_reference<decltype(i3)>::value, "");
static_assert(!std::is_reference<decltype(i4)>::value, "");
static_assert(std::is_lvalue_reference<decltype(i5)>::value, "");
auto n1 = non_reference();
decltype(auto) n2 = non_reference();
auto&& n3 = non_reference();
static_assert(!std::is_reference<decltype(n1)>::value, "");
static_assert(!std::is_reference<decltype(n2)>::value, "");
static_assert(std::is_rvalue_reference<decltype(n3)>::value, "");
auto l1 = lvalue_reference();
decltype(auto) l2 = lvalue_reference();
auto&& l3 = lvalue_reference();
static_assert(!std::is_reference<decltype(l1)>::value, "");
static_assert(std::is_lvalue_reference<decltype(l2)>::value, "");
static_assert(std::is_lvalue_reference<decltype(l3)>::value, "");
auto c1 = const_lvalue_reference();
decltype(auto) c2 = const_lvalue_reference();
auto&& c3 = const_lvalue_reference();
static_assert(!std::is_reference<decltype(c1)>::value, "");
static_assert(std::is_lvalue_reference<decltype(c2)>::value, "");
static_assert(std::is_lvalue_reference<decltype(c3)>::value, "");
auto r1 = rvalue_reference();
decltype(auto) r2 = rvalue_reference();
auto&& r3 = rvalue_reference();
static_assert(!std::is_reference<decltype(r1)>::value, "");
static_assert(std::is_rvalue_reference<decltype(r2)>::value, "");
static_assert(std::is_rvalue_reference<decltype(r3)>::value, "");
return 0;
}
}
namespace cpp17
{
class NonMoveable
{
public:
int i = 1;
NonMoveable(int i) : i(i) { }
NonMoveable(NonMoveable&&) = delete;
};
NonMoveable make(int i)
{
return NonMoveable{i};
}
void take(NonMoveable nm)
{
return static_cast<void>(nm);
}
int main()
{
auto nm = make(2);
auto nm2 = NonMoveable{make(3)};
// take(nm);
take(make(4));
take(NonMoveable{make(5)});
return 0;
}
}
int main()
{
cpp98::main();
cpp11::main();
reference::main();
auto_decl::main();
cpp17::main();
}
C++98表達式類別
每個C++表達式都有一個類型:42
的類型為int
,int i;
則(i)
的類型為int&
。這些類型落入若幹類別中。在C++98/03中,每個表達式都是左值或右值。
左值(lvalue)是指向真實儲存在記憶體或寄存器中的值的表達式。“l”指的是“left-hand side”,因為在C中只有lvalue才能寫在賦值運算符的左邊。相對地,右值(rvalue,“r”指的是“right-hand side”)只能出現在賦值運算符的右邊。
有一些例外,如const int i;
,i
雖然是左值但不能出現在賦值運算符的左邊。到了C++,類類型的rvalue卻可以出現在賦值運算符的左邊,事實上這裡的賦值是對賦值運算符函數的調用,與基本類型的賦值是不同的。
lvalue可以理解為可取地址的值,變數、對指針解引用、對返回類型為引用類型的函數的調用等,都是lvalue。臨時對象都是rvalue,包括字面量和返回類型為非引用類型的函數調用等。字元串字面量是個例外,它屬於不可修改的左值。
賦值運算符左邊需要一個lvalue,右邊需要一個rvalue,如果給它一個lvalue,該lvalue會被隱式轉換成rvalue。這個過程是理所當然的。
動機
C++11引入了右值引用和移動語義。函數返回的右值引用,顧名思義,應該表現得和右值一樣,但是這會破壞很多既有的規則:
-
rvalue是匿名的,不一定有存儲空間,但右值引用指向記憶體中的具體對象,該對象還要被維護著;
-
rvalue的類型是確定的,必須是完全類型,靜態類型與動態類型相同,而右值引用可以是不完全類型,也可以支持多態;
-
非類類型的rvalue沒有cv修飾(
const
和volatile
),但右值引用可以有,而且修飾符必須保留。
這給傳統的lvalue/rvalue二分法帶來了挑戰,C++委員會面臨選擇:
-
維持右值引用是rvalue,添加一些特殊規則;
-
把右值引用歸為lvalue,添加一些特殊規則;
-
細化表達式類別。
上述問題只是冰山一角;歷史選擇了第三種方案。
C++11表達式類別
C++11提出了表達式類別(value category)的概念。雖然名叫“value category”,但類別劃分的是表達式而不是值,所以我從標題開始就把它譯為“表達式類別”。C++標准定義表達式為:
An expression is a sequence of operators and operands that specifies a computation. An expression can result in a value and can cause side effects.
每個表達式都是三種類別之一:左值(lvalue)、消亡值(xvalue)和純右值(prvalue),稱為主類別。還有兩種混合類別:lvalue和xvalue統稱範左值(glvalue),xvalue和prvalue統稱右值(rvalue)。
#define is_glvalue(x) (is_lvalue(x) || is_xvalue(x))
#define is_rvalue(x) (is_xvalue(x) || is_prvalue(x))
C++11對這些類別的定義如下:
-
lvalue指定一個函數或一個對象;
-
xvalue(eXpiring vavlue)也指向對象,通常接近其生命周期的終點;一些涉及右值引用的表達式的結果是xvalue;
-
gvalue(generalized lvalue)是一個lvalue或xvalue;
-
rvalue是xvalue、臨時對象或它們的子對象,或者沒有關聯對象的值;
-
prvalue(pure rvalue)是不是xvalue的rvalue。
這種定義不是很清晰。具體來講,lvalue包括:(點擊展開)
-
變數、函數、數據成員的名字,包括右值引用類型的變數也是lvalue;
int i; int&& j = std::move(i); static_assert(is_lvalue(j), ""); static_assert(std::is_rvalue_reference<decltype(j)>::value, "");
-
函數調用或重載運算符表達式,其返回類型為左值引用類型,或函數的右值引用類型;
-
內置賦值、複合賦值、前置自增、前置自減運算符表達式;
-
內置數組下標表達式
a[n]
和p[n]
(a
為數組類型,p
為指針類型),a
是一個數組lvalue; -
a.m
,除非m
是枚舉成員,或非靜態成員函數,或a
是rvalue且m
是非引用類型的非靜態數據成員; -
p->m
,除非m
是枚舉成員,或非靜態成員函數; -
a.*mp
,a
是一個lvalue,mp
是數據成員指針; -
p->*mp
,mp
是數據成員指針; -
逗號表達式,第二個運算數是lvalue;
-
條件運算符
a ? b : c
,這裡有非常複雜的規則,舉其中一例,當b
和c
是相同類型的lvalue時; -
字元串字面量;
-
顯式轉換為左值引用類型或函數的右值引用類型。
lvalue的性質:
-
與glvalue相同;
-
內置取地址運算符可以作用於lvalue;
-
可修改的lvalue可以出現在內置賦值運算符的左邊;
-
可以用來初始化一個左值引用。
prvalue包括:
-
除字元串以外的字面量;
-
函數調用或重載運算符表達式,其返回類型為非引用類型;
-
內置算術運算、邏輯運算、比較運算、取地址運算符表達式;
-
a.m
或p->m
,m
是枚舉成員或非靜態成員函數(見下); -
a.*mp
或p->*mp
,mp
是成員函數指針; -
逗號表達式,第二個運算數是rvalue;
-
條件運算符
a ? b : c
的部分情況,如b
和c
是相同類型的prvalue; -
顯式轉換為非引用類型;
-
this
指針; -
枚舉成員;
-
非類型模板參數,除非它是左值引用類型;
-
lambda表達式。
prvalue的性質:
-
與rvalue相同;
-
不能是多態的;
-
非類類型且非數組的prvalue沒有cv修飾符,即使寫了也沒有;
-
必須是完全類型;
-
不能是抽象類型或其數組。
xvalue包括:
-
函數調用或重載運算符表達式,其返回類型為右值引用類型;
-
內置數組下標表達式
a[n]
,a
是一個數組rvalue; -
a.m
,a
是rvalue且m
是非引用類型的非靜態數據成員; -
a.*mp
,a
是一個rvalue,mp
是數據成員指針; -
條件運算符
a ? b : c
的部分情況,如b
和c
是相同類型的xvalue。
xvalue的性質;
-
與rvalue相同;
-
與glvalue相同。
glvalue的性質:
-
可以隱式轉換為prvalue;
-
可以是多態的;
-
可以是不完全類型。
rvalue的性質:
-
內置取地址運算符不能作用於rvalue;
-
不能出現在內置賦值或複合賦值運算符的左邊;
-
可以綁定給
const
左值引用(見下); -
可以用來初始化右值引用(見下);
-
如果一個函數有右值引用參數和
const
左值引用參數兩個重載,傳入一個rvalue時,右值引用的那個重載被調用。
還有一些特殊的分類:
-
對於非靜態成員函數
mf
及其指針pmf
,a.mf
、p->mf
、a.*pmf
和p->*pmf
都被歸類為prvalue,但它們不是常規的prvalue,而是pending(即將發生的) member function call,只能用於函數調用; -
返回
void
的函數調用、向void
的類型裝換和throw
語句都是void
表達式,不能用於初始化引用或函數參數; -
C++中最小的定址單位是位元組,因此位域不能綁定到非
const
左值引用上;const
左值引用和右值引用可以綁定位域,它們指向的是位域的一個拷貝。
終於把5個類別介紹完了。表達式可以分為lvalue、xvalue和prvalue三類,lvalue和prvalue與C++98中的lvalue和rvalue類似,而xvalue則完全是為右值引用而生,兼有glvalue與rvalue的性質。除了這種三分類法外,表達式還可以分為lvalue和rvalue兩類,它們之間的主要差別在於是否可以取地址;還可以分為glvalue和prvalue兩類,它們之間的主要差別在於是否存在實體,glvalue有實體,因而可以修改原對象,xvalue常被壓榨剩餘價值。
引用綁定
我們稍微岔開一會,來看兩個與表達式分類相關的特性。
引用綁定有以下類型:
-
左值引用綁定lvalue,cv修飾符只能多不能少;
-
右值引用可以綁定rvalue,我們通常不給右值引用加cv修飾符;
-
const
左值引用可以綁定rvalue。
左值引用綁定lvalue天經地義,沒什麼需要關照的。但rvalue都是臨時對象,綁定給引用就意味著要繼續用它,它的生命周期會受到影響。通常,rvalue的生命周期會延長到綁定引用的聲明周期,但有以下例外:
-
由
return
語句返回的臨時對象在return
語句結束後即銷毀,這樣的函數總是會返回一個空懸引用(dangling reference); -
綁定到初始化列表中的引用的臨時對象的生命周期只延長到構造函數結束——這是個缺陷,在C++14中被修複;
-
綁定到函數參數的臨時對象的生命周期延長到函數調用所在表達式結束,把該參數作為引用返回會得到空懸引用;
-
綁定到
new
表達式中的引用的臨時對象的生命周期只延長到包含new
的表達式的結束,不會跟著那個對象。
簡而言之,臨時變數的生命周期只能延長一次。
#include <utility>
int&& rvalue_reference()
{
int local = 1;
return std::move(local);
}
const int& const_lvalue_reference(const int& arg)
{
return arg;
}
int main()
{
auto&& i = rvalue_reference(); // dangling reference
auto&& j = const_lvalue_reference(2); // dangling reference
int k = 3;
auto&& l = const_lvalue_reference(k);
}
rvalue_reference
返回一個指向局部變數的引用,因此i
是空懸引用;2
綁定到const_lvalue_reference
的參數arg
上,函數返回後延長的生命周期達到終點,因此j
也是懸空引用;k
在傳參的過程中根本沒有臨時對象創建出來,所以l
不是空懸引用,它是指向k
的const
左值引用。
auto與decltype
從C++11開始,auto
關鍵字用於自動推導類型,用的是模板參數推導的規則:如果是拷貝列表初始化,則對應模板參數為std::initializer_list<T>
,否則把auto
替換為T
。至於詳細的模板參數推導規則,要介紹的話未免喧賓奪主了。
還好,這不是我們的重點。在引出重點之前,我們還得先看decltype
。
decltype
用於聲明一個類型("declare type"),有兩種語法:
-
decltype(entity)
; -
decltype(expression)
。
第一種,decltype
的參數是沒有括弧包裹的標識符或類成員,則decltype
產生該實體的類型;如果是結構化綁定,則產生被引類型。
第二種,decltype
的參數是不能匹配第一種的任何表達式,其類型為T
,則根據其表達式類別討論:
-
如果是xvalue,產生
T&&
——#define is_xvalue(x) std::is_rvalue_reference<decltype((x))>::value
; -
如果是lvalue,產生
T&
——#define is_lvalue(x) std::is_lvalue_reference<decltype((x))>::value
; -
如果是prvalue,產生
T
——#define is_prvalue(x) !std::is_reference<decltype((x))>::value
。
因此,decltype(x)
和decltype((x))
產生的類型通常是不同的。
對於不帶引用修飾的auto
,初始化器的表達式類別會被抹去,為此C++14引入了新語法decltype(auto)
,產生的類型為decltype(expr)
,其中expr
為初始化器。對於局部變數,等號右邊加上一對圓括弧,可以保留表達式類別。
#include <utility>
#include <type_traits>
int non_reference() { return 1; }
int& lvalue_reference() { static int i; return i; }
const int& const_lvalue_reference() { return lvalue_reference(); }
int&& rvalue_reference() { static int i; return std::move(i); }
int main()
{
auto [s1, s2] = std::pair(2, 3);
auto&& t1 = s1;
static_assert(!std::is_reference<decltype(s1)>::value);
static_assert(std::is_lvalue_reference<decltype(t1)>::value);
int i1 = 4;
auto i2 = i1;
decltype(auto) i3 = i1;
decltype(auto) i4{i1};
decltype(auto) i5 = (i1);
static_assert(!std::is_reference<decltype(i2)>::value);
static_assert(!std::is_reference<decltype(i3)>::value);
static_assert(!std::is_reference<decltype(i4)>::value);
static_assert(std::is_lvalue_reference<decltype(i5)>::value);
auto n1 = non_reference();
decltype(auto) n2 = non_reference();
auto&& n3 = non_reference();
static_assert(!std::is_reference<decltype(n1)>::value, "");
static_assert(!std::is_reference<decltype(n2)>::value, "");
static_assert(std::is_rvalue_reference<decltype(n3)>::value, "");
auto l1 = lvalue_reference();
decltype(auto) l2 = lvalue_reference();
auto&& l3 = lvalue_reference();
static_assert(!std::is_reference<decltype(l1)>::value, "");
static_assert(std::is_lvalue_reference<decltype(l2)>::value, "");
static_assert(std::is_lvalue_reference<decltype(l3)>::value, "");
auto c1 = const_lvalue_reference();
decltype(auto) c2 = const_lvalue_reference();
auto&& c3 = const_lvalue_reference();
static_assert(!std::is_reference<decltype(c1)>::value, "");
static_assert(std::is_lvalue_reference<decltype(c2)>::value, "");
static_assert(std::is_lvalue_reference<decltype(c3)>::value, "");
auto r1 = rvalue_reference();
decltype(auto) r2 = rvalue_reference();
auto&& r3 = rvalue_reference();
static_assert(!std::is_reference<decltype(r1)>::value, "");
static_assert(std::is_rvalue_reference<decltype(r2)>::value, "");
static_assert(std::is_rvalue_reference<decltype(r3)>::value, "");
}
用auto
定義的變數都是int
類型,無論函數的返回類型的引用和const
修飾;用decltype(auto)
定義的變數的類型與函數返回類型相同;auto&&
是轉發引用,n3
類型為int&&
,其餘與decltype(auto)
相同。
C++17表達式類別
眾所周知,編譯器常會執行NRVO(named return value optimization),減少一次對函數返回值的移動或拷貝。不過,這屬於C++標準說編譯器可以做的行為,卻沒有保證編譯器會這麼做,因此客戶不能對此作出假設,從而需要提供一個拷貝或移動構造函數,儘管它們可能不會被調用。然而,並不是所有情況下都能提供移動構造函數,即使能移動構造函數也未必只是一個指針的交換。總之,我們明知移動構造函數不會被調用卻還要硬著頭皮提供一個,這樣做非常形式主義。
所以,C++17規定了拷貝省略,確保在以下情況下,即使拷貝或移動構造函數有可觀察的效果,它們也不會被調用,原本要拷貝或移動的對象直接在目標位置構造:
-
在
return
表達式中,運算數是忽略cv修飾符以後的返回類型的prvalue; -
在初始化中,初始化器是與變數相同類型的prvalue。
值得一提的是,這類行為在C++17中不能算是一種優化,因為不存在用來拷貝或移動的臨時對象。事實上,C++17重新定義了表達式類別:
-
glvalue的求值能確定對象、位域、函數的身份;
-
prvalue的求值初始化對象或位域,或計算運算數的值,由上下文決定;
-
xvalue是表示一個對象或位域的資源能被重用的glvalue;
-
lvalue是不是xvalue的glvalue;
-
rvalue是prvalue或xvalue。
這個定義在功能上與C++11中的相同,但是更清晰地指出了glvalue和prvalue的區別——glvalue產生地址,prvalue執行初始化。
prvalue初始化的對象由上下文決定:在拷貝省略的情形下,prvalue不曾有關聯的對象;其他情形下,prvalue將產生一個臨時對象,這個過程稱為臨時實體化(temporary materialization)。
臨時實體化把一個完全類型的prvalue轉換成xvalue,在以下情形中發生:
-
把引用綁定到prvalue上;
-
類prvalue被獲取成員;
-
數組prvalue被轉換為指針或下標取元素;
-
prvalue出現在大括弧初始化列表中,用於初始化一個
std::initializer_list<T>
; -
被使用
typeid
或sizeof
運算符; -
在語句
expr;
中或被轉換成void
,即該表達式的值被丟棄。
或者可以理解為,所有非拷貝省略的場合中的prvalue都會被臨時實體化。
class NonMoveable
{
public:
int i = 1;
NonMoveable(int i) : i(i) { }
NonMoveable(NonMoveable&&) = delete;
};
NonMoveable make(int i)
{
return NonMoveable{i};
}
void take(NonMoveable nm)
{
return static_cast<void>(nm);
}
int main()
{
auto nm = make(2);
auto nm2 = NonMoveable{make(3)};
// take(nm);
take(make(4));
take(NonMoveable{make(5)});
}
NonMoveable
的移動構造函數被聲明為delete
,於是拷貝構造函數也被隱式delete
。在auto nm = make(2);
中,NonMoveable{i}
為prvalue,根據拷貝省略的第一條規則,它直接構造為返回值;返回值是NonMoveable
的prvalue,與nm
類型相同,根據第二條規則,這個prvalue直接在nm
的位置上構造;兩部分結合,該聲明式相當於NonMoveable nm{2};
。
在MSVC中,這段代碼不能通過編譯,這是編譯器未能嚴格遵守C++標準的緣故。然而,如果在NonMoveable
的移動構造函數中添加輸出語句,程式運行起來也沒有任何輸出,即使在Debug模式下、即使用C++11標準編譯都如此。這也側面反映出拷貝省略的意義。
總結
C++11規定每個表達式都屬於lvalue、xvalue和prvalue三個類別之一,表達式另可分為lvalue和rvalue,或glvalue和prvalue。返回右值引用的函數調用是xvalue,右值引用類型的變數是lvalue。
const
左值引用和右值引用可以綁定臨時對象,但是臨時對象的聲明周期只能延長一次,返回一個指向局部變數的右值引用也會導致空懸引用。
標識符加上一對圓括弧成為表達式,decltype
用於表達式可以根據其類別產生相應的類型,用decltype(auto)
聲明變數可以保留表達式類別。
C++17中prvalue是否有關聯對象由上下文決定,拷貝省略規定了特定情況下對象不經拷貝或移動直接構造,NRVO成為強制性標準,使不能被移動的對象在語義上可以值傳遞。