C++特殊成員函數(shù)以及其生成機制詳解
前言
在C++中,特殊成員函數(shù)指的是那些編譯器在需要時會自動生成的成員函數(shù)。C++98中有四種特殊的成員函數(shù),分別是默認構(gòu)造函數(shù)、析構(gòu)函數(shù)、拷貝構(gòu)造函數(shù)和拷貝賦值運算符。而在C++11中,隨著移動語義的引入,移動構(gòu)造函數(shù)和移動賦值運算符也加入了特殊成員函數(shù)的大家庭。本文主要基于Klaus Iglberger在CppCon 2021上發(fā)表的主題演講Back To Basics: The Special Member Fuctions以及Scott Meyers的著作Effective Modern C++中的條款17,向大家介紹這六種特殊成員函數(shù)的特點以及它們的生成機制。
默認構(gòu)造函數(shù)
當(dāng)且僅當(dāng)以下條件成立時,編譯器會生成一個默認構(gòu)造函數(shù):
- 沒有顯式聲明的構(gòu)造函數(shù)
- 所有的數(shù)據(jù)成員和基類都擁有自己的默認構(gòu)造函數(shù)
如果用戶聲明了自己的構(gòu)造函數(shù),那么編譯器就不會再去生成一個默認構(gòu)造函數(shù);如果用戶沒有聲明構(gòu)造函數(shù),但是類中包含了一個沒有默認構(gòu)造函數(shù)的數(shù)據(jù)成員,那么編譯器也不會生成默認構(gòu)造函數(shù)。
數(shù)據(jù)成員初始化
編譯器生成默認構(gòu)造函數(shù)會初始化所有類類型的數(shù)據(jù)成員,但是并不會初始化基礎(chǔ)類型的數(shù)據(jù)成員。以下面的代碼為例,第六行代碼會調(diào)用默認構(gòu)造函數(shù)將成員變量s
初始化為空字符串,但是并不會初始化整型成員變量i以及指針pi。
struct Widget { int i; std::string s; int* pi; }; int main() { Widget w1; // Default initialization Widget w2{}; // Vaule initialization return 0; }
如果我們想同時初始化所有的成員變量,可以使用值初始化,只需在聲明對象時添加一對大括號即可,見上述代碼第8行。如果沒有聲明默認構(gòu)造函數(shù),值初始化會zero-initialize整個對象,然后default-initializes所有non-trivial的數(shù)據(jù)成員。以上面的代碼為例,使用值初始化后,i被初始化為0,s仍然被初始化為空字符串,而pi被初始化為nullptr。如果用戶聲明了默認構(gòu)造函數(shù),那么值初始化就會按照用戶聲明來完成初始化操作。
通過默認構(gòu)造函數(shù),我們可以初始化類中的數(shù)據(jù)成員。但是需要注意賦值和初始化的區(qū)別。在下面的代碼中,我們實現(xiàn)了兩個默認構(gòu)造函數(shù)(僅僅為了說明賦值和初始化的區(qū)別,不代表類中能夠?qū)崿F(xiàn)兩個默認構(gòu)造函數(shù))。在第一個默認構(gòu)造函數(shù)中,所有的成員在函數(shù)體內(nèi)執(zhí)行賦值操作。對于基礎(chǔ)類型來說還好,但是對于類類型或者std::string這種,一次賦值操作帶來的開銷要比初始化的開銷大。而第二個默認構(gòu)造函數(shù)使用了成員初始化列表,每次操作都是初始化,所以它的開銷會更低,性能也更好。
struct Widget { Widget() { i = 42; // Assignment, not initialization s = "CppCon"; // Assignment, not initialization pi = nullptr; // Assignment, not initialization } Widget() : i{42} // Initializing to 42 , s{"CppCon"} // Initializing to "CppCon" , pi{} // Initializing to nullptr {} int i; std::string s; int* pi; };
對于數(shù)據(jù)成員的初始化,C++ Core Guideline定義了兩條規(guī)則。首先,我們要按照數(shù)據(jù)成員在類中的定義順序來初始化數(shù)據(jù)成員;其次,盡量在構(gòu)造函數(shù)中使用初始化而非賦值。
Core Guideline C.47: Define and initialize member variables in the order of member declaration.
Core Guideline C.49: Prefer initialization to assignment in constructors.
析構(gòu)函數(shù)
當(dāng)用戶沒有顯式聲明析構(gòu)函數(shù)時,編譯器會生成一個析構(gòu)函數(shù)。編譯器生成的析構(gòu)函數(shù)會調(diào)用類類型成員變量的析構(gòu)函數(shù),但是不會對基礎(chǔ)類型的成員變量執(zhí)行任何操作。如果類中含有指針類型的成員變量,那么編譯器生成的析構(gòu)函數(shù)就有可能導(dǎo)致資源泄露,因為編譯器生成的析構(gòu)函數(shù)并不會釋放掉指針所指向的那些資源。
因此,如果類中的數(shù)據(jù)成員擁有某些外部資源的所有權(quán),我們就需要實現(xiàn)一個析構(gòu)函數(shù)來正確釋放掉相關(guān)資源。如果確實沒有啥資源需要手動釋放,那么也不要寫一個空的析構(gòu)函數(shù),最好是讓編譯器生成或者將析構(gòu)函數(shù)定義成=default。
拷貝操作
我們首先來看一下拷貝構(gòu)造函數(shù)和拷貝賦值運算符的函數(shù)簽名。一般來說,拷貝構(gòu)造函數(shù)的形參是一個常量左值引用,極少數(shù)情況下是一個非常量左值引用,但不可能是一個對象的拷貝,因為這會導(dǎo)致遞歸調(diào)用。對于拷貝賦值運算符,它的形參也是一個常量左值引用,極少數(shù)情況下是非常量左值引用,也有可能是一個對象的拷貝,因為拷貝賦值運算符可以通過拷貝構(gòu)造函數(shù)實現(xiàn),所以這種形參是合法的。
// copy constructor Widget(const Weidget&); // The default Widget(Widget&); // Possible, but very likely not reasonable Widget(Widget); // Not possible, recursive call // copy assignment operator Widget& operator=(const Widget&); // The default Widget& operator=(Widget&); // Possible, but very likely not reasonable Widget& operator=(Widget); // Reasonable, builds on the copy constructor
當(dāng)且僅當(dāng)以下條件成立時,編譯器會生成拷貝操作:
- 不存在顯式聲明的拷貝操作
- 不存在顯式聲明的移動操作
- 所有的成員變量都能夠被拷貝構(gòu)造或拷貝賦值
拷貝構(gòu)造函數(shù)和拷貝賦值運算符的生成是獨立的:聲明了其中一個,并不會阻止編譯器生成另一個。如果用戶聲明了拷貝構(gòu)造函數(shù),但是沒有聲明拷貝賦值運算符,同時又編寫了要求拷貝賦值的代碼,那么編譯器就會自動生成拷貝賦值運算符,反之亦然。
編譯器生成的拷貝操作默認會按成員進行拷貝。對于指針類型的數(shù)據(jù)成員,如果執(zhí)行按成員拷貝,那么就只會拷貝成員的值,也就是拷貝指針的值。這樣一來,就會有兩個對象指向同一塊資源。當(dāng)其中一個對象被析構(gòu)以后,資源會被釋放,另一個對象中的指針就成了懸掛指針(Dangling Pointer)。當(dāng)這個對象被析構(gòu)時,它所指向的資源就會被析構(gòu)兩次,內(nèi)存的重復(fù)釋放會導(dǎo)致嚴重的錯誤。為了解決此問題,我們需要在拷貝構(gòu)造函數(shù)和拷貝賦值運算符中執(zhí)行深拷貝操作,也就是要拷貝指針指向的那一塊資源。
struct Widget { Widget(Wiget& other) noexcept : Base{other} , i{other.i} , s{other.s} , pr{other.pr ? new Resource(*ohter.pr) : nullptr} {} Widget& operator=(Widget&& other) { deleter pr; // cleanup current resource Base::operator=(std::move(other)); i = other.i; s = other.s; pr = other.pr ? new Resource{*other.pr} : nullptr; return *this; } int i; std::string s; Resource* pr{}; };
注意在上述代碼的拷貝賦值運算符中,我們首先刪除了當(dāng)前對象所指向的資源,然后再執(zhí)行相關(guān)的拷貝操作。然而,這會導(dǎo)致程序不能正確處理self-assignment的情況。形如Widget w{}; w = w;這樣的代碼就會釋放掉對象w指向的資源,從而導(dǎo)致程序發(fā)生錯誤。幸運的是,我們可以用copy-and-swap的思想,通過一個臨時對象和swap函數(shù)來解決此問題。臨時對象在退出作用域是會自動調(diào)用析構(gòu)函數(shù),所以我們就不用擔(dān)心資源泄漏的問題。
Widget& operator=(const Widget& other) { Widget tmp(other); swap(tmp); return *this; } void swap(Widget& other) { std::swap(id, other.id); std::swap(name, other.name); std::swap(pr, other.pr); }
這種做法的好處就是安全,代碼能正確處理self-assignment的情況,但它的缺點就是性能比較一般。
移動操作
我們首先來看一下移動構(gòu)造函數(shù)和移動賦值運算符的函數(shù)簽名。一般來說,移動構(gòu)造函數(shù)和移動賦值運算符的形參都是一個右值引用,帶有const的形參是合法的,但是非常少見,一般也不會遇到。
// move constructor Widget(Widget&&) noexcept; // The default Widget(const Widget&&) noexcept // Possible, but uncommon // move assignment operator Widget& operator=(Widget&&) noexcept; // The default Widget& operator=(const Widget&&) noexcept // Possible, but uncommon
當(dāng)且僅當(dāng)以下條件成立時,編譯器會生成移動操作:
- 不存在顯式聲明的移動操作
- 不存在顯式聲明的析構(gòu)函數(shù)和拷貝操作
- 所有的數(shù)據(jù)成員都是可以被拷貝或移動
移動構(gòu)造函數(shù)和移動賦值運算符的生成并不獨立:聲明了其中一個,編譯器就不會生成另一個。這樣做的原因是,如果用戶聲明了一個移動構(gòu)造函數(shù),那么這就表明移動操作的行為將會與編譯器所生成的移動構(gòu)造函數(shù)不一致。而若是按成員進行的移動操作有不合理之處,那么按成員移動的賦值運算符極有可能同樣有不合理之處。因此,聲明移動構(gòu)造函數(shù)會阻止編譯器生成移動賦值運算符,反之亦然。
與拷貝操作類似,編譯器生成的移動操作默認會按成員進行移動。顯然,如果數(shù)據(jù)成員是一個指針類型,那么按成員移動同樣將會導(dǎo)致懸掛指針。所以,對于包含指針類型的類,我們需要按照下面的方式實現(xiàn)移動構(gòu)造函數(shù)和移動賦值運算符,其中std::exchange(a, b)
的作用是用b的值去替換a的值并返回a的舊值。
struct Widget { Widget(Wiget&& other) noexcept : Base{std::move(other)} , i{std::move(other.i)} , s{std::move(other.s)} , pr{std::exchange(other.pr, {})} {} Widget& operator=(Widget&& other) { deleter pr; Base::operator=(std::move(other)); i = std::move(other.i); s = std::move(other.s); pr = std::exchange(other.pr, {}); } int i; std::string s; Resource* pr{}; };
然而,上面這種實現(xiàn)方式同樣無法處理self-assignment的問題。雖然移動一個對象到它本身是一件非常奇怪的事情,一般也不會有人去寫這種代碼,但是作為類的提供者,我們必須要盡量考慮到所有可能出現(xiàn)的情況。對于self-assignment這個問題,我們可以借助copy-and-swap思想,利用一個臨時對象來解決,代碼如下。
Widget& operator=(Widget&& other) noexcept { Widget tmp(std::move(other)); swap(tmp); return *this; } ~Widget() { delete pr; }
使用原生指針來管理資源會讓我們的代碼寫起來比較困難和繁瑣。如果我們用智能指針替換掉原生指針,那么代碼寫起來將會容易很多。如果我們使用unique_ptr替換掉上例中的原生指針,因為unique_ptr只能被移動不能被拷貝,所以我們只需要實現(xiàn)拷貝構(gòu)造函數(shù)和拷貝賦值運算符(如果我們真的需要拷貝操作的話),并將默認構(gòu)造函數(shù)、析構(gòu)函數(shù)和移動操作聲明為=default即可。如果我們使用shared_ptr,那么連拷貝操作也不用寫了,六個特殊成員函數(shù)群都定義成=default就完事了,不過shared_ptr會改變整個類的語義,因為所有的指針都會指向同一個資源,所以在用它的時候要多加小心。C++ Core Guideline就指出,盡量用unique_ptr而非shared_ptr,除非你是真的想共享資源的所有權(quán)。
Core Guideline R.21: Prefer unique_ptr over shared_ptr unless you need to share ownership.
最后,我們再來看下C++ Core Guideline中的The Rule of Zero以及The Rule of Five。這兩條規(guī)則的意思非常簡單,就是說我們在定義一個類的時候,如果能避免定義所有的默認操作,那就盡量不定義;如果定義或刪除了某個默認操作,那么就定義或刪除所有的默認操作。
Core Guideline C.20: If you can avoid defining default operation, do (aka The Rule of Zero).
Core Guideline C.21: If you define or =delete any default operation, define or =delete them all (aka The Rule of Five).
總結(jié)
到此這篇關(guān)于C++特殊成員函數(shù)以及其生成機制的文章就介紹到這了,更多相關(guān)C++特殊成員函數(shù)及生成機制內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
c++ std::invalid_argument應(yīng)用
想研究std::invalid_argument的朋友可以參考下2013-01-01優(yōu)先隊列(priority_queue)的C語言實現(xiàn)代碼
本文簡要介紹一種基于數(shù)組二叉堆實現(xiàn)的優(yōu)先隊列,定義的數(shù)據(jù)結(jié)構(gòu)和實現(xiàn)的函數(shù)接口說明如下2013-10-10Cocos2d-x學(xué)習(xí)筆記之Hello World!
這篇文章主要介紹了Cocos2d-x學(xué)習(xí)筆記之Hello World!本文基于vs2010和C++語言開發(fā),需要的朋友可以參考下2014-09-09