文章首發(fā)
【重學(xué)C++】04 | 說(shuō)透C++右值引用、移動(dòng)語(yǔ)義、完美轉(zhuǎn)發(fā)(上)
引言
大家好,我是只講技術(shù)干貨的會(huì)玩code,今天是【重學(xué)C++】的第四講,在前面《03 | 手?jǐn)]C++智能指針實(shí)戰(zhàn)教程》中,我們或多或少接觸了右值引用和移動(dòng)的一些用法。
右值引用是 C++11 標(biāo)準(zhǔn)中一個(gè)很重要的特性。第一次接觸時(shí),可能會(huì)很亂,不清楚它們的目的是什么或者它們解決了什么問(wèn)題。接下來(lái)兩節(jié)課,我們?cè)敿?xì)講講右值引用及其相關(guān)應(yīng)用。內(nèi)容很干,注意收藏!
左值 vs 右值
簡(jiǎn)單來(lái)說(shuō),左值是指可以使用&
符號(hào)獲取到內(nèi)存地址的表達(dá)式,一般出現(xiàn)在賦值語(yǔ)句的左邊,比如變量、數(shù)組元素和指針等。
int i = 42;
i = 43; // ok, i是一個(gè)左值
int* p = &i; // ok, i是一個(gè)左值,可以通過(guò)&符號(hào)獲取內(nèi)存地址
int& lfoo() { // 返回了一個(gè)引用,所以lfoo()返回值是一個(gè)左值
int a = 1;
return a;
};
lfoo() = 42; // ok, lfoo() 是一個(gè)左值
int* p1 = &lfoo(); // ok, lfoo()是一個(gè)左值
相反,右值是指無(wú)法獲取到內(nèi)存地址的表達(dá)是,一般出現(xiàn)在賦值語(yǔ)句的右邊。常見(jiàn)的有字面值常量、表達(dá)式結(jié)果、臨時(shí)對(duì)象等。
int rfoo() { // 返回了一個(gè)int類型的臨時(shí)對(duì)象,所以rfoo()返回值是一個(gè)右值
return 5;
};
int j = 0;
j = 42; // ok, 42是一個(gè)右值
j = rfoo(); // ok, rfoo()是右值
int* p2 = &rfoo(); // error, rfoo()是右值,無(wú)法獲取內(nèi)存地址
左值引用 vs 右值引用
C++中的引用是一種別名,可以通過(guò)一個(gè)變量名訪問(wèn)另一個(gè)變量的值。
上圖中,變量a和變量b指向同一塊內(nèi)存地址,也可以說(shuō)變量a是變量b的別名。
在C++中,引用分為左值引用和右值引用兩種類型。左值引用是指對(duì)左值進(jìn)行引用的引用類型,通常使用&
符號(hào)定義;右值引用是指對(duì)右值進(jìn)行引用的引用類型,通常使用&&
符號(hào)定義。
class X {...};
// 接收一個(gè)左值引用
void foo(X& x);
// 接收一個(gè)右值引用
void foo(X&& x);
X x;
foo(x); // 傳入?yún)?shù)為左值,調(diào)用foo(X&);
X bar();
foo(bar()); // 傳入?yún)?shù)為右值,調(diào)用foo(X&&);
所以,通過(guò)重載左值引用和右值引用兩種函數(shù)版本,滿足在傳入左值和右值時(shí)觸發(fā)不同的函數(shù)分支。
值得注意的是,void foo(const X& x);
同時(shí)接受左值和右值傳參。
void foo(const X& x);
X x;
foo(x); // ok, foo(const X& x)能夠接收左值傳參
X bar();
foo(bar()); // ok, foo(const X& x)能夠接收右值傳參
// 新增右值引用版本
void foo(X&& x);
foo(bar()); // ok, 精準(zhǔn)匹配調(diào)用foo(X&& x)
到此,我們先簡(jiǎn)單對(duì)右值和右值引用做個(gè)小結(jié):
- 像字面值常量、表達(dá)式結(jié)果、臨時(shí)對(duì)象等這類無(wú)法通過(guò)
&
符號(hào)獲取變量?jī)?nèi)存地址的,稱為右值。 - 右值引用是一種引用類型,表示對(duì)右值進(jìn)行引用,通常使用
&&
符號(hào)定義。
右值引用主要解決一下兩個(gè)問(wèn)題:
- 實(shí)現(xiàn)移動(dòng)語(yǔ)義
- 實(shí)現(xiàn)完美轉(zhuǎn)發(fā)
這一節(jié)我們先詳細(xì)講講右值是如何實(shí)現(xiàn)移動(dòng)效果的,以及相關(guān)的注意事項(xiàng)。完美轉(zhuǎn)發(fā)篇幅有點(diǎn)多,我們留到下節(jié)講。
復(fù)制 vs 移動(dòng)
假設(shè)有一個(gè)自定義類X
,該類包含一個(gè)指針成員變量,該指針指向另一個(gè)自定義類對(duì)象。假設(shè)O
占用了很大內(nèi)存,創(chuàng)建/復(fù)制O
對(duì)象需要較大成本。
class O {
public:
O() {
std::cout << "call o constructor" << std::endl;
};
O(const O& rhs) {
std::cout << "call o copy constructor." << std::endl;
}
};
class X {
public:
O* o_p;
X() {
o_p = new O();
}
~X() {
delete o_p;
}
};
X
對(duì)應(yīng)的拷貝賦值函數(shù)如下:
X& X::operator=(X const & rhs) {
// 根據(jù)rhs.o_p生成的一個(gè)新的O對(duì)象資源
O* tmp_p = new O(*rhs.o_p);
// 回收x當(dāng)前的o_p;
delete this->o_p;
// 將tmp_p 賦值給 this.o_p;
this->o_p = tmp_p;
return *this;
}
假設(shè)對(duì)X
有以下使用場(chǎng)景:
X x1;
X x2;
x1 = x2;
上述代碼輸出:
call o constructor
call o constructor
call o copy constructor
x1
和x2
初始化時(shí),都會(huì)執(zhí)行new O()
, 所以會(huì)調(diào)用兩次O
的構(gòu)造函數(shù);執(zhí)行x1=x2
時(shí),會(huì)調(diào)用一次O
的拷貝構(gòu)造函數(shù),根據(jù)x2.o_p
復(fù)制一個(gè)新的O
對(duì)象。
由于x2
在后續(xù)代碼中可能還會(huì)被使用,所以為了避免影響x2
,在賦值時(shí)調(diào)用O
的拷貝構(gòu)造函數(shù)復(fù)制一個(gè)新的O
對(duì)象給x1
在這種場(chǎng)景下是沒(méi)問(wèn)題的。
但在某些場(chǎng)景下,這種拷貝顯得比較多余:
X foo() {
return X();
};
X x1;
x1 = foo();
代碼輸出與之前一樣:
call o constructor
call o constructor
call o copy constructor
在這個(gè)場(chǎng)景下,foo()
創(chuàng)建的那個(gè)臨時(shí)X
對(duì)象在后續(xù)代碼是不會(huì)被用到的。所以我們不需要擔(dān)心賦值函數(shù)中會(huì)不會(huì)影響到那個(gè)臨時(shí)X
對(duì)象,沒(méi)必要去復(fù)制一個(gè)新的O
對(duì)象給x1
。
更高效的做法,是直接使用swap
交換臨時(shí)X
對(duì)象的o_p
和x1.o_p
。這樣做有兩個(gè)好處:1. 不用調(diào)用耗時(shí)的O
拷貝構(gòu)造函數(shù),提高效率;2. 交換后,臨時(shí)X
對(duì)象擁有之前x1.o_p
指向的資源,在析構(gòu)時(shí)能自動(dòng)回收,避免內(nèi)存泄漏。
這種避免高昂的復(fù)制成本,而直接將資源從一個(gè)對(duì)象"移動(dòng)"到另外一個(gè)對(duì)象的行為,就是C++的移動(dòng)語(yǔ)義。
哪些場(chǎng)景適用移動(dòng)操作呢?無(wú)法獲取內(nèi)存地址的右值就很合適,我們不需要擔(dān)心后續(xù)的代碼會(huì)用到該右值。
最后,我們看下移動(dòng)版本的賦值函數(shù)
X& operator=(X&& rhs) noexcept {
std::swap(this->o_p, rhs.o_p);
return *this;
};
看下使用效果:
X x1;
x1 = foo();
輸出結(jié)果:
call o constructor
call o constructor
右值引用一定是右值嗎?
假設(shè)我們有以下代碼:
class X {
public:
// 復(fù)制版本的賦值函數(shù)
X& operator=(const X& rhs);
// 移動(dòng)版本的賦值函數(shù)
X& operator=(X&& rhs) noexcept;
};
void foo(X&& x) {
X x1;
x1 = x;
}
類X
重載了復(fù)制版本和移動(dòng)版本的賦值函數(shù)。現(xiàn)在問(wèn)題是:x1=x
這個(gè)賦值操作調(diào)用的是X& operator=(const X& rhs)
還是 X& operator=(X&& rhs)
?
針對(duì)這種情況,C++給出了相關(guān)的標(biāo)準(zhǔn):
Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is:?if it has a name, then it is an lvalue. Otherwise, it is an rvalue.
也就是說(shuō),只要一個(gè)右值引用有名稱,那對(duì)應(yīng)的變量就是一個(gè)左值,否則,就是右值。
回到上面的例子,函數(shù)foo
的入?yún)㈦m然是右值引用,但有變量名x
,所以x
是一個(gè)左值,所以operator=(const X& rhs)
最終會(huì)被調(diào)用。
再給一個(gè)沒(méi)有名字的右值引用的例子
X bar();
// 調(diào)用X& operator=(X&& rhs),因?yàn)閎ar()返回的X對(duì)象沒(méi)有關(guān)聯(lián)到一個(gè)變量名上
X x = bar();
這么設(shè)計(jì)的原因也挺好理解。再改下foo
函數(shù)的邏輯:
void foo(X&& x) {
X x1;
x1 = x;
...
std::cout << *(x.inner_ptr) << std::endl;
}
我們并不能保證在foo
函數(shù)的后續(xù)邏輯中不會(huì)訪問(wèn)到x
的資源。所以這種情況下如果調(diào)用的是移動(dòng)版本的賦值函數(shù),x
的內(nèi)部資源在完成賦值后就亂了,無(wú)法保證后續(xù)的正常訪問(wèn)。
std::move
反過(guò)來(lái)想,如果我們明確知道在x1=x
后,不會(huì)再訪問(wèn)到x
,那有沒(méi)有辦法強(qiáng)制走移動(dòng)賦值函數(shù)呢?
C++提供了std::move
函數(shù),這個(gè)函數(shù)做的工作很簡(jiǎn)單: 通過(guò)隱藏掉入?yún)⒌拿?,返回?duì)應(yīng)的右值。
X bar();
X x1
// ok. std::move(x1)返回右值,調(diào)用移動(dòng)賦值函數(shù)
X x2 = std::move(x1);
// ok. std::move(bar())與 bar()效果相同,返回右值,調(diào)用移動(dòng)賦值函數(shù)
X x3 = std::move(bar());
最后,用一個(gè)容易犯錯(cuò)的例子結(jié)束這一環(huán)節(jié)
class Base {
public:
// 拷貝構(gòu)造函數(shù)
Base(const Base& rhs);
// 移動(dòng)構(gòu)造函數(shù)
Base(Base&& rhs) noexcept;
};
class Derived : Base {
public:
Derived(Derived&& rhs)
// wrong. rhs是左值,會(huì)調(diào)用到 Base(const Base& rhs).
// 需要修改為Base(std::move(rhs))
: Base(rhs) noexcept {
...
}
}
返回值優(yōu)化
依照慣例,還是先給出類X
的定義
class X {
public:
// 構(gòu)造函數(shù)
X() {
std::cout << "call x constructor" <<std::endl;
};
// 拷貝構(gòu)造函數(shù)
X(const X& rhs) {
std::cout << "call x copy constructor" << std::endl;
};
// 移動(dòng)構(gòu)造函數(shù)
X(X&& rhs) noexcept {
std::cout << "call x move constructor" << std::endl
};
}
大家先思考下以下兩個(gè)函數(shù)哪個(gè)性能比較高?
X foo() {
X x;
return x;
};
X bar() {
X x;
return std::move(x);
}
很多讀者可能會(huì)覺(jué)得foo
需要一次復(fù)制行為:從x
復(fù)制到返回值;bar
由于使用了std::move
,滿足移動(dòng)條件,所以觸發(fā)的是移動(dòng)構(gòu)造函數(shù):從x
移動(dòng)到返回值。復(fù)制成本 > 移動(dòng)成本,所以bar
性能更好。
實(shí)際效果與上面的推論相反,bar
中使用std::move
反倒多余了?,F(xiàn)代C++編譯器會(huì)有返回值優(yōu)化。換句話說(shuō),編譯器將直接在foo
返回值的位置構(gòu)造x
對(duì)象,而不是在本地構(gòu)造x
然后將其復(fù)制出去。很明顯,這比在本地構(gòu)造后移動(dòng)效率更快。
以下是foo
和bar
的輸出:
// foo
call x constructor
// bar
call x constructor
call x move constructor
移動(dòng)需要保證異常安全
細(xì)心的讀者可能已經(jīng)發(fā)現(xiàn)了,在前面的幾個(gè)小節(jié)中,移動(dòng)構(gòu)造/賦值函數(shù)我都在函數(shù)簽名中加了關(guān)鍵字noexcept
,這是向調(diào)用者表明,我們的移動(dòng)函數(shù)不會(huì)拋出異常。
這點(diǎn)對(duì)于移動(dòng)函數(shù)很重要,因?yàn)橐苿?dòng)操作會(huì)對(duì)右值造成破壞
。如果移動(dòng)函數(shù)中發(fā)生了異常,可能會(huì)對(duì)程序造成不可逆的錯(cuò)誤。以下面為例
class X {
public:
int* int_p;
O* o_p;
X(X&& rhs) {
std::swap(int_p, rhs.int_p);
...
其他業(yè)務(wù)操作
...
std::swap(o_p, rhs.o_p);
}
}
如果在「其他業(yè)務(wù)操作」中發(fā)生了異常,不僅會(huì)影響到本次構(gòu)造,rhs
內(nèi)部也已經(jīng)被破壞
了,后續(xù)無(wú)法重試構(gòu)造。所以,除非明確標(biāo)識(shí)noexcept
,C++在很多場(chǎng)景下會(huì)慎用
移動(dòng)構(gòu)造。
比較經(jīng)典的場(chǎng)景是std::vector
擴(kuò)縮容。當(dāng)vector
由于push_back
、insert
、reserve
、resize
等函數(shù)導(dǎo)致內(nèi)存重分配時(shí),如果元素提供了一個(gè)noexcept
的移動(dòng)構(gòu)造函數(shù),vector
會(huì)調(diào)用該移動(dòng)構(gòu)造函數(shù)將元素移動(dòng)
到新的內(nèi)存區(qū)域;否則,則會(huì)調(diào)用拷貝構(gòu)造函數(shù),將元素復(fù)制過(guò)去。
總結(jié)
今天我們主要學(xué)了C++中右值引用的相關(guān)概念和應(yīng)用場(chǎng)景,并花了很大篇幅講解移動(dòng)語(yǔ)義及其相關(guān)實(shí)現(xiàn)。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-455815.html
右值引用主要解決實(shí)現(xiàn)移動(dòng)語(yǔ)義和完美轉(zhuǎn)發(fā)的問(wèn)題。我們下節(jié)接著講解右值是如何實(shí)現(xiàn)完美轉(zhuǎn)發(fā)。歡迎關(guān)注,及時(shí)收到推送~文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-455815.html
到了這里,關(guān)于【重學(xué)C++】04 | 說(shuō)透C++右值引用、移動(dòng)語(yǔ)義、完美轉(zhuǎn)發(fā)(上)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!