【重学C++】04 | 说透C++右值引用(上)
文章首發
引言
大家好,我是只講技術干貨的會玩code,今天是【重學C++】的第四講,在前面《03 | 手擼C++智能指針實戰教程》中,我們或多或少接觸了右值引用和移動的一些用法。
右值引用是 C++11 標準中一個很重要的特性。第一次接觸時,可能會很亂,不清楚它們的目的是什么或者它們解決了什么問題。接下來兩節課,我們詳細講講右值引用及其相關應用。內容很干,注意收藏!
左值 vs 右值
簡單來說,左值是指可以使用&符號獲取到內存地址的表達式,一般出現在賦值語句的左邊,比如變量、數組元素和指針等。
int i = 42;
i = 43; // ok, i是一個左值
int* p = &i; // ok, i是一個左值,可以通過&符號獲取內存地址
int& lfoo() { // 返回了一個引用,所以lfoo()返回值是一個左值
int a = 1;
return a;
};
lfoo() = 42; // ok, lfoo() 是一個左值
int* p1 = &lfoo(); // ok, lfoo()是一個左值
相反,右值是指無法獲取到內存地址的表達是,一般出現在賦值語句的右邊。常見的有字面值常量、表達式結果、臨時對象等。
int rfoo() { // 返回了一個int類型的臨時對象,所以rfoo()返回值是一個右值
return 5;
};
int j = 0;
j = 42; // ok, 42是一個右值
j = rfoo(); // ok, rfoo()是右值
int* p2 = &rfoo(); // error, rfoo()是右值,無法獲取內存地址
左值引用 vs 右值引用
C++中的引用是一種別名,可以通過一個變量名訪問另一個變量的值。
上圖中,變量a和變量b指向同一塊內存地址,也可以說變量a是變量b的別名。
在C++中,引用分為左值引用和右值引用兩種類型。左值引用是指對左值進行引用的引用類型,通常使用&符號定義;右值引用是指對右值進行引用的引用類型,通常使用&&符號定義。
class X {...};
// 接收一個左值引用
void foo(X& x);
// 接收一個右值引用
void foo(X&& x);
X x;
foo(x); // 傳入參數為左值,調用foo(X&);
X bar();
foo(bar()); // 傳入參數為右值,調用foo(X&&);
所以,通過重載左值引用和右值引用兩種函數版本,滿足在傳入左值和右值時觸發不同的函數分支。
值得注意的是,void foo(const X& x);同時接受左值和右值傳參。
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, 精準匹配調用foo(X&& x)
到此,我們先簡單對右值和右值引用做個小結:
- 像字面值常量、表達式結果、臨時對象等這類無法通過
&符號獲取變量內存地址的,稱為右值。 - 右值引用是一種引用類型,表示對右值進行引用,通常使用
&&符號定義。
右值引用主要解決一下兩個問題:
- 實現移動語義
- 實現完美轉發
這一節我們先詳細講講右值是如何實現移動效果的,以及相關的注意事項。完美轉發篇幅有點多,我們留到下節講。
復制 vs 移動
假設有一個自定義類X,該類包含一個指針成員變量,該指針指向另一個自定義類對象。假設O占用了很大內存,創建/復制O對象需要較大成本。
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 對應的拷貝賦值函數如下:
X& X::operator=(X const & rhs) {
// 根據rhs.o_p生成的一個新的O對象資源
O* tmp_p = new O(*rhs.o_p);
// 回收x當前的o_p;
delete this->o_p;
// 將tmp_p 賦值給 this.o_p;
this->o_p = tmp_p;
return *this;
}
假設對X有以下使用場景:
X x1;
X x2;
x1 = x2;
上述代碼輸出:
call o constructor
call o constructor
call o copy constructor
x1和x2初始化時,都會執行new O(), 所以會調用兩次O的構造函數;執行x1=x2時,會調用一次O的拷貝構造函數,根據x2.o_p復制一個新的O對象。
由于x2在后續代碼中可能還會被使用,所以為了避免影響x2,在賦值時調用O的拷貝構造函數復制一個新的O對象給x1在這種場景下是沒問題的。
但在某些場景下,這種拷貝顯得比較多余:
X foo() {
return X();
};
X x1;
x1 = foo();
代碼輸出與之前一樣:
call o constructor
call o constructor
call o copy constructor
在這個場景下,foo()創建的那個臨時X對象在后續代碼是不會被用到的。所以我們不需要擔心賦值函數中會不會影響到那個臨時X對象,沒必要去復制一個新的O對象給x1。
更高效的做法,是直接使用swap交換臨時X對象的o_p和x1.o_p。這樣做有兩個好處:1. 不用調用耗時的O拷貝構造函數,提高效率;2. 交換后,臨時X對象擁有之前x1.o_p指向的資源,在析構時能自動回收,避免內存泄漏。
這種避免高昂的復制成本,而直接將資源從一個對象"移動"到另外一個對象的行為,就是C++的移動語義。
哪些場景適用移動操作呢?無法獲取內存地址的右值就很合適,我們不需要擔心后續的代碼會用到該右值。
最后,我們看下移動版本的賦值函數
X& operator=(X&& rhs) noexcept {
std::swap(this->o_p, rhs.o_p);
return *this;
};
看下使用效果:
X x1;
x1 = foo();
輸出結果:
call o constructor
call o constructor
右值引用一定是右值嗎?
假設我們有以下代碼:
class X {
public:
// 復制版本的賦值函數
X& operator=(const X& rhs);
// 移動版本的賦值函數
X& operator=(X&& rhs) noexcept;
};
void foo(X&& x) {
X x1;
x1 = x;
}
類X重載了復制版本和移動版本的賦值函數。現在問題是:x1=x這個賦值操作調用的是X& operator=(const X& rhs)還是 X& operator=(X&& rhs)?
針對這種情況,C++給出了相關的標準:
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.
也就是說,只要一個右值引用有名稱,那對應的變量就是一個左值,否則,就是右值。
回到上面的例子,函數foo的入參雖然是右值引用,但有變量名x,所以x是一個左值,所以operator=(const X& rhs)最終會被調用。
再給一個沒有名字的右值引用的例子
X bar();
// 調用X& operator=(X&& rhs),因為bar()返回的X對象沒有關聯到一個變量名上
X x = bar();
這么設計的原因也挺好理解。再改下foo函數的邏輯:
void foo(X&& x) {
X x1;
x1 = x;
...
std::cout << *(x.inner_ptr) << std::endl;
}
我們并不能保證在foo函數的后續邏輯中不會訪問到x的資源。所以這種情況下如果調用的是移動版本的賦值函數,x的內部資源在完成賦值后就亂了,無法保證后續的正常訪問。
std::move
反過來想,如果我們明確知道在x1=x后,不會再訪問到x,那有沒有辦法強制走移動賦值函數呢?
C++提供了std::move函數,這個函數做的工作很簡單: 通過隱藏掉入參的名字,返回對應的右值。
X bar();
X x1
// ok. std::move(x1)返回右值,調用移動賦值函數
X x2 = std::move(x1);
// ok. std::move(bar())與 bar()效果相同,返回右值,調用移動賦值函數
X x3 = std::move(bar());
最后,用一個容易犯錯的例子結束這一環節
class Base {
public:
// 拷貝構造函數
Base(const Base& rhs);
// 移動構造函數
Base(Base&& rhs) noexcept;
};
class Derived : Base {
public:
Derived(Derived&& rhs)
// wrong. rhs是左值,會調用到 Base(const Base& rhs).
// 需要修改為Base(std::move(rhs))
: Base(rhs) noexcept {
...
}
}
返回值優化
依照慣例,還是先給出類X的定義
class X {
public:
// 構造函數
X() {
std::cout << "call x constructor" <<std::endl;
};
// 拷貝構造函數
X(const X& rhs) {
std::cout << "call x copy constructor" << std::endl;
};
// 移動構造函數
X(X&& rhs) noexcept {
std::cout << "call x move constructor" << std::endl
};
}
大家先思考下以下兩個函數哪個性能比較高?
X foo() {
X x;
return x;
};
X bar() {
X x;
return std::move(x);
}
很多讀者可能會覺得foo需要一次復制行為:從x復制到返回值;bar由于使用了std::move,滿足移動條件,所以觸發的是移動構造函數:從x移動到返回值。復制成本 > 移動成本,所以bar性能更好。
實際效果與上面的推論相反,bar中使用std::move反倒多余了。現代C++編譯器會有返回值優化。換句話說,編譯器將直接在foo返回值的位置構造x對象,而不是在本地構造x然后將其復制出去。很明顯,這比在本地構造后移動效率更快。
以下是foo和bar的輸出:
// foo
call x constructor
// bar
call x constructor
call x move constructor
移動需要保證異常安全
細心的讀者可能已經發現了,在前面的幾個小節中,移動構造/賦值函數我都在函數簽名中加了關鍵字noexcept,這是向調用者表明,我們的移動函數不會拋出異常。
這點對于移動函數很重要,因為移動操作會對右值造成破壞。如果移動函數中發生了異常,可能會對程序造成不可逆的錯誤。以下面為例
class X {
public:
int* int_p;
O* o_p;
X(X&& rhs) {
std::swap(int_p, rhs.int_p);
...
其他業務操作
...
std::swap(o_p, rhs.o_p);
}
}
如果在「其他業務操作」中發生了異常,不僅會影響到本次構造,rhs內部也已經被破壞了,后續無法重試構造。所以,除非明確標識noexcept,C++在很多場景下會慎用移動構造。
比較經典的場景是std::vector 擴縮容。當vector由于push_back、insert、reserve、resize 等函數導致內存重分配時,如果元素提供了一個noexcept的移動構造函數,vector會調用該移動構造函數將元素移動到新的內存區域;否則,則會調用拷貝構造函數,將元素復制過去。
總結
今天我們主要學了C++中右值引用的相關概念和應用場景,并花了很大篇幅講解移動語義及其相關實現。
右值引用主要解決實現移動語義和完美轉發的問題。我們下節接著講解右值是如何實現完美轉發。歡迎關注,及時收到推送~
總結
以上是生活随笔為你收集整理的【重学C++】04 | 说透C++右值引用(上)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Tomcat 部署两个工程时,另一个访问
- 下一篇: Java面试(一) -- 基础部分(1