個人覺得最核心的變化是右值引用的引入,右值引用是 ?C++ 走向現(xiàn)代化的最重要一步。建議每一位 C++ 開發(fā)者都應(yīng)該深入去了解并充分使用它。
右值引用是 C++11 中最重要的新特性之一,它解決了 C++ 中大量的歷史遺留問題,使 C++ 標準庫的實現(xiàn)在多種場景下消除了不必要的額外開銷(如?std::vector,?std::string),也使得另外一些標準庫(如?std::unique_ptr,?std::function)成為可能。即使你并不直接使用右值引用,也可以通過標準庫,間接從這一新特性中受益。為了更好地理解標準庫結(jié)合右值引用帶來的優(yōu)化,我們有必要了解一下右值引用的重大意義。
右值引用的意義通常解釋為兩大作用:移動語義和完美轉(zhuǎn)發(fā)。本文主要討論移動語義。
一、移動語義
移動語義,簡單來說解決的是各種情形下對象的資源所有權(quán)轉(zhuǎn)移的問題。而在 C++11 之前,移動語義的缺失是 C++ 飽受詬病的問題之一。
舉個例子。
問題一:如何將大象放入冰箱?
答案是眾所周知的。首先你需要有一臺特殊的冰箱,這臺冰箱是為了裝下大象而制造的。你打開冰箱門,將大象放入冰箱,然后關(guān)上冰箱門。
問題二:如何將大象從一臺冰箱轉(zhuǎn)移到另一臺冰箱?
普通解答:
打開冰箱門,取出大象,關(guān)上冰箱門,打開另一臺冰箱門,放進大象,關(guān)上冰箱門。
2B 解答:
在第二個冰箱中啟動量子復制系統(tǒng),克隆一只完全相同的大象,然后啟動高能激光將第一個冰箱內(nèi)的大象氣化消失。
等等,這個 2B 解答聽起來很耳熟,這不就是 C++ 中要移動一個對象時所做的事情嗎?
“移動”,這是一個三歲小孩都明白的概念。將大象(資源)從一臺冰箱(對象)移動到另一臺冰箱,這個行為是如此自然,沒有任何人會采用先復制大象,再銷毀大象這樣匪夷所思的方法。C++ 通過拷貝構(gòu)造函數(shù)和拷貝賦值操作符為類設(shè)計了拷貝/復制的概念,但為了實現(xiàn)對資源的移動操作,調(diào)用者必須使用先復制、再析構(gòu)的方式。否則,就需要自己實現(xiàn)移動資源的接口。
為了實現(xiàn)移動語義,首先需要解決的問題是,如何標識對象的資源是可以被移動的呢?這種機制必須以一種最低開銷的方式實現(xiàn),并且對所有的類都有效。C++ 的設(shè)計者們注意到,大多數(shù)情況下,右值所包含的對象都是可以安全的被移動的。
右值(相對應(yīng)的還有左值)是從 C 語言設(shè)計時就有的概念,但因為其如此基礎(chǔ),也是一個最常被忽略的概念。不嚴格的來說,左值對應(yīng)變量的存儲位置,而右值對應(yīng)變量的值本身。C++ 中右值可以被賦值給左值或者綁定到引用。類的右值是一個臨時對象,如果沒有被綁定到引用,在表達式結(jié)束時就會被廢棄。于是我們可以在右值被廢棄之前,移走它的資源進行廢物利用,從而避免無意義的復制。被移走資源的右值在廢棄時已經(jīng)成為空殼,析構(gòu)的開銷也會降低。
右值中的數(shù)據(jù)可以被安全移走這一特性使得右值被用來表達移動語義。以同類型的右值構(gòu)造對象時,需要以引用形式傳入參數(shù)。右值引用顧名思義專門用來引用右值,左值引用和右值引用可以被分別重載,這樣確保左值和右值分別調(diào)用到拷貝和移動的兩種語義實現(xiàn)。對于左值,如果我們明確放棄對其資源的所有權(quán),則可以通過std::move()來將其轉(zhuǎn)為右值引用。std::move()實際上是?static_cast
右值引用至少可以解決以下場景中的移動語義缺失問題:
1.按值傳入?yún)?shù)
按值傳參是最符合人類思維的方式?;镜乃悸肥牵绻麄魅?yún)?shù)是為了將資源交給函數(shù)接受者,就應(yīng)該按值傳參。同時,按值傳參可以兼容任何的?cv-qualified?左值、右值,是兼容性最好的方式。
class?People?{ public: ????//?按值傳入字符串,可接收左值、右值。 ????//?接收左值時為復制,接收右值時為移動 ????People(string?name)? ????????:?name_(move(name))?//?顯式移動構(gòu)造,將傳入的字符串移入成員變量 ????{ ????} ????string?name_; }; People?a("Alice");?//?移動構(gòu)造name string?bn?=?"Bob"; People?b(bn);?//?拷貝構(gòu)造name
構(gòu)造a時,調(diào)用了一次字符串的構(gòu)造函數(shù)和一次字符串的移動構(gòu)造函數(shù)。如果使用?const string& name?接收參數(shù),那么會有一次構(gòu)造函數(shù)和一次拷貝構(gòu)造,以及一次?non-trivial?的析構(gòu)。盡管看起來很蛋疼,盡管編譯器還有優(yōu)化,但從語義來說按值傳入?yún)?shù)是最優(yōu)的方式。
如果你要在構(gòu)造函數(shù)中接收?std::shared_ptr
2.按值返回
和接收輸入?yún)?shù)一樣,返回值按值返回也是最符合人類思維的方式。曾經(jīng)有無數(shù)函數(shù)為了返回容器而不得不寫成這樣:
//?一個按值語義定義的字符串拆分函數(shù) void?str_split(const?string&?s,?vector*?vec);?
這里不考慮分隔符,假定分隔符是固定的。這樣要求 vec在外部被事先構(gòu)造,此時尚無從得知vec的大小。即使函數(shù)內(nèi)部有辦法預測vec的大小,因為函數(shù)并不負責構(gòu)造vec,很可能仍需要?resize`。
對這樣的函數(shù)嵌套調(diào)用更是痛苦的事情,誰用誰知道啊。
有了移動語義,就可以寫成這樣:
vector?str_split(const?string&?s)?{ ????vector ?v; ????//?... ????return?v;?// v是左值,但優(yōu)先移動,不支持移動時仍可復制。 }
如果函數(shù)按值返回,return 語句又直接返回了一個棧上的左值對象(輸入?yún)?shù)除外)時,標準要求優(yōu)先調(diào)用移動構(gòu)造函數(shù),如果不符再調(diào)用拷貝構(gòu)造函數(shù)。盡管 ``v是左值,仍然會優(yōu)先采用移動語義,返回?vector` 從此變得云淡風輕。此外,無論移動或是拷貝,可能的情況下仍然適用編譯器優(yōu)化,但語義不受影響。
對于?std::unique_ptr?來說,這簡直就是福音。
unique_ptr?create_obj(/*...*/)?{ ????unique_ptr ?ptr(new?SomeObj(/*...*/)); ????ptr->foo();?//?一些可能的初始化 ????return?ptr; }
當然還有更簡單的形式:
unique_ptr?create_obj(/*...*/)?{ ????return?unique_ptr (new?SomeObj(/*...*/)); }
在工廠類中,這樣的語義是非常常見的。返回?unique_ptr?能夠明確對所構(gòu)造對象的所有權(quán)轉(zhuǎn)移,特別的,這樣的工廠類返回值可以被忽略而不會造成內(nèi)存泄露。上面兩種形式分別返回棧上的左值和右值,但都適用移動語義(unique_ptr?不支持拷貝)。
3.接收右值表達式
沒有移動語義時,以表達式的值(例為函數(shù)調(diào)用)初始化對象或者給對象賦值是這樣的:
vector?str_split(const?string&?s); //?返回的vector用以拷貝構(gòu)造對象v。為v申請堆內(nèi)存,復制數(shù)據(jù),然后析構(gòu)臨時對象(釋放堆內(nèi)存)。 vector ?v?=?str_split("1,2,3");? vector ?v2; //?返回的vector被復制給對象v(拷貝賦值操作符)。需要先清理v2中原有數(shù)據(jù),將臨時對象中的數(shù)據(jù)復制給v2,然后析構(gòu)臨時對象。 v2?=?str_split("1,2,3");?
注:v?的拷貝構(gòu)造調(diào)用有可能被優(yōu)化掉,盡管如此在語義上仍然是有一次拷貝操作。
同樣的代碼,在支持移動語義的世界里就變得更美好了。
vector?str_split(const?string&?s); //?返回的vector用以移動構(gòu)造對象v。v直接取走臨時對象的堆上內(nèi)存,無需新申請。之后臨時對象成為空殼,不再擁有任何資源,析構(gòu)時也無需釋放堆內(nèi)存。 vector ?v?=?str_split("1,2,3");? vector ?v2; //?返回的vector被移動給對象v(移動賦值操作符)。先釋放v2原有數(shù)據(jù),然后直接從返回值中取走數(shù)據(jù),然后返回值被析構(gòu)。 v2?=?str_split("1,2,3");?
注:v?的移動構(gòu)造調(diào)用有可能被優(yōu)化掉,盡管如此在語義上仍然是有一次移動操作。
不用多說也知道上面的形式是多么常用和自然。而且這里完全沒有任何對右值引用的顯式使用,性能提升卻默默的實現(xiàn)了。
4.對象存入容器
這個問題和前面的構(gòu)造函數(shù)傳參是類似的。不同的是這里是按兩種引用分別傳參。參見?std::vector?的?push_back?函數(shù)。
void?push_back(?const?T&?value?);?//?(1) void?push_back(?T&&?value?);?//?(2)
不用多說自然是左值調(diào)用 1 右值調(diào)用 2。如果你要往容器內(nèi)放入超大對象,那么版本 2 自然是不 2 選擇。
vector>?vv; vector ?v?=?{"123",?"456"}; v.push_back("789");?//?臨時構(gòu)造的string類型右值被移動進容器v vv.push_back(move(v));?//?顯式將v移動進vv
困擾多年的難言之隱是不是一洗了之了?
5.std::vector 的增長
又一個隱蔽的優(yōu)化。當?vector?的存儲容量需要增長時,通常會重新申請一塊內(nèi)存,并把原來的內(nèi)容一個個復制過去并刪除。對,復制并刪除,改用移動就夠了。
對于像?vector
6.std::unique_ptr放入容器
曾經(jīng),由于?vector?增長時會復制對象,像?std::unique_ptr?這樣不可復制的對象是無法放入容器的。但實際上?vector?并不復制對象,而只是“移動”對象。所以隨著移動語義的引入,std::unique_ptr?放入?std::vector?成為理所當然的事情。
容器中存儲?std::unique_ptr?有太多好處。想必每個人都寫過這樣的代碼:
MyObj::MyObj()?{ ????for?(...)?{ ????????vec.push_back(new?T()); ????} ????//?... } MyObj::~MyObj()?{ ????for?(vector::iterator?iter?=?vec.begin();?iter?!=?vec.end();?++iter)?{ ????????if?(*iter)?delete?*iter; ????} ????//?... }
繁瑣暫且不說,異常安全也是大問題。使用?vector
unique_ptr?是非常輕量的封裝,存儲空間等價于裸指針,但安全性強了一個世紀。實際中需要共享所有權(quán)的對象(指針)是比較少的,但需要轉(zhuǎn)移所有權(quán)是非常常見的情況。auto_ptr?的失敗就在于其轉(zhuǎn)移所有權(quán)的繁瑣操作。unique_ptr?配合移動語義即可輕松解決所有權(quán)傳遞的問題。
注:如果真的需要共享所有權(quán),那么基于引用計數(shù)的?shared_ptr?是一個好的選擇。shared_ptr?同樣可以移動。由于不需要線程同步,移動?shared_ptr?比復制更輕量。
7.std::thread 的傳遞
thread 也是一種典型的不可復制的資源,但可以通過移動來傳遞所有權(quán)。同樣?std::future?std::promise?std::packaged_task?等等這一票多線程類都是不可復制的,也都可以用移動的方式傳遞。
二、完美轉(zhuǎn)發(fā)
除了移動語義,右值引用還解決了 C++03 中引用語法無法轉(zhuǎn)發(fā)右值的問題,實現(xiàn)了完美轉(zhuǎn)發(fā),才使得?std::function?能有一個優(yōu)雅的實現(xiàn)。這部分不再展開了。
三、總結(jié)
移動語義絕不是語法糖,而是帶來了 C++ 的深刻革新。移動語義不僅僅是針對庫作者的,任何一個程序員都有必要去了解它。盡管你可能不會去主動為自己的類實現(xiàn)移動語義,但卻時時刻刻都在享受移動語義帶來的受益。因此這絕不意味著這是一個可有可無的東西。
所以,如果你要寫出優(yōu)雅的 Modern C++ 代碼,應(yīng)該多使用右值引用,喜歡它,擁抱它。
編輯:黃飛
?
評論
查看更多