?
?移動(dòng)語義是從C++11開始引入的一項(xiàng)全新功能。本文將為您撥開云霧,讓您對移動(dòng)語義有個(gè)全面而深入的理解,希望本文對你理解移動(dòng)語義提供一點(diǎn)經(jīng)驗(yàn)和指導(dǎo)。
一、為什么要有移動(dòng)語義
(一)從拷貝說起
我們知道,C++中有拷貝構(gòu)造函數(shù)和拷貝賦值運(yùn)算符。那既然是拷貝,聽上去就是開銷很大的操作。沒錯(cuò),所謂拷貝,就是申請一塊新的內(nèi)存空間,然后將數(shù)據(jù)復(fù)制到新的內(nèi)存空間中。如果一個(gè)對象中都是一些基本類型的數(shù)據(jù)的話,由于數(shù)據(jù)量很小,那執(zhí)行拷貝操作沒啥毛病。但如果對象中涉及其他對象或指針數(shù)據(jù)的話,那么執(zhí)行拷貝操作就可能會(huì)是一個(gè)很耗時(shí)的過程。
我們來看一個(gè)例子。假設(shè)我們有個(gè)類,該類中有一個(gè)string類型的成員變量,定義如下:
?
class MyClass{public: MyClass(const std::string& s) : str{ s } {}; private: std::string str;}; MyClass A{ "hello" };
?
當(dāng)我們新建一個(gè)該類的對象A,并傳遞參數(shù)“hello”時(shí),對象A的成員變量str中會(huì)存儲字符串“hello”。而為了存儲字符串,string類型會(huì)為其分配內(nèi)存空間。因此,當(dāng)前內(nèi)存中的數(shù)據(jù)如圖所示:
現(xiàn)在,當(dāng)我們定義了一個(gè)該類的新對象B,且把對象A賦值給對象B時(shí),會(huì)發(fā)生什么?即,我們執(zhí)行如下語句:
?
MyClass B = A;
?
當(dāng)拷貝發(fā)生時(shí),為了讓B對象中的成員變量str也能夠存儲字符串“hello”,string類型會(huì)為其分配內(nèi)存空間,并將對象A的str中存儲的數(shù)據(jù)復(fù)制過來。因此,經(jīng)過拷貝操作后,此時(shí)內(nèi)存中的數(shù)據(jù)如圖所示:
這個(gè)拷貝操作無可厚非,畢竟我們希望A對象和B對象是完全獨(dú)立無關(guān)的對象,對B對象的修改不會(huì)影響A對象,反之亦然。
(二)需要移動(dòng)語義的情況
既然拷貝操作沒毛病,那為什么要新增移動(dòng)語義呢。因?yàn)樵谝恍┣闆r下,我們可能確實(shí)不需要拷貝操作。考慮下面一個(gè)例子:
?
class MyClass{public: MyClass(const std::string& s) : str{ s } {}; private: std::string str;}; std::vectormyClasses;MyClass tmp{ "hello" };myClasses.push_back(tmp);myClasses.push_back(tmp);
?
在這個(gè)例子中,我們創(chuàng)建了一個(gè)容器以及一個(gè)MyClass對象tmp,我們將tmp對象添加到容器中2次。每次添加時(shí),都會(huì)發(fā)生一次拷貝操作。最終內(nèi)存中的數(shù)據(jù)如圖所示:
現(xiàn)在問題來了,tmp對象在被添加到容器中2次之后,就不需要了,也就是說,它的生命期即將結(jié)束。那么聰明的你一定想到了,既然tmp對象不再需要了,那么第2次將其添加到容器中的操作是不是就可以不執(zhí)行拷貝操作了,而是讓容器直接取tmp對象的數(shù)據(jù)繼續(xù)用。沒錯(cuò),這時(shí),就需要移動(dòng)語義帥氣登場了!
(三)移動(dòng)語義帥氣登場
所謂移動(dòng)語義,就像其字面意思一樣,即把數(shù)據(jù)從一個(gè)對象中轉(zhuǎn)移到另一個(gè)對象中,從而避免拷貝操作所帶來的性能損耗。
那么在上面的例子中,我們?nèi)绾斡|發(fā)移動(dòng)語義呢?很簡單,我們只需要使用std::move函數(shù)即可。有關(guān)std::move函數(shù),就是另一個(gè)話題了,這里我們不深入探討。我們只需要知道,通過std::move函數(shù),我們可以告知編譯器,某個(gè)對象不再需要了,可以把它的數(shù)據(jù)轉(zhuǎn)移給其他需要的對象用。
我們來改造下之前的例子:
?
class MyClass{public: MyClass(const std::string& s) : str{ s } {}; // 假設(shè)已經(jīng)實(shí)現(xiàn)了移動(dòng)語義 private: std::string str;}; std::vectormyClasses;MyClass tmp{ "hello" };myClasses.push_back(tmp);myClasses.push_back(std::move(tmp)); // 看這里
?
由于我們還沒講到移動(dòng)語義的實(shí)現(xiàn),因此這里先假設(shè)MyClass類已經(jīng)實(shí)現(xiàn)了移動(dòng)語義。我們改動(dòng)的是最后一行代碼,由于我們不再需要tmp對象,因此通過使用std::move函數(shù),我們讓myClasses容器直接轉(zhuǎn)移tmp對象的數(shù)據(jù)為已用,而不再需要執(zhí)行拷貝操作了。
通過數(shù)據(jù)轉(zhuǎn)移,我們避免了一次拷貝操作,最終內(nèi)存中的數(shù)據(jù)如圖所示:
至此,我們可以了解到,C++11引入移動(dòng)語義可以在不需要拷貝操作的場合執(zhí)行數(shù)據(jù)轉(zhuǎn)移,從而極大的提升程序的運(yùn)行性能。
二、移動(dòng)語義的實(shí)現(xiàn)
在了解了為什么要有移動(dòng)語義之后,接著我們就該來看看它該如何實(shí)現(xiàn)。
(一)左值引用與右值引用
在學(xué)習(xí)如何實(shí)現(xiàn)移動(dòng)語義之前,我們需要先了解2個(gè)概念,即“左值引用”與“右值引用”。
為了支持移動(dòng)語義,C++11引入了一種新的引用類型,稱為“右值引用”,使用“&&”來聲明。而我們最常使用的,使用“&”聲明的引用,現(xiàn)在則稱為“左值引用”。
右值引用能夠引用沒有名稱的臨時(shí)對象以及使用std::move標(biāo)記的對象:
?
int val{ 0 };int&& rRef0{ getTempValue() }; // OK,引用臨時(shí)對象int&& rRef1{ val }; // Error,不能引用左值int&& rRef2{ std::move(val) }; // OK,引用使用std::move標(biāo)記的對象
?
移動(dòng)語義的實(shí)現(xiàn)需要用到右值引用,我們在后文會(huì)詳細(xì)的說。現(xiàn)在我們需要知道,以下2種情況會(huì)讓編譯器將對象匹配為右值引用:
一個(gè)在語句執(zhí)行完畢后就會(huì)被自動(dòng)銷毀的臨時(shí)對象;
由std::move標(biāo)記的非const對象。
讓編譯器將對象匹配為右值引用,是一切的基礎(chǔ)。
(二)區(qū)分拷貝操作與移動(dòng)操作
我們回到上文的例子,對于myClasses容器的第一次push_back,我們期望執(zhí)行的是拷貝操作,而對于myClasses容器的第二次push_back,由于之后我們不再需要tmp對象了,因此我們期望執(zhí)行的是移動(dòng)操作:
?
class MyClass{public: MyClass(const std::string& s) : str{ s } {}; // 假設(shè)已經(jīng)實(shí)現(xiàn)了移動(dòng)語義 private: std::string str;}; std::vectormyClasses;MyClass tmp{ "hello" };myClasses.push_back(tmp); // 這里執(zhí)行拷貝操作,將tmp中的數(shù)據(jù)拷貝給容器中的元素myClasses.push_back(std::move(tmp)); // 這里執(zhí)行移動(dòng)操作,容器中的元素直接將tmp的數(shù)據(jù)轉(zhuǎn)移給自己
?
現(xiàn)在我們已經(jīng)知道,移動(dòng)操作執(zhí)行的是對象數(shù)據(jù)的轉(zhuǎn)移,那么它一定是與拷貝操作不一樣的。因此,為了能夠?qū)⒖截惒僮髋c移動(dòng)操作區(qū)分執(zhí)行,就需要用到我們上一節(jié)的主題:左值引用與右值引用。
因此,對于容器的push_back函數(shù)來說,它一定針對拷貝操作和移動(dòng)操作有不同的重載實(shí)現(xiàn),而重載用到的即是左值引用與右值引用。偽代碼如下:
?
class vector{public: void push_back(const MyClass& value) // const MyClass& 左值引用 { // 執(zhí)行拷貝操作 } void push_back(MyClass&& value) // MyClass&& 右值引用 { // 執(zhí)行移動(dòng)操作 }};
?
通過傳遞左值引用或右值引用,我們就能夠根據(jù)需要調(diào)用不同的push_back重載函數(shù)了。那么下一個(gè)問題來了,我們知道std::vector是模板類,可以用于任意類型。所以,std::vector不可能自己去實(shí)現(xiàn)拷貝操作或移動(dòng)操作,因?yàn)樗恢雷约簳?huì)用在哪些類型上。因此,std::vector真正做的,是委托具體類型自己去執(zhí)行拷貝操作與移動(dòng)操作。
(三)移動(dòng)構(gòu)造函數(shù)
當(dāng)通過push_back向容器中添加一個(gè)新的元素時(shí),如果是通過拷貝的方式,那么對應(yīng)執(zhí)行的會(huì)是容器元素類型的拷貝構(gòu)造函數(shù)。關(guān)于拷貝構(gòu)造函數(shù),它是C++一直以來都包含的功能,相信大家已經(jīng)很熟悉了,因此在這里就不展開了。
當(dāng)通過push_back向容器中添加一個(gè)新的元素時(shí),如果是通過移動(dòng)的方式,那么對應(yīng)執(zhí)行的會(huì)是容器元素類型的“移動(dòng)構(gòu)造函數(shù)”(敲黑板,劃重點(diǎn))。
移動(dòng)構(gòu)造函數(shù)是C++11引入的一種新的構(gòu)造函數(shù),它接收右值引用。以我們前文的MyClass例子來說,為其定義移動(dòng)構(gòu)造函數(shù):
?
class MyClass{public: // 移動(dòng)構(gòu)造函數(shù) MyClass(MyClass&& rValue) noexcept // 關(guān)于noexcept我們稍后會(huì)介紹 : str{ std::move(rValue.str) } // 看這里,調(diào)用std::string類型的移動(dòng)構(gòu)造函數(shù) {} MyClass(const std::string& s) : str{ s } {} private: std::string str;};
?
在移動(dòng)構(gòu)造函數(shù)中,我們要做的就是轉(zhuǎn)移成員數(shù)據(jù)。我們的MyClass有一個(gè)std::string類型的成員,該類型自身實(shí)現(xiàn)了移動(dòng)語義,因此我們可以繼續(xù)調(diào)用std::string類型的移動(dòng)構(gòu)造函數(shù)。
在有了移動(dòng)構(gòu)造函數(shù)之后,我們就可以在需要時(shí)通過它來創(chuàng)建新的對象,從而避免拷貝操作的開銷。以如下代碼為例:
?
MyClass tmp{ "hello" };MyClass A{ std::move(tmp) }; // 調(diào)用移動(dòng)構(gòu)造函數(shù)
?
首先我們創(chuàng)建了一個(gè)tmp對象,接著我們通過tmp對象來創(chuàng)建A對象,此時(shí)傳遞給構(gòu)造函數(shù)的參數(shù)為std::move(tmp)。還記得我們前文提及的編譯器匹配右值引用的情況之一嘛,即由std::move標(biāo)記的非const對象,因此編譯器會(huì)調(diào)用執(zhí)行移動(dòng)構(gòu)造函數(shù),我們就完成了將tmp對象的數(shù)據(jù)轉(zhuǎn)移到對象A上的操作:
(四)自己動(dòng)手實(shí)現(xiàn)移動(dòng)語義
在前文的MyClass例子中,我們將移動(dòng)操作交由std::string類型去完成。那如果我們的類有成員數(shù)據(jù)需要我們自己去實(shí)現(xiàn)數(shù)據(jù)轉(zhuǎn)移的話,通常該怎么做呢?
我們來舉個(gè)例子,假設(shè)我們定義的類型中包含了一個(gè)int類型的數(shù)據(jù)以及一個(gè)char*類型的指針:
?
class MyClass{public: MyClass() : val{ 998 } { name = new char[] { "Peter" }; } ~MyClass() { if (nullptr != name) { delete[] name; name = nullptr; } } private: int val; char* name;}; MyClass A{};
?
當(dāng)我們創(chuàng)建一個(gè)MyClass的對象A時(shí),它在內(nèi)存中的布局如圖所示:
現(xiàn)在我們來為MyClass類型實(shí)現(xiàn)移動(dòng)構(gòu)造函數(shù),代碼如下所示:
?
class MyClass{public: MyClass() : val{ 998 } { name = new char[] { "Peter" }; } // 實(shí)現(xiàn)移動(dòng)構(gòu)造函數(shù) MyClass(MyClass&& rValue) noexcept : val{ std::move(rValue.val) } // 轉(zhuǎn)移數(shù)據(jù) { rValue.val = 0; // 清除被轉(zhuǎn)移對象的數(shù)據(jù) name = rValue.name; // 轉(zhuǎn)移數(shù)據(jù) rValue.name = nullptr; // 清除被轉(zhuǎn)移對象的數(shù)據(jù) } ~MyClass() { if (nullptr != name) { delete[] name; name = nullptr; } } private: int val; char* name;}; MyClass A{};MyClass B{ std::move(A) }; // 通過移動(dòng)構(gòu)造函數(shù)創(chuàng)建新對象B
?
還記得移動(dòng)語義的精髓嘛?數(shù)據(jù)拿過來用就完事兒了。因此,在移動(dòng)構(gòu)造函數(shù)中,我們將傳入對象A的數(shù)據(jù)轉(zhuǎn)移給新創(chuàng)建的對象B。同時(shí),還需要關(guān)注的重點(diǎn)在于,我們需要把傳入對象A的數(shù)據(jù)清除,不然就會(huì)產(chǎn)生多個(gè)對象共享同一份數(shù)據(jù)的問題。被轉(zhuǎn)移數(shù)據(jù)的對象會(huì)處于“有效但未定義(valid but unspecified)”的狀態(tài)(后文會(huì)介紹)。
通過移動(dòng)構(gòu)造函數(shù)創(chuàng)建對象B之后,內(nèi)存中的布局如圖所示:
(五)移動(dòng)賦值運(yùn)算符
與拷貝構(gòu)造函數(shù)和拷貝賦值運(yùn)算符一樣,除了移動(dòng)構(gòu)造函數(shù)之外,C++11還引入了移動(dòng)賦值運(yùn)算符。移動(dòng)賦值運(yùn)算符也是接收右值引用,它的實(shí)現(xiàn)和移動(dòng)構(gòu)造函數(shù)基本一致。在移動(dòng)賦值運(yùn)算符中,我們也是從傳入的對象中轉(zhuǎn)移數(shù)據(jù),并將該對象的數(shù)據(jù)清除:
?
class MyClass{public: MyClass() : val{ 998 } { name = new char[] { "Peter" }; } MyClass(MyClass&& rValue) noexcept : val{ std::move(rValue.val) } { rValue.val = 0; name = rValue.name; rValue.name = nullptr; } // 移動(dòng)賦值運(yùn)算符 MyClass& operator=(MyClass&& myClass) noexcept { val = myClass.val; myClass.val = 0; name = myClass.name; myClass.name = nullptr; return *this; } ~MyClass() { if (nullptr != name) { delete[] name; name = nullptr; } } private: int val; char* name;}; MyClass A{};MyClass B{};B = std::move(A); // 使用移動(dòng)賦值運(yùn)算符將對象A賦值給對象B
?
三、移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符
? ? ? ? ? ? ? ? ? ?的生成規(guī)則
在C++11之前,我們擁有4個(gè)特殊成員函數(shù),即構(gòu)造函數(shù)、析構(gòu)函數(shù)、拷貝構(gòu)造函數(shù)以及拷貝賦值運(yùn)算符。從C++11開始,我們多了2個(gè)特殊成員函數(shù),即移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符。
本節(jié)將介紹移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符的生成規(guī)則。
(一)deleted functions
在細(xì)說移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符的生成規(guī)則之前,我們先要說一說“已刪除的函數(shù)(deleted functions)”。
在C++11中,可以使用語法=delete;來將函數(shù)定義為“已刪除”。任何使用“已刪除”函數(shù)的代碼都會(huì)產(chǎn)生編譯錯(cuò)誤:
?
class MyClass{public: void Test() = delete;}; MyClass value;value.Test(); // 編譯錯(cuò)誤:attempting to reference a deleted function
?
在之后的介紹中,我們需要關(guān)注到的點(diǎn)是在特定情況下,編譯器會(huì)將移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符定義為deleted。
現(xiàn)在讓我們進(jìn)入主題,正式開始吧。
(二)默認(rèn)情況下,我們擁有一切
我們知道,在C++11之前,如果我們定義一個(gè)空類,編譯器會(huì)自動(dòng)為我們生成構(gòu)造函數(shù)、析構(gòu)函數(shù)、拷貝構(gòu)造函數(shù)以及拷貝賦值運(yùn)算符。該特性在移動(dòng)語義上得以延伸。在C++11之后,如果我們定義一個(gè)空類,除了之前的4個(gè)特殊成員函數(shù),編譯器還會(huì)為我們生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符:
?
class MyClass{}; MyClass A{}; // OK,執(zhí)行編譯器默認(rèn)生成的構(gòu)造函數(shù)MyClass B{ A }; // OK,執(zhí)行編譯器默認(rèn)生成的拷貝構(gòu)造函數(shù)MyClass C{ std::move(A) }; // OK,執(zhí)行編譯器默認(rèn)生成的移動(dòng)構(gòu)造函數(shù)
?
(三)當(dāng)我們定義了拷貝操作之后
如果我們在類中定義了拷貝構(gòu)造函數(shù)或者拷貝賦值運(yùn)算符,那么編譯器就不會(huì)自動(dòng)生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符。此時(shí),如果調(diào)用移動(dòng)語義的話,由于編譯器沒有自動(dòng)生成,因此會(huì)轉(zhuǎn)而執(zhí)行拷貝操作:
?
class MyClass{public: MyClass() {} // 我們定義了拷貝構(gòu)造函數(shù),這會(huì)禁止編譯器自動(dòng)生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符 MyClass(const MyClass& value) {}}; MyClass A{};MyClass B{ std::move(A) }; // 執(zhí)行的是拷貝構(gòu)造函數(shù)來創(chuàng)建對象B
?
(四)析構(gòu)函數(shù)登場
析構(gòu)函數(shù)的情況和定義拷貝操作一致,如果我們在類中定義了析構(gòu)函數(shù),那么編譯器也不會(huì)自動(dòng)生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符。此時(shí),如果調(diào)用移動(dòng)語義的話,同樣會(huì)轉(zhuǎn)而執(zhí)行拷貝操作:
?
class MyClass{public: // 我們定義了析構(gòu)函數(shù),這會(huì)禁止編譯器自動(dòng)生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符 ~MyClass() {}}; MyClass A{};MyClass B{ std::move(A) }; // 執(zhí)行的是拷貝構(gòu)造函數(shù)來創(chuàng)建對象B
?
析構(gòu)函數(shù)有一點(diǎn)值得注意,許多情況下,當(dāng)一個(gè)類需要作為基類時(shí),都需要聲明一個(gè)virtual析構(gòu)函數(shù),此時(shí)需要特別留意是不是應(yīng)該手動(dòng)的為該類定義移動(dòng)構(gòu)造函數(shù)以及移動(dòng)賦值運(yùn)算符。此外,當(dāng)子類派生時(shí),如果子類沒有實(shí)現(xiàn)自己的析構(gòu)函數(shù),那么將不會(huì)影響移動(dòng)構(gòu)造函數(shù)以及移動(dòng)賦值運(yùn)算符的自動(dòng)生成:
?
class MyBaseClass{public: virtual ~MyBaseClass() {}}; class MyClass : MyBaseClass // 子類沒有實(shí)現(xiàn)自己的析構(gòu)函數(shù){}; MyClass A{};MyClass B{ std::move(A) }; // 這里將執(zhí)行編譯器自動(dòng)生成的移動(dòng)構(gòu)造函數(shù)
?
(五)移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符的相互影響
如果我們在類中定義了移動(dòng)構(gòu)造函數(shù),那么編譯器就不會(huì)為我們自動(dòng)生成移動(dòng)賦值運(yùn)算符。反之,如果我們在類中定義了移動(dòng)賦值運(yùn)算符,那么編譯器也不會(huì)為我們自動(dòng)生成移動(dòng)構(gòu)造函數(shù)。
之前我們提到,如果我們在類中定義了拷貝構(gòu)造函數(shù)、拷貝賦值運(yùn)算符或者析構(gòu)函數(shù),那么編譯器不會(huì)為我們生成移動(dòng)構(gòu)造函數(shù)與移動(dòng)賦值運(yùn)算符。此時(shí)如果執(zhí)行移動(dòng)語義,會(huì)轉(zhuǎn)而執(zhí)行拷貝操作。但這里不同,以移動(dòng)構(gòu)造函數(shù)為例,如果我們定義了移動(dòng)構(gòu)造函數(shù),那么編譯器不會(huì)為我們自動(dòng)生成移動(dòng)賦值運(yùn)算符,此時(shí),移動(dòng)賦值運(yùn)算符的調(diào)用并不會(huì)轉(zhuǎn)而執(zhí)行拷貝賦值運(yùn)算符,而是會(huì)產(chǎn)生編譯錯(cuò)誤:
?
class MyClass{public: MyClass() {} // 我們定義了移動(dòng)構(gòu)造函數(shù),這會(huì)禁止編譯器自動(dòng)生成移動(dòng)賦值運(yùn)算符,并且對移動(dòng)賦值運(yùn)算符的調(diào)用會(huì)產(chǎn)生編譯錯(cuò)誤 MyClass(MyClass&& rValue) noexcept {}}; MyClass A{};MyClass B{};B = std::move(A); // 對移動(dòng)賦值運(yùn)算符的調(diào)用產(chǎn)生編譯錯(cuò)誤:attempting to reference a deleted function
?
通過編譯器的報(bào)錯(cuò)信息我們可以推斷,如果我們定義了移動(dòng)構(gòu)造函數(shù),那么移動(dòng)賦值運(yùn)算符會(huì)被編譯器定義為“已刪除的函數(shù)”,反之,如果我們定義了移動(dòng)賦值運(yùn)算符,那么移動(dòng)構(gòu)造函數(shù)也會(huì)被編譯器定義為“已刪除的函數(shù)”。
(六)小結(jié)
通過以上的介紹說明,我們對移動(dòng)構(gòu)造函數(shù)以及移動(dòng)賦值運(yùn)算符的自動(dòng)生成以及可用性有了理解和掌握。我們現(xiàn)在將其整理為表格,從而能夠更加清晰而全面的一覽無遺:
四、noexcept
在前文我們實(shí)現(xiàn)移動(dòng)構(gòu)造函數(shù)以及移動(dòng)賦值運(yùn)算符時(shí),我們使用了noexcept說明符。本節(jié)我們就來聊聊何為noexcept。
(一)為什么需要noexcept
為了說明為什么需要noexcept,我們還是從一個(gè)例子出發(fā)。我們定義MyClass類,并且我們先不對MyClass類的移動(dòng)構(gòu)造函數(shù)使用noexcept:
?
class MyClass{public: MyClass() {} MyClass(const MyClass& lValue) { std::cout << "拷貝構(gòu)造函數(shù)" << std::endl; } MyClass(MyClass&& rValue) // 注意這里,我們沒有對移動(dòng)構(gòu)造函數(shù)使用noexcept { std::cout << "移動(dòng)構(gòu)造函數(shù)" << std::endl; } private: std::string str{ "hello" };};
?
接著,我們創(chuàng)建一個(gè)MyClass的對象A,并且將其往classes容器中添加2次:
?
MyClass A{};std::vectorclasses;classes.push_back(A);classes.push_back(A);
?
現(xiàn)在,我們來梳理一下流程。classes容器在定義時(shí)默認(rèn)會(huì)申請1個(gè)元素的內(nèi)存空間。當(dāng)?shù)?次執(zhí)行classes.push_back(A);時(shí),對象A會(huì)被拷貝到容器第1個(gè)元素的位置:
當(dāng)?shù)?次執(zhí)行classes.push_back(A);時(shí),由于classes容器已沒有多余的內(nèi)存空間,因此它需要分配一塊新的內(nèi)存空間。在分配新的內(nèi)存空間之后,classes容器會(huì)做2個(gè)操作:將對象A拷貝到容器第2個(gè)元素的位置,以及將之前的元素放到新的內(nèi)存空間中容器第1個(gè)元素的位置:
細(xì)心的小伙伴一定發(fā)現(xiàn)了,如上圖所示那般,老的元素是被拷貝到新的內(nèi)存空間中的。是的,classes容器確實(shí)使用的是拷貝構(gòu)造函數(shù)。那么此時(shí)我們會(huì)想到,既然classes容器已經(jīng)不需要之前的內(nèi)存中的數(shù)據(jù)了,那么將老數(shù)據(jù)放到新的內(nèi)存空間中應(yīng)該使用移動(dòng)語義,而非拷貝操作。
那么為什么classes容器沒有使用移動(dòng)語義呢?
此時(shí),我們需要提及一個(gè)概念,即“強(qiáng)異常保證(strong exception guarantee)”。所謂強(qiáng)異常保證,即當(dāng)我們調(diào)用一個(gè)函數(shù)時(shí),如果發(fā)生了異常,那么應(yīng)用程序的狀態(tài)能夠回滾到函數(shù)調(diào)用之前:
那么強(qiáng)異常保證和決定使用移動(dòng)語義或拷貝操作又有什么關(guān)系呢?
這是因?yàn)槿萜鞯膒ush_back函數(shù)是具備強(qiáng)異常保證的,也就是說,當(dāng)push_back函數(shù)在執(zhí)行操作的過程中(由于內(nèi)存不足需要申請新的內(nèi)存、將老的元素放到新內(nèi)存中等),如果發(fā)生了異常(內(nèi)存空間不足無法申請等),push_back函數(shù)需要確保應(yīng)用程序的狀態(tài)能夠回滾到調(diào)用它之前。以上面的例子來說,當(dāng)?shù)?次執(zhí)行classes.push_back(A);時(shí),如果發(fā)生了異常,應(yīng)用程序的狀態(tài)會(huì)回滾到第1次執(zhí)行classes.push_back(A);之后,即classes容器中只有一個(gè)元素。
由于我們的移動(dòng)構(gòu)造函數(shù)沒有使用noexcept說明符,也就是我們沒有保證移動(dòng)構(gòu)造函數(shù)不會(huì)拋出異常。因此,為了確保強(qiáng)異常保證,就只能使用拷貝構(gòu)造函數(shù)了。那么拷貝構(gòu)造函數(shù)同樣沒有保證不會(huì)拋出異常,為什么就能用呢?這是因?yàn)榭截悩?gòu)造函數(shù)執(zhí)行之后,被拷貝對象的原始數(shù)據(jù)是不會(huì)丟失的。因此,即使發(fā)生異常需要回滾,那些已經(jīng)被拷貝的對象仍然完整且有效。但移動(dòng)語義就不同了,被移動(dòng)對象的原始數(shù)據(jù)是會(huì)被清除的,因此如果發(fā)生異常,那些已經(jīng)被移動(dòng)的對象的數(shù)據(jù)就沒有了,找不回來了,也就無法完成狀態(tài)回滾了。
(二)為移動(dòng)語義使用noexcept說明符
在了解了以上的規(guī)則后,我們就清楚了,要想使用移動(dòng)構(gòu)造函數(shù)來將老的元素放到新的內(nèi)存中,我們就需要告知編譯器,我們的移動(dòng)構(gòu)造函數(shù)不會(huì)拋出異常,可以放心使用,這就是通過noexcept說明符完成的。
我們來修改下MyClass類的移動(dòng)構(gòu)造函數(shù),為其加上noexcept說明符:
?
class MyClass{public: MyClass() {} MyClass(const MyClass& lValue) { std::cout << "拷貝構(gòu)造函數(shù)" << std::endl; } MyClass(MyClass&& rValue) noexcept // 注意這里,為移動(dòng)構(gòu)造函數(shù)使用noexcept { std::cout << "移動(dòng)構(gòu)造函數(shù)" << std::endl; } private: std::string str{ "hello" };};
?
現(xiàn)在,我們再次執(zhí)行上文的例子,會(huì)發(fā)現(xiàn)使用的是移動(dòng)構(gòu)造函數(shù)來創(chuàng)建新的內(nèi)存中的元素了:
關(guān)于noexcept說明符,是個(gè)龐大的話題,這里我們只是粗略的提及和移動(dòng)語義有關(guān)的部分。值得注意的是,noexcept說明符是我們對于不會(huì)拋出異常的保證,如果在執(zhí)行的過程中有異常被拋出了,應(yīng)用程序?qū)?huì)直接終止執(zhí)行。
五、使用移動(dòng)語義時(shí)需要注意的其他內(nèi)容
在最后一節(jié),我們聊聊與移動(dòng)語義相關(guān)的一些額外內(nèi)容。
(一)編譯器生成的移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符
前文我們提及,在特定情況下,編譯器會(huì)為我們自動(dòng)生成移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符。在自動(dòng)生成的函數(shù)中,編譯器執(zhí)行的是逐成員的移動(dòng)語義。
假設(shè)我們的類包含一個(gè)int類型和一個(gè)std::string類型的成員:
?
class MyClass{private: int val; std::string str;};
?
那么編譯器為我們自動(dòng)生成的移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值運(yùn)算符類似于如下所示:
?
class MyClass{public: // 編譯器自動(dòng)生成的移動(dòng)構(gòu)造函數(shù)類似這樣,執(zhí)行逐成員的移動(dòng)語義 MyClass(MyClass&& rValue) noexcept : val{ std::move(rValue.val) } , str{ std::move(rValue.str) } {} // 編譯器自動(dòng)生成的移動(dòng)賦值運(yùn)算符類似這樣,執(zhí)行逐成員的移動(dòng)語義 MyClass& operator=(MyClass&& rValue) noexcept { val = std::move(rValue.val); str = std::move(rValue.str); return *this; } private: int val; std::string str;};
?
了解編譯器自動(dòng)生成的移動(dòng)構(gòu)造函數(shù)以及移動(dòng)賦值運(yùn)算符之后,我們就會(huì)對何時(shí)應(yīng)該自己去實(shí)現(xiàn)有個(gè)很清晰的認(rèn)識。
(二)被移動(dòng)對象的狀態(tài)
當(dāng)一個(gè)對象被移動(dòng)之后,該對象仍然是有效的,你可以繼續(xù)使用它,最終它會(huì)被銷毀,執(zhí)行析構(gòu)函數(shù)。
C++在其文檔中表明,所有標(biāo)準(zhǔn)庫中的對象,當(dāng)被移動(dòng)之后,會(huì)處于一個(gè)“有效但未定義的狀態(tài)(valid but unspecified state)”。
C++并沒有強(qiáng)制的規(guī)定限制被移動(dòng)對象必須處于什么狀態(tài),并且當(dāng)類型需要滿足不同用途時(shí)它的要求也不一致(例如用于key的類型要求被移動(dòng)對象仍然能夠進(jìn)行排序),因此我們在實(shí)現(xiàn)自己的類型時(shí)需要根據(jù)具體情況來分析。但通常來說,我們應(yīng)該盡可能的貼近C++標(biāo)準(zhǔn)庫中的類型規(guī)范。但不管如何,以下這一點(diǎn)是我們必須考慮的:
保證被移動(dòng)對象能夠被正確的析構(gòu)。
為什么必須保證這一點(diǎn)呢?這是因?yàn)楸灰苿?dòng)對象只是處于一個(gè)特殊的狀態(tài),對于運(yùn)行時(shí)來說,仍然是有效的,最終也會(huì)執(zhí)行析構(gòu)函數(shù)進(jìn)行銷毀。
例如在之前我們的MyClass類型定義中,當(dāng)我們執(zhí)行移動(dòng)語義后,被移動(dòng)對象的name指針會(huì)被置空。當(dāng)執(zhí)行析構(gòu)函數(shù)時(shí),我們就可以簡單的通過判空來避免指針的無效釋放,這就確保了被移動(dòng)對象能夠正確的析構(gòu):
?
?
?
class MyClass{public: MyClass() : val{ 998 } { name = new char[] { "Peter" }; } MyClass(MyClass&& rValue) noexcept : val{ std::move(rValue.val) } { rValue.val = 0; name = rValue.name; rValue.name = nullptr; // 置空被移動(dòng)對象的指針 } MyClass& operator=(MyClass&& myClass) noexcept { val = myClass.val; myClass.val = 0; name = myClass.name; myClass.name = nullptr; // 置空被移動(dòng)對象的指針 return *this; } ~MyClass() { if (nullptr != name) // 通過判空來避免指針的無效釋放 { delete[] name; name = nullptr; } } private: int val; char* name;};
?
(三)避免非必要的std::move調(diào)用
在C++中,存在稱為“NRVO(named return value optimization,命名返回值優(yōu)化)”的技術(shù),即如果函數(shù)返回一個(gè)臨時(shí)對象,則該對象會(huì)直接給函數(shù)調(diào)用方使用,而不會(huì)再創(chuàng)建一個(gè)新對象。聽起來有點(diǎn)晦澀,我們來看一個(gè)例子:
?
class MyClass{}; MyClass GetTemporary(){ MyClass A{}; return A;} MyClass myClass = GetTemporary(); // 注意這里
?
在上面的例子中,GetTemporary函數(shù)會(huì)創(chuàng)建一個(gè)臨時(shí)的MyClass對象A,接著在函數(shù)結(jié)束時(shí)返回。在沒有NRVO的情況下,當(dāng)執(zhí)行語句MyClass myClass=GetTemporary();時(shí),會(huì)調(diào)用MyClass類的拷貝構(gòu)造函數(shù),通過對象A來拷貝創(chuàng)建myClass對象。因此,整個(gè)流程如圖所示:
我們可以發(fā)現(xiàn),在創(chuàng)建完myClass對象之后,對象A就被銷毀了,這無疑是一種浪費(fèi)。因此,編譯器會(huì)啟用NRVO,直接讓myClass對象使用對象A。這樣一來,在整個(gè)過程中,我們只有一次創(chuàng)建對象A時(shí)構(gòu)造函數(shù)的調(diào)用開銷,省去了拷貝構(gòu)造函數(shù)以及析構(gòu)函數(shù)的調(diào)用開銷:
為NRVO點(diǎn)贊!
此時(shí),可能有細(xì)心的小伙伴已經(jīng)發(fā)現(xiàn)了,這種返回臨時(shí)對象的情況不就是移動(dòng)語義發(fā)揮的場景嘛。沒錯(cuò),機(jī)智的你是不是會(huì)想到如下的修改:
?
MyClass GetTemporary(){ MyClass A{}; return std::move(A); // 使用移動(dòng)語義}
?
這樣一來,通過移動(dòng)語義,即使沒有NRVO,也可以避免拷貝操作。乍看上去沒啥毛病,但我們忽略了一種情況,那就是返回的對象類型并沒有實(shí)現(xiàn)移動(dòng)語義。
讓我們來分析一下這種情況,我們改寫一下MyClass類:
?
class MyClass{public: ~MyClass() // 注意這里,通過聲明析構(gòu)函數(shù),我們禁止了編譯器去實(shí)現(xiàn)默認(rèn)移動(dòng)構(gòu)造函數(shù) {}};
?
現(xiàn)在,MyClass類型沒有實(shí)現(xiàn)移動(dòng)語義,當(dāng)我們執(zhí)行語句MyClass myClass=GetTemporary();時(shí),編譯器沒有辦法調(diào)用移動(dòng)構(gòu)造函數(shù)來創(chuàng)建myClass對象。同時(shí),遺憾的是,由于std::move(A)返回的類型是MyClass&&,與函數(shù)的返回類型MyClass不一致,因此編譯器也不會(huì)使用NRVO。最終,編譯器只能調(diào)用拷貝構(gòu)造函數(shù)來創(chuàng)建myClass對象。
因此,當(dāng)返回局部對象時(shí),我們不用畫蛇添足,直接返回對象即可,編譯器會(huì)優(yōu)先使用最佳的NRVO,在沒有NRVO的情況下,會(huì)嘗試執(zhí)行移動(dòng)構(gòu)造函數(shù),最后才是開銷最大的拷貝構(gòu)造函數(shù)。
六、總結(jié)
本文向您闡述了C++中的移動(dòng)語義,從緣由、定義到實(shí)現(xiàn),以及其他的一些相關(guān)細(xì)節(jié)內(nèi)容。相信您在看完本文后對C++的移動(dòng)語義會(huì)有更加全面而深刻的認(rèn)識,可以向媽媽匯報(bào)了!
評論