声明及赋值_重述《Effective C++》二——构造、析构、赋值运算
關于本專欄,請看為什么寫這個專欄。如果你想閱讀帶有條款目錄的文章,歡迎訪問我的主頁。
構造和析構一方面是對象的誕生和終結;另一方面,它們也意味著資源的開辟和歸還。這些操作犯錯誤會導致深遠的后果——你需要產生和銷毀的每一個對象都面臨著風險。這些函數形成了一個自定義類的脊柱,所以如何確保這些函數的行為正確是“生死攸關”的大事。
條款05:了解C++默默編寫并調用了哪些函數
編譯器會主動為你編寫的任何類聲明一個拷貝構造函數、拷貝復制操作符和一個析構函數,同時如果你沒有生命任何構造函數,編譯器也會為你聲明一個default版本的拷貝構造函數,這些函數都是public且inline的。注意,上邊說的是聲明哦,只有當這些函數有調用需求的時候,編譯器才會幫你去實現它們。但是編譯器替你實現的函數可能在類內引用、類內指針、有const成員以及類型有虛屬性的情形下會出問題。
- 對于拷貝構造函數,你要考慮到類內成員有沒有深拷貝的需求,如果有的話就需要自己編寫拷貝構造函數/操作符,而不是把這件事情交給編譯器來做。
- 對于拷貝構造函數,如果類內有引用成員或const成員,你需要自己定義拷貝行為,因為在上述兩個場景中編譯器替你實現的拷貝行為很有可能是有問題的。
- 對于析構函數,如果該類有多態需求,請主動將析構函數聲明為virtual,具體請看條款07 。
除了這些特殊的場景以外,如果不是及其簡單的類型,請自己編寫好構造、析構、拷貝構造和賦值操作符、移動構造和賦值操作符(C++11、如有必要)這六個函數。
條款06:若不想使用編譯器自動生成的函數,就該明確拒絕。
承接上一條款,如果你的類型在語義或功能上需要明確禁止某些函數的調用行為,比如禁止拷貝行為,那么你就應該禁止編譯器去自動生成它。作者在這里給出了兩種方案來實現這一目標:
- 將被禁止生成的函數聲明為private并省略實現,這樣可以禁止來自類外的調用。但是如果類內不小心調用了(成員函數、友元),那么會得到一個鏈接錯誤。
- 將上述的可能的鏈接錯誤轉移到編譯期間。設計一不可拷貝的工具基類,將真正不可拷貝的基類私有繼承該基類型即可,但是這樣的做法過于復雜,對于已經有繼承關系的類型會引入多繼承,同時讓代碼晦澀難懂。
但是有了C++11,我們可以直接使用= delete來聲明拷貝構造函數,顯示禁止編譯器生成該函數。
條款07:為多態基類聲明virtual
該條款的核心內容為:帶有多態性質的基類必須將析構函數聲明為虛函數,防止指向子類的基類指針在被釋放時只局部銷毀了該對象。如果一個類有多態的內涵,那么幾乎不可避免的會有基類的指針(或引用)指向子類對象,因為非虛函數沒有動態類型,所以如果基類的析構函數不是虛函數,那么在基類指針析構時會直接調用基類的析構函數,造成子類對象僅僅析構了基類的那一部分,有內存泄漏的風險。除此之外,還需注意:
- 需要注意的是,普通的基類無需也不應該有虛析構函數,因為虛函數無論在時間還是空間上都會有代價,詳情《More Effective C++》條款24。
- 如果一個類型沒有被設計成基類,又有被誤繼承的風險,請在類中聲明為final(C++ 11),這樣禁止派生可以防止誤繼承造成上述問題。
- 編譯器自動生成的析構函數時非虛的,所以多態基類必須將析構函數顯示聲明為virtual。
條款08:別讓異常逃離析構函數
析構函數一般情況下不應拋出異常,因為很大可能發生各種未定義的問題,包括但不限于內存泄露、程序異常崩潰、所有權被鎖死等。
一個直觀的解釋:析構函數是一個對象生存期的最后一刻,負責許多重要的工作,如線程,連接和內存等各種資源所有權的歸還。如果析構函數執行期間某個時刻拋出了異常,就說明拋出異常后的代碼無法再繼續執行,這是一個非常危險的舉動——因為析構函數往往是為類對象兜底的,甚至是在該對象其他地方出現任何異常的時候,析構函數也有可能會被調用來給程序擦屁股。在上述場景中,如果在一個異常環境中執行的析構函數又拋出了異常,很有可能會讓程序直接崩潰,這是每一個程序員都不想看到的。
話說回來,如果某些操作真的很容易拋出異常,如歸還資源等,并且你又不想把異常吞掉,那么就請把這些操作移到析構函數之外,提供一個普通函數做類似的清理工作,在析構函數中只負責記錄日志,我們需要時刻保證析構函數能夠執行到底。
條款09:絕不在構造和析構過程中調用virtual函數。
結論正如該條款的名字:請不要在構造函數和析構函數中調用virtual函數。
在多態環境中,我們需要重新理解構造函數和析構函數的意義,這兩個函數在執行過程中,涉及到了對象類型從基類到子類,再從子類到基類的轉變。
一個子類對象開始創建時,首先調用的是基類的構造函數,在調用子類構造函數之前,該對象將一直保持著“基類對象”的身份而存在,自然在基類的構造函數中調用的虛函數——將會是基類的虛函數版本,在子類的構造函數中,原先的基類對象變成了子類對象,這時子類構造函數里調用的是子類的虛函數版本。這是一件有意思的事情,這說明在構造函數中虛函數并不是虛函數,在不同的構造函數中,調用的虛函數版本并不同,因為隨著不同層級的構造函數調用時,對象的類型在實時變化。那么相似的,析構函數在調用的過程中,子類對象的類型從子類退化到基類。
因此,如果你指望在基類的構造函數中調用子類的虛函數,那就趁早打消這個想法好了。但很遺憾的是,你可能并沒有意識到自己做出了這樣的設計,例如將構造函數的主要工作抽象成一個init()函數以防止不同構造函數的代碼重復是一個很常見的做法,但是在init()函數中是否調用了虛函數,就有待考證了,同樣的情況在析構函數中也是一樣。
條款10:令operator =返回一個reference to *this
簡單來說:這樣做可以讓你的賦值操作符實現“連等”的效果:
x = y = z = 10;在設計接口時一個重要的原則是,讓自己的接口和內置類型相同功能的接口盡可能相似,所以如果沒有特殊情況,就請讓你的賦值操作符的返回類型為ObjectClass&類型并在代碼中返回*this吧。
條款11:在operator=中處理“自我賦值”
自我賦值指的是將自己賦給自己。這是一種看似愚蠢無用但卻在代碼中出現次數比任何人想象得都多的操作,這種操作常常披著指針的外衣:
*pa = *pb; //pa和pb指向同一對象,便是自我賦值。arr[i] = arr[j]; //i和j相等,便是自我賦值那么對于管理一定資源的對象重載的operator = 中,一定要對是不是自我賦值格外小心并且增加預判,因為無論是深拷貝還是資源所有權的轉移,原先的內存或所有權一定會被清空才能被賦值,如果不加處理,這套邏輯被用在自我賦值上會發生——先把自己的資源給釋放掉了,然后又把以釋放掉的資源賦給了自己——出錯了。
第一種做法是在賦值前增加預判,但是這種做法沒有異常安全性,試想如果在刪除掉原指針指向的內存后,在賦值之前任何一處跑出了異常,那么原指針就指向了一塊已經被刪除的內存。
SomeClass& SomeClass::operator=(const SomeClass& rhs) {if (this == &rhs) return *this;delete ptr; ptr = new DataBlock(*rhs.ptr); //如果此處拋出異常,ptr將指向一塊已經被刪除的內存。return *this;}如果我們把異常安全性也考慮在內,那么我們就會得到如下方法,令人欣慰的是這個方法也解決了自我賦值的問題。
SomeClass& SomeClass::operator=(const SomeClass& rhs) {DataBlock* pOrg = ptr;ptr = new DataBlock(*rhs.ptr); //如果此處拋出異常,ptr仍然指向之前的內存。delete pOrg;return *this;}另一個使用copy and swap技術的替代方案將在條款29中作出詳細解釋。
條款12:復制對象時勿忘其每一個成分
所謂“每一個成分”,作者在這里其實想要提醒大家兩點:
- 當你給類新增了成員變量時,請不要忘記在拷貝構造函數和賦值操作符中對新加的成員變量進行處理。如果你忘記處理,編譯器也不會報錯。
- 如果你的類有繼承,那么在你為子類編寫拷貝構造函數時一定要格外小心復制基類的每一個成分,這些成分往往是private的,所以你無法訪問它們,你應該讓子類使用子類的拷貝構造函數去調用相應基類的拷貝構造函數。
除此之外,拷貝構造函數和拷貝賦值操作符,他們兩個中任意一個不要去調用另一個,這雖然看上去是一個避免代碼重復好方法,但是是荒謬的。其根本原因在于拷貝構造函數在構造一個對象——這個對象在調用之前并不存在;而賦值操作符在改變一個對象——這個對象是已經構造好了的。因此前者調用后者是在給一個還未構造好的對象賦值;而后者調用前者就像是在構造一個已經存在了的對象。不要這么做!
總結
以上是生活随笔為你收集整理的声明及赋值_重述《Effective C++》二——构造、析构、赋值运算的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 鞋子怎样找厂家拿货 进货渠道其实很多
- 下一篇: 失败的windows系统服务调用read