更新時間:2018-11-29 來源:黑馬程序員技術社區 瀏覽量:
3.1. 構造函數的職責
不要在構造函數中進行復雜的初始化 (尤其是那些有可能失敗或者需要調用虛函數的初始化).
定義:
在構造函數體中進行初始化操作.
優點:
排版方便, 無需擔心類是否已經初始化.
缺點:
在構造函數中執行操作引起的問題有:
結論:
構造函數不得調用虛函數, 或嘗試報告一個非致命錯誤. 如果對象需要進行有意義的 (non-trivial) 初始化, 考慮使用明確的 Init() 方法或使用工廠模式.
如果類中定義了成員變量, 則必須在類中為每個類提供初始化函數或定義一個構造函數. 若未聲明構造函數, 則編譯器會生成一個默認的構造函數, 這有可能導致某些成員未被初始化或被初始化為不恰當的值.
定義:
new 一個不帶參數的類對象時, 會調用這個類的默認構造函數. 用 new[] 創建數組時, 默認構造函數則總是被調用. 在類成員里面進行初始化是指聲明一個成員變量的時候使用一個結構例如 int _count = 17 或者 string _name{"abc"} 來替代 int _count 或者 string _name 這樣的形式.
優點:
用戶定義的默認構造函數將在沒有提供初始化操作時將對象初始化. 這樣就保證了對象在被構造之時就處于一個有效且可用的狀態, 同時保證了對象在被創建時就處于一個顯然”不可能”的狀態, 以此幫助調試.
缺點:
對代碼編寫者來說, 這是多余的工作.
如果一個成員變量在聲明時初始化又在構造函數中初始化, 有可能造成混亂, 因為構造函數中的值會覆蓋掉聲明中的值.
結論:
簡單的初始化用類成員初始化完成, 尤其是當一個成員變量要在多個構造函數里用相同的方式初始化的時候.
如果你的類中有成員變量沒有在類里面進行初始化, 而且沒有提供其它構造函數, 你必須定義一個 (不帶參數的) 默認構造函數. 把對象的內部狀態初始化成一致 / 有效的值無疑是更合理的方式.
這么做的原因是: 如果你沒有提供其它構造函數, 又沒有定義默認構造函數, 編譯器將為你自動生成一個. 編譯器生成的構造函數并不會對對象進行合理的初始化.
如果你定義的類繼承現有類, 而你又沒有增加新的成員變量, 則不需要為新類定義默認構造函數.
對單個參數的構造函數使用 C++ 關鍵字 explicit.
定義:
通常, 如果構造函數只有一個參數, 可看成是一種隱式轉換. 打個比方, 如果你定義了 Foo::Foo(string name), 接著把一個字符串傳給一個以 Foo 對象為參數的函數, 構造函數 Foo::Foo(string name) 將被調用, 并將該字符串轉換為一個 Foo 的臨時對象傳給調用函數. 看上去很方便, 但如果你并不希望如此通過轉換生成一個新對象的話, 麻煩也隨之而來. 為避免構造函數被調用造成隱式轉換, 可以將其聲明為 explicit.
除單參數構造函數外, 這一規則也適用于除第一個參數以外的其他參數都具有默認參數的構造函數, 例如 Foo::Foo(string name, int id = 42).
優點:
避免不合時宜的變換.
缺點:
無
結論:
所有單參數構造函數都必須是顯式的. 在類定義中, 將關鍵字 explicit 加到單參數構造函數前: explicit Foo(string name);
例外: 在極少數情況下, 拷貝構造函數可以不聲明成 explicit. 作為其它類的透明包裝器的類也是特例之一. 類似的例外情況應在注釋中明確說明.
最后, 只有 std::initializer_list 的構造函數可以是非 explicit, 以允許你的類型結構可以使用列表初始化的方式進行賦值. 例如:
如果你的類型需要, 就讓它們支持拷貝 / 移動. 否則, 就把隱式產生的拷貝和移動函數禁用.
定義:
可拷貝類型允許對象在初始化時得到來自相同類型的另一對象的值, 或在賦值時被賦予相同類型的另一對象的值, 同時不改變源對象的值. 對于用戶定義的類型, 拷貝操作一般通過拷貝構造函數與拷貝賦值操作符定義. string 類型就是一個可拷貝類型的例子.
可移動類型允許對象在初始化時得到來自相同類型的臨時對象的值, 或在賦值時被賦予相同類型的臨時對象的值 (因此所有可拷貝對象也是可移動的). std::unique_ptr<int> 就是一個可移動但不可復制的對象的例子. 對于用戶定義的類型, 移動操作一般是通過移動構造函數和移動賦值操作符實現的.
拷貝 / 移動構造函數在某些情況下會被編譯器隱式調用. 例如, 通過傳值的方式傳遞對象.
優點:
可移動及可拷貝類型的對象可以通過傳值的方式進行傳遞或者返回, 這使得 API 更簡單, 更安全也更通用. 與傳指針和引用不同, 這樣的傳遞不會造成所有權, 生命周期, 可變性等方面的混亂, 也就沒必要在協議中予以明確. 這同時也防止了客戶端與實現在非作用域內的交互, 使得它們更容易被理解與維護. 這樣的對象可以和需要傳值操作的通用 API 一起使用, 例如大多數容器.
拷貝 / 移動構造函數與賦值操作一般來說要比它們的各種替代方案, 比如 Clone(), CopyFrom() or Swap(), 更容易定義, 因為它們能通過編譯器產生, 無論是隱式的還是通過 = 默認. 這種方式很簡潔, 也保證所有數據成員都會被復制. 拷貝與移動構造函數一般也更高效, 因為它們不需要堆的分配或者是單獨的初始化和賦值步驟, 同時, 對于類似省略不必要的拷貝這樣的優化它們也更加合適.
移動操作允許隱式且高效地將源數據轉移出右值對象. 這有時能讓代碼風格更加清晰.
缺點:
許多類型都不需要拷貝, 為它們提供拷貝操作會讓人迷惑, 也顯得荒謬而不合理. 為基類提供拷貝 / 賦值操作是有害的, 因為在使用它們時會造成對象切割. 默認的或者隨意的拷貝操作實現可能是不正確的, 這往往導致令人困惑并且難以診斷出的錯誤.
結論:
如果需要就讓你的類型可拷貝 / 可移動. 作為一個經驗法則, 如果對于你的用戶來說這個拷貝操作不是一眼就能看出來的, 那就不要把類型設置為可拷貝. 如果讓類型可拷貝, 一定要同時給出拷貝構造函數和賦值操作的定義. 如果讓類型可拷貝, 同時移動操作的效率高于拷貝操作, 那么就把移動的兩個操作 (移動構造函數和賦值操作) 也給出定義. 如果類型不可拷貝, 但是移動操作的正確性對用戶顯然可見, 那么把這個類型設置為只可移動并定義移動的兩個操作.
建議通過 = default 定義拷貝和移動操作. 定義非默認的移動操作目前需要異常. 時刻記得檢測默認操作的正確性. 由于存在對象切割的風險, 不要為任何有可能有派生類的對象提供賦值操作或者拷貝 / 移動構造函數 (當然也不要繼承有這樣的成員函數的類). 如果你的基類需要可復制屬性, 請提供一個 public virtual Clone() 和一個 protected 的拷貝構造函數以供派生類實現.
如果你的類不需要拷貝 / 移動操作, 請顯式地通過 = delete 或其他手段禁用之.
在能夠減少重復代碼的情況下使用委派和繼承構造函數.
定義:
委派和繼承構造函數是由 C++11 引進為了減少構造函數重復代碼而開發的兩種不同的特性. 通過特殊的初始化列表語法, 委派構造函數允許類的一個構造函數調用其他的構造函數. 例如:
繼承構造函數允許派生類直接調用基類的構造函數, 一如繼承基類的其他成員函數, 而無需重新聲明. 當基類擁有多個構造函數時這一功能尤其有用. 例如:
如果派生類的構造函數只是調用基類的構造函數而沒有其他行為時, 這一功能特別有用.
優點:
委派和繼承構造函數可以減少冗余代碼, 提高可讀性. 委派構造函數對 Java 程序員來說并不陌生.
缺點:
使用輔助函數可以預估出委派構造函數的行為. 如果派生類和基類相比引入了新的成員變量, 繼承構造函數就會讓人迷惑, 因為基類并不知道這些新的成員變量的存在.
結論:
只在能夠減少冗余代碼, 提高可讀性的前提下使用委派和繼承構造函數. 如果派生類有新的成員變量, 那么使用繼承構造函數時要小心. 如果在派生類中對成員變量使用了類內部初始化的話, 繼承構造函數還是適用的.
僅當只有數據時使用 struct, 其它一概使用 class.
說明:
在 C++ 中 struct 和 class 關鍵字幾乎含義一樣. 我們為這兩個關鍵字添加我們自己的語義理解, 以便未定義的數據類型選擇合適的關鍵字.
struct 用來定義包含數據的被動式對象, 也可以包含相關的常量, 但除了存取數據成員之外, 沒有別的函數功能. 并且存取功能是通過直接訪問位域, 而非函數調用. 除了構造函數, 析構函數, Initialize(), Reset(), Validate() 等類似的函數外, 不能提供其它功能的函數.
如果需要更多的函數功能, class 更適合. 如果拿不準, 就用 class.
為了和 STL 保持一致, 對于仿函數和 trait 特性可以不用 class 而是使用 struct.
注意: 類和結構體的成員變量使用不同的命名規則.
使用組合 (composition, YuleFox 注: 這一點也是 GoF 在 <<Design Patterns>> 里反復強調的) 常常比使用繼承更合理. 如果使用繼承的話, 定義為 public 繼承.
定義:
當子類繼承基類時, 子類包含了父基類所有數據及操作的定義. C++ 實踐中, 繼承主要用于兩種場合: 實現繼承 (implementation inheritance), 子類繼承父類的實現代碼; 接口繼承 (interface inheritance), 子類僅繼承父類的方法名稱.
優點:
實現繼承通過原封不動的復用基類代碼減少了代碼量. 由于繼承是在編譯時聲明, 程序員和編譯器都可以理解相應操作并發現錯誤. 從編程角度而言, 接口繼承是用來強制類輸出特定的 API. 在類沒有實現 API 中某個必須的方法時, 編譯器同樣會發現并報告錯誤.
缺點:
對于實現繼承, 由于子類的實現代碼散布在父類和子類間之間, 要理解其實現變得更加困難. 子類不能重寫父類的非虛函數, 當然也就不能修改其實現. 基類也可能定義了一些數據成員, 還要區分基類的實際布局.
結論:
所有繼承必須是 public 的. 如果你想使用私有繼承, 你應該替換成把基類的實例作為成員對象的方式.
不要過度使用實現繼承. 組合常常更合適一些. 盡量做到只在 “是一個” (“is-a”, YuleFox 注: 其他 “has-a” 情況下請使用組合) 的情況下使用繼承: 如果 Bar 的確 “是一種” Foo, Bar 才能繼承 Foo.
必要的話, 析構函數聲明為 virtual. 如果你的類有虛函數, 則析構函數也應該為虛函數. 注意 數據成員在任何情況下都必須是私有的.
當重載一個虛函數, 在衍生類中把它明確的聲明為 virtual. 理論依據: 如果省略 virtual 關鍵字, 代碼閱讀者不得不檢查所有父類, 以判斷該函數是否是虛函數.
真正需要用到多重實現繼承的情況少之又少. 只在以下情況我們才允許多重繼承: 最多只有一個基類是非抽象類; 其它基類都是以 Interface 為后綴的 純接口類.
定義:
多重繼承允許子類擁有多個基類. 要將作為 純接口 的基類和具有 實現 的基類區別開來.
優點:
相比單繼承 (見 繼承), 多重實現繼承可以復用更多的代碼.
缺點:
真正需要用到多重 實現 繼承的情況少之又少. 多重實現繼承看上去是不錯的解決方案, 但你通常也可以找到一個更明確, 更清晰的不同解決方案.
結論:
只有當所有父類除第一個外都是 純接口類 時, 才允許使用多重繼承. 為確保它們是純接口, 這些類必須以 Interface 為后綴.
關于該規則, Windows 下有個 特例.
接口是指滿足特定條件的類, 這些類以 Interface 為后綴 (不強制).
定義:
當一個類滿足以下要求時, 稱之為純接口:
接口類不能被直接實例化, 因為它聲明了純虛函數. 為確保接口類的所有實現可被正確銷毀, 必須為之聲明虛析構函數 (作為上述第 1 條規則的特例, 析構函數不能是純虛函數). 具體細節可參考 Stroustrup 的 The C++ Programming Language, 3rd edition 第 12.4 節.
優點:
以 Interface 為后綴可以提醒其他人不要為該接口類增加函數實現或非靜態數據成員. 這一點對于 多重繼承 尤其重要. 另外, 對于 Java 程序員來說, 接口的概念已是深入人心.
缺點:
Interface 后綴增加了類名長度, 為閱讀和理解帶來不便. 同時,接口特性作為實現細節不應暴露給用戶.
結論:
只有在滿足上述需要時, 類才以 Interface 結尾, 但反過來, 滿足上述需要的類未必一定以 Interface 結尾.
除少數特定環境外,不要重載運算符.
定義:
一個類可以定義諸如 + 和 / 等運算符, 使其可以像內建類型一樣直接操作.
優點:
使代碼看上去更加直觀, 類表現的和內建類型 (如 int) 行為一致. 重載運算符使 Equals(), Add()等函數名黯然失色. 為了使一些模板函數正確工作, 你可能必須定義操作符.
缺點:
雖然操作符重載令代碼更加直觀, 但也有一些不足:
混淆視聽, 讓你誤以為一些耗時的操作和操作內建類型一樣輕巧.更難定位重載運算符的調用點, 查找 Equals() 顯然比對應的 == 調用點要容易的多.有的運算符可以對指針進行操作, 容易導致 bug. Foo + 4 做的是一件事, 而 &Foo + 4 可能做的是完全不同的另一件事. 對于二者, 編譯器都不會報錯, 使其很難調試;重載還有令你吃驚的副作用. 比如, 重載了 operator& 的類不能被前置聲明.
結論:
一般不要重載運算符. 尤其是賦值操作 (operator=) 比較詭異, 應避免重載. 如果需要的話, 可以定義類似 Equals(), CopyFrom() 等函數.
然而, 極少數情況下可能需要重載運算符以便與模板或 “標準” C++ 類互操作 (如 operator<<(ostream&, const T&)). 只有被證明是完全合理的才能重載, 但你還是要盡可能避免這樣做. 尤其是不要僅僅為了在 STL 容器中用作鍵值就重載 operator== 或 operator<; 相反, 你應該在聲明容器的時候, 創建相等判斷和大小比較的仿函數類型.
有些 STL 算法確實需要重載 operator== 時, 你可以這么做, 記得別忘了在文檔中說明原因.
參考 拷貝構造函數 和 函數重載.
將 所有 數據成員聲明為 private, 并根據需要提供相應的存取函數. 例如, 某個名為 foo_ 的變量, 其取值函數是 foo(). 還可能需要一個賦值函數 set_foo().
特例是, 靜態常量數據成員 (一般寫做 kFoo) 不需要是私有成員.
一般在頭文件中把存取函數定義成內聯函數.
參考 繼承 和 函數命名
在類中使用特定的聲明順序: public: 在 private: 之前, 成員函數在數據成員 (變量) 前;
類的訪問控制區段的聲明順序依次為: public:, protected:, private:. 如果某區段沒內容, 可以不聲明.
每個區段內的聲明通常按以下順序:
typedefs 和枚舉常量構造函數析構函數成員函數, 含靜態成員函數數據成員, 含靜態數據成員友元聲明應該放在 private 區段. 如果用宏 DISALLOW_COPY_AND_ASSIGN 禁用拷貝和賦值, 應當將其置于 private 區段的末尾, 也即整個類聲明的末尾. 參見可拷貝類型和可移動類型.
.cc 文件中函數的定義應盡可能和聲明順序一致.
不要在類定義中內聯大型函數. 通常, 只有那些沒有特別意義或性能要求高, 并且是比較短小的函數才能被定義為內聯函數. 更多細節參考 內聯函數.
傾向編寫簡短, 凝練的函數.
我們承認長函數有時是合理的, 因此并不硬性限制函數的長度. 如果函數超過 40 行, 可以思索一下能不能在不影響程序結構的前提下對其進行分割.
即使一個長函數現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題. 甚至導致難以發現的 bug. 使函數盡量簡短, 便于他人閱讀和修改代碼.
在處理代碼時, 你可能會發現復雜的長函數. 不要害怕修改現有代碼: 如果證實這些代碼使用 / 調試困難, 或者你需要使用其中的一小段代碼, 考慮將其分割為更加簡短并易于管理的若干函數.