5. 類別(Classes) — Google C++ 開源專案風格指南

文章推薦指數: 80 %
投票人數:10人

類別是C++ 中程式碼的基本單元。

想當然爾,在程式中類別將被廣泛使用。

本節列舉了在撰寫一個類別時該做的和不該做的事項。

GoogleC++開源專案風格指南 中文版前言 1.背景 2.C++規範版本 3.標頭檔(HeaderFiles) 4.作用域(Scoping) 5.類別(Classes) 5.1.建構式的職責 5.2.隱式轉換(ImplicitConversions) 5.3.「可複製(copyable)」和「可移動(movable)」型別 5.4.結構(struct)vs.類別(class) 5.5.繼承 5.6.運算子多載化(OperatorOverloading) 5.7.存取控制 5.8.宣告順序 6.函式(Functions) 7.來自Google的奇技 8.其他C++特性 9.命名約定 10.註解 11.格式 12.規則特例 13.結語 GoogleC++開源專案風格指南 Docs» 5.類別(Classes) Viewpagesource 5.類別(Classes) 類別是C++中程式碼的基本單元。

想當然爾,在程式中類別將被廣泛使用。

本節列舉了在撰寫一個類別時該做的和不該做的事項。

5.1.建構式的職責 小訣竅 不要在建構式中呼叫虛擬函式。

如果你沒辦法發出錯誤信號的話,避免進行可能會失敗的初始化。

定義: 在建構式中,你可以進行任何的初始化行為。

優點: 你不用擔心該類別是否已經被初始化了。

在建構式完全初始化的物件,可以宣告為const,而且也比較容易在標準容器或演算法中使用這樣的物件。

缺點: 如果在建構式內呼叫了自身的虛擬函式,不會導向到子類別的實作版本。

即使當前沒有子類別覆寫實作,將來不小心加上時不會有任何錯誤訊息,還是會帶來困擾。

沒有簡單的方法可以從建構式中發出錯誤信號,除了讓程式當掉(有時不怎麼恰當)或是使用例外處理(exceptipns)(但我們禁用例外處理)。

如果建構式執行失敗,就會產生一個初始化不完全的物件,導致我們得用類似boolIsValid()的函式來檢查物件是否可用。

但一般人很容易忘掉這件事。

你沒有辦法取得建構式的位址,所以不管你在建構式裡做了什麼,你都沒辦法輕易將結果傳到其他地方(例如另一個執行緒)。

結論: 建構式不得呼叫虛擬函式。

如果可以的話,在建構式中發生問題時直接結束整個程式。

否則,考慮使用工廠函式(factoryfunction)或Init()方法(如TotW#42所示)。

若是物件沒有公開的方法可以得知其可用狀態,避免建立Init()方法,因為這種分段建構的物件很容易發生錯誤。

5.2.隱式轉換(ImplicitConversions) 小訣竅 不要定義隱式轉換。

使用加上explicit關鍵字的轉換運算子,以及只有一個參數的建構式。

定義: 隱式轉換指的是把某個型別(來源型別)的物件用在需要另一個型別(目的型別)的地方。

例如有個函式的參數為double,然後我們傳int的引數進去。

除了語言本身定義的隱式轉換外,使用者也可以自行定義新的規則,只要來源或目的型別的定義處加上適當的成員函式即可。

來源型別中的隱式轉換是「以目的型別命名」的型別運算子(例如:operatorbool())。

目的型別中的隱式轉換,則是定義「唯一的引數就是來源型別」的建構式(或是唯一引數具沒有預設值)。

建構式或是轉換運算子(後者從C++11開始)可以加上explicit關鍵字,以確保它們只會在目的型別有明確指定(例如有轉型)的情況下使用。

這不只在隱式轉換時會用到,在C++11的條列式初始化(listinitialization)語法中也會用到: classFoo{ explicitFoo(intx,doubley); ... }; voidFunc(Foof); Func({42,3.14});//錯誤 這種程式碼在技術上來說並不是隱式轉換,但只要explicit存在,編譯器就會將之視為隱式轉換。

優點: 隱式轉換可以讓型別可用性更高,而且在行為明確時,可以不需要特別指出要轉換的型別。

隱式轉換可以做為函式覆載(overloading)的簡單替代方案,例如只要寫一個擁有string_view參數的函式,就可代替吃string和constchar*兩種型別的覆載函式。

條列式初始化的語法簡潔又能明確表達意涵。

缺點: 隱式轉換可能會讓人忽略某些型別不符的bug,像是目的型別和使用者預期的不一樣,或是使用者不知道會發生型別轉換。

隱式轉換可能會降低程式碼的可讀型(特別是還有函式覆載時),讓人無法清楚了解哪一段程式碼會被呼叫。

只有單一引數的建構式可能無意間會被當成隱式型別轉換呼叫,即使那不是我們的本意。

若是只有單一引數的建構式沒有加上explicit關鍵字,我們就沒有可靠的方法得知作者是想要定義隱式轉換,還是單純忘了加上explicit。

有時候哪個型別該提供轉換不是那麼明顯;若兩個型別都有提供,那就會發生模稜兩可(ambigous)的問題。

如果目的型別是隱式的話,條列式初始會也會遇到一樣的問題,特別是若是條列內容中只有一個元素時。

結論: 在類別定義中,型別轉換運算子、以及只有一個引數的公開建構式,必須要加上explicit修飾字。

但複製和移動建構子不應加上explicit,因為它們不會進行型別轉換。

對於那些在設計上就是用來包裝其他的型別的型別來說,隱式轉換有時是必要且恰當的。

若遇到這種情況,請和專案領導人討論,看是否可以忽略這條規則。

所需的引數不是剛好一個的建構式,可以不用加explicit。

若建構式接受單一參數,而型別為std::initializer_list的話,也不需要加explicit,這樣才能支援複製初始化(copy-initialization)(例如:MyTypem={1,2};)。

5.3.「可複製(copyable)」和「可移動(movable)」型別 小訣竅 類別的公開API應該要明確告知此類別為「可複製」、「僅能移動」,還是「不能複製也不能移動」。

如果複製和/或移動行為對你的型別來說很清楚、很自然的話,支援這些行為。

定義: 若一個型別可以由暫存物件初始化、且取得其內容,即為「可移動」。

若一個型別可以由另一個相同型別的物件初始化、或是取得其內容,而且不會改變來源物件的內容,則為「可複製」(這樣的條件也自然成為「可移動」)。

std::unique_ptr就是一個「可移動、不可複製」的範例(因為std::unique_ptr物件在將內容傳指派給另一個物件時,來源物件的內容必須改變)。

int和string則是「可移動,且可複製」的範例(對int來說,移動和複製行為完全一樣;而string則是有一個比複製節省資源的移動實作)。

對使用者定義型別來說,複製行為是透過定義copyconstructor(複製建構式)和copy-assignment(複製指派)運算子而達成的。

移動行為是透過定義moveconstructor(移動建構式)和move-assignment(移動指派)運算子、或是copyconstructor和copy-assignment運算子而產生。

在某些情況下編譯器會逕行呼叫複製/移動建構式,例如以傳值的方式傳遞物件時。

優點: 可移動及可複製類別的物件可以通過傳值的方式進行傳遞或者回傳,這使得API更簡單、更安全,也更通用。

與傳遞指標和reference不同,這樣的傳遞不會造成所有權、生命週期、可變性等方面的混亂,也就沒必要在協議中特別註明。

這同時也防止了客戶端與實作進行非本地端的互動,讓它們更容易被理解、維護、以及在編譯器進行最佳化。

另外,這樣的物件可以和需要傳值操作的泛型API(例如大多數容器)一起使用,而且在某些應用下(例如typecomposition)也更有彈性。

複製/移動建構式與賦值操作一般來說要比它們的各種替代方案(例如Clone()、CopyFrom()或Swap())更容易定義,因為無論是隱式的版本還是=的預設行為,編譯器都能幫我們自動產生。

這種方式很簡潔,也保證所有資料成員都會被複製。

複製與移動建構式一般也更有效率,因為它們不需要配置heap空間或是單獨的初始化和賦值步驟,同時也很適合進行類似複製省略這樣的最佳化。

移動作業允許隱式且有效地將rvalue物件中的資源轉移出來。

有時這能讓程式碼風格更加簡潔。

缺點: 有些類別不需要能被複製,為這些型別提供複製功能會讓人迷惑,也顯得荒謬而不合理。

描述singleton物件的型別(Registerer)、跟某個特定作用域綁定的物件(Cleanup),或是和物件識別(objectidentity)緊密結合的類別(Mutex)等,也都沒有提供複製功能的必要。

為多型架構下的基底類別提供複製功能是有害的,因為會造成objectslicing的問題。

未經仔細設計或預設的複製功能實作可能不正確,這往往會產生令人困惑且難以揪出的臭蟲。

複製建構式是隱式呼叫的,因此很容易被人忽略。

對於那些慣用「資料一定是以reference方式傳遞」的語言的開發人員們來說,這尤其讓人困擾。

這也可能過度鼓勵複製行為,進而導致效能低落。

結論: 每個類別的公開界面都須明確指明這個類別要支援哪些複製和移動作業。

作法通常是在類別宣告的public區間中,明確地宣告希望支援的行為、同時明確地刪除不想支援的行為。

更精確地來說:可複製的類別應該要明確宣告複製相關函式;只能被移動的類別應該要明確宣告移動相關函式;而不能移動也不能複製的類別,應該要明確地刪除複製及移動相關函式。

不管是宣告還是刪除,你可以同時將複製、移動相關的四個函式全部列出,但不是必要的。

如果你提供了copy-assignment或move-assignment運算子,你必須同時提供對應的建構式。

classCopyable{ public: Copyable(constCopyable&rhs)=default; Copyable&operator=(constCopyable&rhs)=default; //上述的宣告覆蓋了隱式的移動行為。

}; classMoveOnly{ public: MoveOnly(MoveOnly&&rhs); MoveOnly&operator=(MoveOnly&&rhs); //上述宣告已隱含「刪除複製行為」之意, //不過如果你希望的話,可以明確表示出來: MoveOnly(constMoveOnly&)=delete; MoveOnly&operator=(constMoveOnly&)=delete; }; classNotCopyableOrMovable{ public: //不可複製也不可移動 NotCopyableOrMovable(constNotCopyableOrMovable&)=delete; NotCopyableOrMovable&operator=(constNotCopyableOrMovable&) =delete; //上述宣告已隱含「刪除移動行為」之意, //不過如果你希望的話,可以明確表示出來: NotCopyableOrMovable(NotCopyableOrMovable&&)=delete; NotCopyableOrMovable&operator=(NotCopyableOrMovable&&) =delete; }; 只有在非常明顯的情況下才能省略宣告/刪除語句:舉例來說,如果基底類別不可複製或不可移動,繼承它的類別自然也不行。

同樣的,結構是否可以複製或移動,得視它的資料成員是否可以複製或移動而定(和類別的規則不同,因為在Google的程式碼中,類別的資料成員不是公開的)。

但如果你明確地宣告或刪除了複製/移動行為,另一組的行為不明確,那麼就不能套用這段所說的例外情況(特別是:若是你宣告或刪除了結構的複製/移動行為,那麼你就得遵守這一節中所有針對類別設定的規則)。

如果一個型別的複製/移動行為意義不明確,或是會帶來意料之外的效率成本,那麼這個型別就不應為「可複製」或「可移動」。

對於「可複製」的型別來說,移動行為完全是為了對效率最佳化而生,而且是臭蟲和複雜性的潛在來源,所以除非它的執行效率真的遠勝單純的複製行為,儘量不要額外定義移動行為。

如果你的型別是可複製的,我們建議你仔細設計你的類別,好讓預設的實作版本能正常運作。

記得要仔細檢查預設實作版本的正確性,一如你對待其他的程式碼。

為了避免發生slicing的問題,若是一個類別是設計來當基底類別的,儘量不要提供公開的指派運算子或複製/移動建構式(同時,儘量不要去繼承有這類成員的類別)。

如果你的基底類別須為可複製的,那麼請提供公開的Clone()虛擬函式、以及protected的複製建構式,以利繼承類別能實作自己的版本。

5.4.結構(struct)vs.類別(class) 小訣竅 想要建立只有資料的被動物件時,使用struct;其他狀況一律使用class。

在C++中struct和class的行為幾乎一樣。

我們為這兩個關鍵字添加我們自己的語義,以便為定義的資料型別選擇合適的關鍵字。

struct用來定義包含數據的被動物件,也可以包含相關的常數,但除了可以存取其中的資料成員外,沒有其他功能。

存取資料時直接存取資料所在的欄外,而非透過函式。

除了建構式、解構式、Initialize()、Reset()、Validate()等設定資料成員的方法外,不得提供其他的行為方法。

如果需要更多的功能,class更適合。

如果難以判斷,就用class。

為了和STL保持一致,對於函式物件(functor)和trait特性可以用struct而非class。

注意:結構和類別的資料成員命名規則不同。

5.5.繼承 小訣竅 使用組合(composition)常比使用繼承更合理。

如果使用繼承的話,定義為public繼承。

定義: 當子類別繼承基底類別時,子類別包含了基底類別所定義的所有資料及函式。

「界面繼承(interfaceinheritance)」指的是繼承自「純抽象基底類別(pureabstractclass)」,也就是完全沒有狀態或方法實作的類別;其他的繼承行為都是「實作繼承(implementationinheritance)」。

優點: 實作繼承通過原封不動的重覆使用基底類別程式碼減少了程式碼的數量。

由於繼承是在編譯時宣告,開發者和編譯器都可以理解對應操作並發現錯誤。

從程式撰寫角度來說,界面繼承是用來強制類別輸出特定的API。

在類別沒有實作API中某個必須的方法時,編譯器同樣會發現並回報錯誤。

缺點: 對於實作繼承,由於子類別的實作程式碼散佈在基底類別和子類別的定義處,要理解其實作變得更加困難。

子類別不能覆寫基底類別的非虛擬函式,當然也就不能修改其實作。

多重繼承的問題又更多了。

它通常會造成非常明顯的效能負擔(事實上,「從單一繼承變成多重繼承」所造成的效能衝擊,通常比「從一般繼承變成虛擬繼承」所造成的效能衝擊還要大),而且還有可能會產生「鑽石型繼承樣式」,造成理解上的困難、模稜兩可的問題,以及難解的bug。

結論: 只能使用public繼承。

如果你覺得要用私有繼承,那應該改為把基底類別的實例當作資料成員。

不要過度使用實作繼承。

組合常常更合適一些。

儘量做到只在「is-a」的情況下使用繼承:如果Bar的確「is-a」Foo,Bar才能繼承Foo。

儘量不要使用protected宣告子類別可以存取的資料成員。

類別的資料成員應該要是私有的。

在子類別覆載虛擬函式或虛擬解構式時,加上override或是final修飾字(雖然後者較不常用);不要加上virtual修飾字。

原因是:假設一個函式/解構式在基底類別中並沒有被宣告為可覆載的虛擬函式/解構式,那麼在子類別中加上override或是final就會產生編譯時的錯誤。

這樣的結果有助於我們找到一些常見的錯誤。

這些修飾字相當於程式碼中的說明文件;如果沒有這些修飾字的話,閱讀程式碼的人就必須檢查所有的基底類別,才能知道這個函式/解構式是否為虛擬函式/解構式。

你可以使用多重繼承,但我們強烈不建議進行多重實作繼承。

5.6.運算子多載化(OperatorOverloading) 小訣竅 謹慎判斷多載化運算子的時機。

不要建立使用者定義的字面符號(literal)。

定義: C++允許使用者使用operator關鍵字,自行宣告內建運算子的多載化版本,只要其中之一的參數型別為使用者自訂型別即可。

此外,operator關鍵字也可以用來定義新的字面符號(literal)(透過operator""),以及定義型別轉換函式(例如operatorbool())。

優點: 運算子多載化讓使用者定義型別的行為更接近內建型別,可以讓程式碼更簡潔、更直觀。

對於某些運算來說,多載化的運算子更符合一般的使用習慣(例如==、在引數相同時不會回傳true。

儘可能將雙引數(binary)運算子定義為「不會修改內容」的「非成員函式」。

如果一個二元運算子被定義為類別成員的話,運算元右手邊的引數就會被隱式轉換,但左手邊的引數不會。

如果a



請為這篇文章評分?