天天看點

C++程式設計規範整理(一)

1. 從頭說起

确定完代碼結構,打開編輯器,開始編寫C++的類時,首先要考慮采用class還是struct結構。然後要确定代碼結構,定義接口(純虛基類)、基類和子類之間的關系。接着最好完整地聲明構造函數、拷貝構造函數、析構函數和重載指派運算符。最後就是聲明成員變量和所需要的行為——成員函數。确定了這些,就可以開始編寫類的代碼邏輯了。

1.1. 繼承

繼承是OO中的重要特性,也是被廣泛“濫用”的特性,很多使用基類的情況僅僅隻是為了可以預設地使用某些方法(這種情況應該使用“接口”而不是繼承)。這不僅僅讓類的設計産生了不一緻的抽象,使使用者難以了解和使用。這種設計往往會産生緊耦合,對代碼的重用和重構帶來很大困難。

另外在使用繼承時,需要注意的是不要覆寫基類的非虛函數,這樣在動态綁定時,會導緻多态性失效,引起難以發現的Bug。

是以在使用繼承時,需要關注:

  • 公開繼承的類,必須表示“is-a”的關系,禁止僅僅因為可以複用某些方法而使用承
  • 對于不滿足“is-a”關系的兩個類,若要複用代碼,使用組合代替繼承
  • 避免使用多重繼承,如果要進行多重實作繼承,考慮使用組合代替
  • 派生類不允許覆寫基類中的非虛函數
  • 建議使用公有繼承,慎用保護繼承和私有繼承

1.2. class還是struct

C++中對資料的包裝主要有兩種方式:class和struct。這兩種在文法上沒有很大的差別,隻有在預設成員的通路權限上存在差别(class預設為private,struct預設為public)。但是在C++中,struct是從C語言中借過來的概念,是以最好盡量的保持其語義與C語言中的一緻——資料集合。

是以在C++中,對于class還是struct的選擇最好遵從下列規則:

  • 用struct封裝資料集合 ,公開定義資料成員,struct中一般不定義成員函數
  • 用class封裝使用者的行為 ,不公開定義資料成員,class之間的資料互動通過成員函數進行

1.3. 構造函數做些什麼

構造函數,顧名思義就是對象建立時,構造對象基本資料的函數,它實作了對象的初始化過程。在設計構造函數時,可以遵循一個簡單的規則:對于已完成構造的對象,應該是即刻可用的。換句話說,構造函數都已經調用了,對象都構造完了,如果此時對象還不可用,那就是違背構造函數的語義的。

有很多實作采用init()函數來代替構造函數完成對象的初始化。但是額外增加的init()函數會大大增加類實作和使用的複雜性。構造函數在建立對象的時候被且僅被調用一次。為了保持該特性,如果使用init()函數來代替構造函數,則必須做到:

  • 所有的成員函數都必須判斷init()函數是否被正确調用
  • init()函數沒調用時,所有成員函數都必須傳回一個錯誤碼來明确訓示
  • init()實作的過程中必須處理多次重複調用的情況,所有成員函數的消費者都必須檢查其傳回值等等

在設計構造函數時,如果需要構造的資料過于複雜,可以考慮用創造性設計模式将每個資料的構造分拆到多個類中。此外還需要注意的是,隻有當構造函數成功調用後,建立的對象才是合法對象,在釋放對象時,才會調用其析構函數。是以如果在調用構造函數的時候出現異常進而導緻構造過程退出時,必須自行釋放所有已配置設定的資源。

綜上所述,在設計和實作構造函數時,應該遵從一下原則:

  • 構造函數能夠使對象進入一個一緻的狀态,是以在構造函數調用結束之後,對象應該是即刻可用的
  • 構造函數隻負責初始化資料成員,避免在構造函數中實作過于複雜的業務邏輯。也要使用構造函數來完成複雜的工作
  • 必須使用初始化清單來顯示初始化直接基類和所有資料成員
  • 構造函數可以抛出異常,但是必須自行清理在之前構造過程中所占用的資源
  • 在設計一個類時,盡量避免使用init()函數

1.4. 預設構造函數

預設構造函數是指沒有參數的構造函數。當程式員沒有顯示定義任何構造函數時,C++編譯器将為其自動生成一個預設構造函數。但是該構造函數的語義往往與程式員的期望不一緻。是以盡量不要使用編譯器生成的預設構造函數。顯示定義預設構造函數既能避免一些意想不到的錯誤,也使代碼具有更好的可讀性,同時也便于通過注釋向類消費者說明預設構造的對象狀态。是以在編寫類代碼時需要注意:

  • 有預設語義的類,必須顯示定義其預設構造函數
  • 沒有預設語義的類,必須顯示定義其他構造函數或者将預設構造函數聲明為private

1.5. 顯示構造函數

顯示構造函數的概念相對于隐式構造函數。所謂的隐式構造函數是指形如MyClass::MyClass(MyArg arg)的單參數構造函數。這種構造函數會定義MyArg類型到MyClass的隐式類型轉換,這可能會帶來很大的風險(輕則效率低,重則導緻不可意料的程式行為,且很難追查)。是以:

  • 對于單參數的構造函數,除非是拷貝構造,或者的确希望提供隐式類型轉換功能(如類string,需要char*到string的隐式類型轉換),則必須對其進行顯示聲明(explicit關鍵字)
  • 在極少數的确需要隐式轉換的情況下,可以隐式聲明單參數構造函數

1.6. 拷貝構造函數

拷貝構造函數是指形如MyClass::MyClass(const MyClass&)的構造函數。拷貝構造函數主要在對象拷貝時調用(是拷貝,區分于指派)。當程式員沒有聲明或定義任何構造函數時,編譯器會自動生成一個拷貝構造函數。

但事實上,有一些類是沒有拷貝的語義的(如托管資源的類,CFile)。此時則應該隐藏拷貝構造函數,以免發生使用者錯誤調用造成的資源洩漏、重複釋放等問題。而對于需要拷貝語義的類,編譯器提供的拷貝構造函數對指針類型進行了淺拷貝,這種政策顯然不是開發者想要看到的。

是以,對于拷貝構造函數需要有如下規定

  • 沒有拷貝意義的類,必須将拷貝構造函數聲明為private,并不給實作
  • 有拷貝意義的類,需要明确指定其拷貝行為(淺拷貝還是深拷貝)

1.7. 重載指派運算符

指派運算函數和拷貝構造類似,它會在對象作為左值時被調用。在程式員沒有重載指派運算符時,編譯器會自動生成一個預設的指派運算符函數。

是以,與拷貝構造函數類似,對于沒有指派意義的類(如托管資源的類),應當防止使用者錯誤調用而導緻資源洩漏、重複釋放等後果。由于預設的指派運算符函數對指針實作淺拷貝,是以對于有指派意義的類,尤其是包含指針成員變量的類,必須重載指派運算符函數。是以:

  • 沒有指派意義的類必須private聲明複制指派函數并且不給出實作
  • 有指派意義的類必須重載指派運算符,并小心指定其拷貝的行為(淺拷貝、深拷貝等)

1.8. 析構函數

析構函數負責對象銷毀時,做資源回收、清理資料等工作。當編寫代碼時沒有顯式定義析構函數,則編譯器會提供一個預設的析構函數。預設析構函數不會析構指針成員所指向的對象,更不會釋放其所占能存,是以這可能會導緻記憶體洩漏或資源句柄長期未釋放。另外若基類沒有将虛構函數聲明為虛函數,那麼delete pBaseClass操作時,不會調用子類的析構函數進而會産生不可預期的結果。

是以對于析構函數的定義和使用,必須遵循以下幾點:

  • 若類定義了虛函數,必須定義虛析構函數
  • 若類設計為可被繼承的,應該定義公開的虛析構函數或protected的非虛析構函數(不允許delete操作)
  • 若類包含有指針成員,則必須顯示定義虛構函數,并在其中明确指明指針是否銷毀、如何銷毀
  • 絕不允許讓異常離開析構函數
  • 析構函數應該用于釋放資源,銷毀對象,避免執行複雜的操作,尤其避免執行可能失敗的操作
  • 不繼承包含有非虛析構函數的類

1.9. 成員通路控制

通路控制是OO的封裝性中重要的概念。若類成員都用public/protected方式定義成員的通路控制,那麼類就完全地暴露在消費者或派生類之中,進而失去了通過重構優化代碼的能力,也失去了封裝性。是以适當的通路控制是必須的。

在類和類之間,資料成員的耦合性比成員函數要大。而且一旦将資料成員暴露在外,就難免會受到資料狀态一緻性的限制,這些是很難控制的。是以對于成員變量,需要盡量使用private限制。如需和外界進行資料互動,最好定義getter和setter來封裝。

對于靜态的成員變量,必須考慮到其線程安全性。是以盡量使用const來保護靜态成員變量,對于可變的,需要用getter和setter來進行保護。

是以,對于成員的通路控制,需要做到:

  • 在滿足需求與接口完整性的前提下,必須為所有方法成員提供盡可能嚴格的通路控制
  • 類不能定義public非靜态資料成員,不應該定義protected資料成員
  • 類可以定義public靜态資料成員,但必須是const的。否則,應該通過靜态getter/setter通路,并通過文檔指定其是否線程安全

1.10. 成員聲明順序

在C++中,成員的聲明順序一般按照通路控制權限來管理,一般按照public、protected和private的先後順序來聲明。

1.11. 友元

友元是C++中的一個重要概念,它使得類與類之間的關系變得非常靈活,但是錯誤的使用友元也會導緻嚴重的錯誤。

友元的存在破壞了類的封裝性,增加了類與類之間的耦合性,這對于代碼結構的設計和實作都會帶來一些麻煩,是以應該盡量避免使用友元。

但是也存在着一些類,它們之間的确在語義上就存在着較強的耦合關系,在這種情況下可以使用友元(如容器和它的疊代器、類與涉及該類的運算符等等)。

下一篇: RAII機制