大型的軟體專案通常也是由同樣較大型的開發人員團隊來著手進行。如果要讓大型團隊所產生的程式碼具有全專案可預見的品質,必須按照標準來撰寫程式碼,並比對標準來加以評斷。因此,對於大型專案的小組而言,建立程式設計標準或準則集非常重要。
使用程式設計標準也使得執行下列各項成為可能:
本文的目標是要呈現 C++ 程式設計準則、準則和提示(又通稱為準則),這些可用來作為標準的基礎。它是專為在大型的專案小組中工作的軟體工程師而設計。
現行版本刻意著重在程式設計(雖然有時難以劃分程式設計與設計);將來會新增設計準則。
所呈現的準則涵蓋 C++ 開發的下列方面:
它們是從大型的產業知識庫中彙集而來(請參閱參考書目:作者與參考資料)。它們係依據:
大部分係依據第一種的一小部分,有很多則是依據第二及第三種。很可惜的是,有一些也是依據最後一種;主要是因為程式設計是一種非常主觀的活動: 並沒有被廣泛接受的「最佳」或「正確」的程式碼編寫方式。
大部分的規則和準則的首要目標,是明確而易於瞭解的 C++ 程式碼: 明確而易於瞭解的程式碼構成了軟體的可靠性和可維護性的主要因素。明確又易於瞭解的程式碼的意義,可以在下列三個基本原則中獲得 [Kruchten, 94]。
降低判讀意外 - 在程式碼的生命期限,閱讀它的頻率高於撰寫它的頻率,特別是規格。在理想上,程式碼讀起來應該像是英語的說明,指出它做了什麼,以及它執行的附加好處。為人撰寫的程式比為機器撰寫的程式還多。判讀程式碼是一項繁複的思考過程,可受一致性所緩和,這在本指引中又稱為降低判讀意外原則。在整個專案中的一致風格,是軟體開發人員小組對程式設計標準取得一致意見的主因,它不應被認知為某種懲罰或是創造力或生產力的阻礙。
單一維護點 - 應盡可能只在程式碼中的一個點上表達一個設計決策, 同時應該以程式化的方式,從這一點衍生其大部分的結果。違反這個原則會非常危及可維護性和可靠性,以及可瞭解性。
最少的視覺干擾 - 最後,套用最少的干擾原則,這是易讀性的主要構成要素。也就是說,已努力避免用視覺上的「干擾」雜陳於程式碼中: 含有極少的資訊內容或是對於瞭解軟體的用途無甚用處之資訊的列、框以及其他文字。
這裡陳述的準則的理想精神不是要過度限制;而是試圖要提供有關語言特性的正確及安全用法的指引。好軟體的關鍵在於:
這裡呈現的準則做了少量的基本假設:
準則的重要相並不相同:它們是用下列的比例來衡量:
以上符號所識別的準則是一項要訣,它是一段簡單的忠告,可以遵循它,也可以安全地加以忽略。
上方符號所識別的準則是通常依據較為技術上的領域之建議事項;在某些實作中,可能會影響封裝、凝聚力、耦合、可攜性或可復用性,以及效能。建議事項必須要遵循,除非有好的調整指示不要遵循。
以上符號所識別的準則是一項要求或限制;違規將會明確地導致不當的、無法信賴的或不可攜的程式碼。若沒有棄權,將不能違反要求或限制
當您找不到適用的規則或準則時;當規則顯然不適用時;或是當其他作法都不管用時: 請善用常識,以及檢查基本原則。這個規則超越其他的所有規則。即使有規則和準則,還是需要常識。
本章提供有關程式結構和佈置的指引。
大型系統通常是以若干個較小的功能子系統來開發。子系統本身通常是由若干個程式碼模組所建構而來。在 C++ 中,一個模組通常包含一個(較少見的時候為一組)緊密相關的抽象化之實作。在 C++ 中,抽象化通常是當作類別來實作。類別有兩個不同的元件: 類別用戶端看得到的介面,其提供類別之功能和責任的宣告或規格;所宣告規格的實作(類別定義)。
與類別類似的是,模組也有介面和實作;模組介面包含內含的模組抽象化之規格(類別宣告);而模組實作則包含抽象化的實際實作(類別定義)。
在系統的建構中;子系統也可以編排成合作群組或階層,以最小化及控制它們的相依關係。
模組的規格應放在與其實作分開的檔案中: 規格檔又稱為標頭。模組的實作可以放在一或多個實作檔中。
如果模組實作包含大量的列入函數、一般實作(private 宣告、測試程式碼,或平台專用程式碼),則請將這些組件分開到它們自己的檔案,並在其組件的內容之後命名每一個檔案。
如果顧慮到可執行程式的大小,則很少使用的函數也應放在它們自己的個別檔案中。
用下列方式建構組件檔名稱:
File_Name::=
<Module_Name> [<Separator> <Part_Name>] '.' <File_Extension>
以下是模組的分割及命名架構範例:
inlines
檔中(請參閱「將模組列入函數定義放在各別的檔案中」)。Module.Test
。將測試碼宣告為夥伴類別,有助於獨立開發模組以及其測試碼;並可讓您將測試碼省略在最後的模組目的碼之外,而無需變更來源。SymaNetwork.hh // 包含 // 類別 "SymaNetwork" 的宣告。SymaNetwork.Inlines.cc // 列入定義子單元 SymaNetwork.cc // 模組的主要實作單位 SymaNetwork.Private.cc // private 實作子單元 SymaNetwork.Test.cc // 測試程式碼子單元
將模組的規格從它的實作隔開,有助於獨立開發使用者和供應商的程式碼。
將模組的實作分成多個轉換單位,可對目的碼的移除提供較佳的支援,結果執行檔會比較小。
使用一般的以及可預測的檔案命名和分割慣例時,可以不檢查模組的實際內容,就可以瞭解其內容和編排。
從程式碼傳遞名稱至檔名,可以增加可預測度,並且可以促進檔案型工具的建置,而無需複雜的名稱對映 [Ellemtel, 1993]。
常用的副檔名有:.h、.H、.hh、.hpp
和
.hxx
代表標頭檔,.c、、.C、.cc、.cpp
和
.cxx
代表實作。請挑選一組副檔名,然後保持一致地使用它們。
SymaNetwork.hh // 副檔名 ".hh" 用來 // 指定 "SymaNetwork" 模組標頭。SymaNetwork.cc // 副檔名 ".cc" 用來指定 // "SymaNetwork" 模組實作。
C++ 初稿標準工作底稿也使用副檔名 ".ns" 代表名稱空間所封裝的標頭。
只有在很少的情況下,才應將多個類別一起放在一個模組中;放在一起的條件是它們緊密相關(例如:儲存器以及它的反覆元)。如果一律要求用戶端模組可以看見所有的類別,則將模組的主要類別以及其支援類別放在同一個標頭檔內是可以接受的。
降低模組的介面以及依附於其上的其他相依關係。
除了類別專用成員之外,模組的實作 private 宣告(例如: 實作類型及支援類別)不應出現在模組的規格中。這些宣告應該放在所需的實作檔案中,除非有多個實作檔案需要這些宣告;在該情況下,宣告就應該放在次要的、專用的標頭檔中。然後其他實作檔案應視需要包含這些次要、 專用的標頭檔。
這種作法可確保:
// 模組 foo 的規格,內含於 "foo.hh" 檔 // class foo { .. declarations }; // "foo.hh" 結束 // 模組 foo 的 Private 宣告,內含於 // "foo.private.hh" 檔,並供所有的 foo 實作檔案使用。... private 宣告 // "foo.private.hh" 結束 // 模組 foo 實作,內含於 // "foo.x.cc" 和 "foo.y.cc" 等多個檔案 // "foo.x.cc" 檔 // #include "foo.hh" // Include module's own header #include "foo.private.hh" // Include implementation // 需要宣告。... definitions // "foo.x.cc" 結束 // "foo.y.cc" 檔 // #include "foo.hh" #include "foo.private.hh" ... definitions // "foo.y.cc" 結束
#include
來獲得對模組規格的存取權使用另一個前置處理器的模組必須使用前置處理器 #include
指令來獲得供應商模組之規格的有效範圍。 相同地,模組絕不應重新宣告供應商模組規格的任何部分。
在併入檔案時,僅對 "standard" 標頭使用 #include <header>
語法,並對其餘的標頭使用 #include
"header" 語法。
使用 #include
指令也適用於模組自己的實作檔案:
模組實作必須包含其自己的規格以及 private 次要標頭(請參閱「將模組的規格和實作放在分開的檔案中」)。
// 在其標頭檔 // "foo.hh" 中的模組 foo 之規格 // class foo { ... declarations }; // "foo.hh" 結束 // "foo.cc" 檔中的模組 foo 之實作 // #include "foo.hh" // The implementation includes its own // 規格 ... definitions for members of foo // "foo.cc" 結束
#include
規則的例外,是當模組傳參考位置(使用指標或參考類型宣告),僅使用或僅包含供應商的類型(類別);在此情況下,是使用正向宣告(另請參閱「將編譯相依關係降至最低」)而非 #include
指令來指定傳參考位置的用法或是包含。
避免包含絕對需要以外的標頭: 這表示模組標頭不應包含只有模組實作需要的其他標頭。
#include "a_supplier.hh" class needed_only_by_reference;// 如果我們只需要指標或參考位置 //來存取類別, // 則請對它使用 // 正向宣告。 void operation_requiring_object(a_supplier required_supplier, ...); // // 需要實際供應商物件的作業;// 因此必須 #include 供應商規格。void some_operation(needed_only_by_reference& a_reference, ...); // // 某些作業僅需要物件的參照;// 因此應對供應商使用正向宣告。
這項規則可確保:
當模組有許多個列入函數時,它們的定義應放在只有列入函數的各別檔案中,模組標頭檔的結尾應指出列入函數檔案。
另請參閱「使用 No_Inline
條件式編譯符號來推翻列入編譯」。
這項技術可使模組的標頭不會被實作的一些細節混亂;因而可以保持清楚的規格。它在不進行列入編譯時也有助於縮減程式碼抄寫: 使用條件式編譯,列入函數可以編譯到單一物件檔中,而不是以靜態的方式編譯到每個使用中的模組。同樣地,列入函數定義也不應定義於類別定義,除非它們完全無關緊要。
將大型模組分成多個轉換單位,以便於在程式鏈結期間移除未被參照的程式碼。不常被參照的成員函數應與那些常用的成員函數分隔到各別的檔案中。最極端的作法是,可以將個別的成員函數置於它們自己的檔案中 [Ellemtel, 1993]。
並不是所有的鏈結程式都一樣能夠消除物件檔內未被參照的程式碼。將大型模組分成多個檔案,可讓這些鏈結程式消除全部的物件檔鏈結,而縮減了可執行程式的大小 [Ellemtel, 1993]。
先考量模組是否應分成許多個較小的抽象化,可能也是相當值得的。
將不可在不同平台上執行的程式碼從可在不同的平台上執行的程式碼分隔出來;此舉將會促進植入。應以其平台名稱限定不可在不同平台上執行的程式碼模組,以突顯平台相依關係。
SymaLowLevelStuff.hh // "LowLevelStuff" // 規格 SymaLowLevelStuff.SunOS54.cc // SunOS 5.4 實作 SymaLowLevelStuff.HPUX.cc // HP-UX 實作 SymaLowLevelStuff.AIX.cc // 實作
從架構及維護的觀點來看,在小量的低階子系統中包含平台相依關係也是一個相當好的作法。
採用標準檔案內容結構,並前後一致地加以套用
建議的檔案內容結構由下列各部分按下列順序所組成:
1、重複的併入保護(僅限規格)。
2、選用性檔案及版本控制識別。
3、這個單位所需的檔案。
4、模組文件(僅限規格)。
5、宣告(類別、類型、常數、物件和函數),以及其他的文字規格(前置條件和後置條件,以及不變量)。
6、併入這個模組的列入函數定義。
7、定義(物件和函數)以及實作 private 宣告。
8、版權聲明。
9、選用的版本控制歷程
上述的檔案內容排序首先呈現用戶端的相關資訊;並且與類別的 public、protected 和 private 等區段之排序的基本原理一致。
版權資訊可能必須放在檔案頂端,視企業政策而定。
應在每一個標頭檔中使用下列建構, 來防止併入及編譯重複的檔案:
#if !defined(module_name) // 使用前置處理器符號 #define module_name // 來防止重複 // 併入... // 在這裡宣告 #include "module_name.inlines.cc" // 選擇性列入 // 併入於此。 // 在併入模組的列入函數之後, // 沒有其他的宣告。 #endif // End of module_name.hh
使用模組檔案名稱作為併入保護符號。對符號使用與模組名稱相同的大小寫。
No_Inline
" 條件式編譯符號來推翻列入編譯使用下列的條件式編譯建構,來控制可列入函數的列入編譯對行外編譯:
// 在 module_name.inlines.hh 頂端 #if !defined(module_name_inlines) #define module_name_inlines #if defined(No_Inline) #define inline // 廢除行內關鍵字 #endif ... // 列入定義位置 #endif // End of module_name.inlines.hh // 在 module_name.hh 結尾 // #if !defined(No_Inline) #include "module_name.inlines.hh" #endif // 在併入 module_name.hh 之後,// 於 module_name.cc 頂端 // #if defined(No_Inline) #include "module_name.inlines.hh" #endif
條件式編譯建構類似於多個併入保護建構。如果未定義 No_Inline
符號,則列入函數會使用模組規格編譯,並自動排除在模組實作之外。如果有定義 No_Inline
符號,列入定義會被排除在模組規格之外,但是會被併入在模組實作之內,同時關鍵字 inline
會被廢除。
在行外編譯列入函數時,上述技術可以減少程式碼抄寫。使用條件式編譯時,會將單一的列入函數複本編譯到定義方模組中;相對於抄寫的函數,此函數會在編譯器 switch 指定行外編譯時,被編譯為每個使用中的模組中的「靜態」函數(內部鏈結)。
使用條件編譯會增加維護建置相依關係中所涉及的複雜度。管理這個複雜性的方式,是一律將標頭及列入函數定義視為單一邏輯單元來處理: 因而實作檔視標頭及列入函數定義檔而定。
以視覺化方式描述巢狀陳述式時,應使用一致的縮排;2 格到 4 格的縮排經驗證能夠達到這個目的最佳視覺效果。我們建議使用 2 格的一般縮排。
複合或建構陳述式定界字元 ({}
) 的縮排層次應與周圍的陳述式相同(這意味著 {}
垂直對齊)。建構內的陳述式則應按所選的空格數縮排。
switch
陳述式之 case label 的縮排層次應與 switch
陳述式的縮排層次相同;這樣 switch
陳述式內的陳述式可以從 switch
陳述式本身及 case label 內縮 1 個縮排層次。
if (true) { // 新建構 foo(); // 建構內的陳述式 // 縮排 2 個空格。} else { bar(); } while (expression) { statement(); } switch (i) { case 1: do_something();// 陳述式從 switch 陳述式 // 本身內縮 1 個 break; // 縮排層次。case 2: //... default: //... }
2 個空格的縮排是以下兩者之間的折衷辦法: 能夠輕易辨認建構,以及在程式碼漂流到螢幕或印出紙張的右邊緣太遠前能留有足夠的巢狀建構。
如果函數宣告無法放在一行上,則請將第一個參數放在與函數名稱同一行上;後續的每一個參數都放在新的一行上,其縮排層次與第一個參數相同。以下所示的這種樣式的宣告和縮排,在函數傳回類型和名稱以下留了一些空白;這種作法可以改善它們的能見度。
void foo::function_decl( some_type first_parameter, some_other_type second_parameter, status_type and_subsequent);
如果遵循上述的準則會導致折行,或是導致參數被縮排到太遠,則從函數名稱或範圍名稱(類別、名稱空間)後面開始縮排所有的參數,其中每一個參數都在不同的一行上:
void foo::function_with_a_long_name( // 函數名稱的能見度降低很多 some_type first_parameter, some_other_type second_parameter, status_type and_subsequent);
另請參閱以下的對齊規則。
應限制程式行的長度上限,以防止印在標準 (letter) 或預設印出紙張大小上時流失資訊。
如果縮排層次導致巢狀深度較深的陳述式漂移到太過右邊,而且要延伸的陳述式遠超過右邊距,則可能是考慮將程式碼拆成幾個較小的、較好控制的函數的好時機。
當函數宣告、定義和呼叫中的參數清單或是列舉宣告中的列舉元無法放在一行上,請將每一個清單元素後面的行折行,並將每一個元素放在不同的一行上(另請參閱「從函數名稱或範圍名稱後面開始縮排函數參數」)。
enum color { red, orange, yellow, green, //... violet };
如果類別或函數範本宣告過長,請將它折疊到範本引數清單後面的連續行上。例如(從標準反覆子庫 [X3J16, 95] 宣告):
template <class InputIterator, class Distance> void advance(InputIterator& i, Distance n);
本章提供有關在程式碼中使用註解的指引。
註解應用來補充程式碼,而不可改寫它:
針對每一個註解,程式設計師應能夠輕易地回答下列問題: 「這個註解有什麼加值效果?」。一般而言,選擇好的名稱經常可以讓您不需要註解。編譯器不會檢查註解,除非它們參與某些正式的「程式設計語言 (PDL)」;因此,按照單一維護點的原則,就算得多用幾個宣告,設計決策仍應在程式碼中而非註解中表達。
C++ 樣式 "//
" 註解定界字元應比 C 樣式 "/*...*/
" 優先使用。
C++ 樣式註解的能見度較高,而且可以降低由於遺漏註解結束定界字元而意外註銷一大塊程式碼的風險。
/* 遺漏註解結束定界字元的註解開始 do_something(); do_something_else(); /* 關於 do_something_else 的註解 */ // 這裡是註解結束 ---> */ // do_something 和 // do_something_else // 都意外被註銷! Do_further();
註解應放在靠近加以註解之程式碼的地方;採用相同的縮排層次,並用空白註解行附加到程式碼。
適用於多個、連續的來源陳述式的註解,應放在陳述式的上方-作為陳述式的簡介。同樣地,與個別陳述式相關聯的註解應放在陳述式的下方。
// 陳述式前的註解,// 適用於若干個下列的陳述式 // ... void function(); // // 前述陳述式的 // 陳述式後註解。
請避免將註解放在與程式碼建構同一行上: 它們經常會變成沒有對齊。不過,還是可以容許這類的註解作為長宣告中的元素之說明,例如列舉宣告中的列舉元。
避免使用含有像是作者、電話號碼、建立和修改日期等資訊的標頭: 作者和電話號碼很快就會變成過時;而建立和修改日期以及修改的原因,則最好由配置管理工具(或是其他某種格式的版本歷程檔案)來維護。
即使是主要建構(如函數和類別),也請避免使用垂直列、封閉的頁框或方框;它們只會使畫面更加雜亂,而且難以保持一致。使用空白行來分隔相關的程式碼建構,而不要用太長的註解行。使用單一空白行來分隔函數或類別內的建構。使用雙空白行來將函數彼此分隔。
頁框或表單的外觀可能較一致,並且會提醒程式設計師記載程式碼,但是它們往往導致段落化的風格 [Kruchten, 94]。
在單一註解建構內使用空註解(而非空行)來分隔段落
// 這裡的部分說明 // 需要延續到後續的段落。// // 上面的空註解行 // 可讓人清楚看見 // 這是同一個註解建構的另一個段落。
避免在註解中重複程式 ID 以及重複在他處出現的資訊-而提供指向該資訊的指標。否則,任何程式變更都可能需要在多個地方維護。若是未能在每個地方都進行必要的註解變更,將會導致誤導或錯誤的註解:這些結果比完全沒有註解還要糟糕。
永遠致力於撰寫自編文件碼而非提供註解。可以達到這個目的的作法是:選擇較佳的名稱;使用額外的暫存變數;或是更改程式碼的結構。注意註解中的風格、語法和拼字。使用自然語言註解,而不用過於簡潔的或含意模糊的風格。
do { ... } while (string_utility.locate(ch, str) != 0); // 找到它時結束搜尋迴路。替換為:
do { ... found_it = (string_utility.locate(ch, str) == 0); } while (!found_it);
雖然較建議採用自編文件碼而非註解,但是通常在說明複雜的程式碼部分之外,還有需要提供其他的資訊。有需要的資訊指的至少是下列各項的說明文件:
將程式碼說明文件與宣告搭配使用,應已足供用戶端使用程式碼所需;單單使用 C++ 並無法充分表達類別、函數、類型和物件的完整語意,因此說明文件乃是必要的。
本章提供有關選擇各種 C++ 實體的指引。
能想出一個好的類型實體(類別、函數、類型、物件、文字、異常狀況、名稱空間)名稱並不是件容易的事。就中型到大型的應用程式而言,這個問題的挑戰性甚至更大:名稱衝突,以及缺乏同義字來指定不同但類似的概念,更增加了困難度。
使用命名慣例可以少費一點心思創造合適的名稱。除了這個好處之外,命名慣例還多了能強制程式碼一致的好處。命名慣例應提供有關下列各項的指引,就會相當有用:打字樣式(或是如何撰寫名稱);以及名稱構造(或是如何選擇名稱)。
使用哪一個命名慣例並不是那麼的要緊,最重要的是能夠保持一致地套用它。命名的一致性遠比實際的慣例來得重要;一致性維繫了將意外減至最少的原則。
由於 C++ 是一種區分大小寫的語言,也因為 C++ 社群廣泛地使用若干個迴異的命名慣例;要達到命名完全一致的可能性極低。我們建議依據主機環境(例如,UNIX 或 Windows)以及專案所用的原則庫來挑選專案的命名慣例;要達到最大的程式碼一致性:
細心的讀者會發現本文中的範例目前並沒有遵循所有的準則。有一部分的原因是這些範例是從多個來源衍生而來;也因為要節省篇幅,因此也就沒有嚴密地套用格式化準則。但是訊息是「遵其言,不遵其行」。
具有單前端底線 ('_') 的名稱經常會被程式庫函數("_main
" 和 "_exit
")使用。具有雙前端底線 ("__") 或是單前端底線後接大寫字母的名稱已保留供編譯器內部使用。
另請避免具有鄰接底線的名稱,因為經常難以分辨確切的底線數。
要記得只有大小寫不同的類型名稱之間的差異相當困難,因此很容易把它們混淆。
在下列情況下可以使用縮寫:它們在應用程式領域中被普遍使用(例如,FFT 代表 Fast Fourier Transform),或是它們定義於專案可以辨識的縮寫清單中。否則,就非常有可能到處都出現類似但又不是完全相同的縮寫,造成稍後的混淆和錯誤(例如,track_identification 縮寫成 trid、trck_id、tr_iden、tid、tr_ident 等等)。
使用字尾作為實體的分類類型(如 type 代表類型,error 代表異常狀況),對於授予程式碼的瞭解通常不是很有效。諸如 array 和 struct 等字尾也意指特定的實作;這在發生實作變更(變更 struct 或 array 的表示法)時,對於任何用戶端程式碼會有負面的效果或是會讓人誤解。
不過,在若干個受限的情況下,字尾可能非常有用:
從使用的角度選擇名稱;並搭配使用形容詞和名詞來加強當地(特定背景)的意涵。另請確定名稱符合它們的類型。
慎選名稱,讓像是:
object_name.function_name(...); object_name->function_name(...);
等構造易讀而且能夠清楚顯示它的意義。
為了加速打字而調整為使用短名稱或縮寫名稱並不恰當。選擇單字母及過短的 ID 往往表示選擇極糟或是只求輕省。異常狀況是相當著名的實例,像是使用 E 代表自然對數的基底;或是 Pi。
可惜編譯器及支援工具有時會限制名稱的長度;因此,請小心確定長名稱不是只有最後幾個字元不同: 這些工具可能會截斷不同的字元。
void set_color(color new_color) { ... the_color = new_color; ... }優於:
void set_foreground_color(color fg)以及:
oid set_foreground_color(color foreground);{ ... the_foreground_color = foreground; ... }
第一個範例中的命名優於其他兩個範例:new_color
合格並符合其類型;從而強化了函數的語意。
在第二個案例中,讀者直覺上可能會推斷 fg
是要表示前景;然而,在任何好的程式設計風格中,不應留有任何事項給讀者去直覺或推斷。
在第三個案例中,當使用參數 foreground
(遠離其宣告)時,會導致讀者認為 foreground
實際上是表示前景顏色。然而,有任何類型可以隱含地轉換成 color
是可以理解的。
從名詞和形容詞形成名稱,並確定名稱符合它們遵循自然語言的類型,以及提升程式碼的可讀性和語意。
有一部分是英文字的名稱之拼字應正確,並且符合專案要求的格式,亦即,從頭到尾英文或美式英文,但不可兩者都有。註解也是一樣。
針對 Boolean 物件、函數及函數引數,使用採正面形式的述詞子句,例如,found_it
、is_available
,但是不要用 is_not_available
。
採負面述詞時,雙重負面述詞就更難以瞭解。
如果系統分解成子系統,請使用子系統名稱作為名稱空間名稱,以分割及最小化系統的廣域名稱空間。如果系統是一個程式庫,請使用最外面的單一名稱空間代表整個程式庫。
賦予一個有意義的名稱給每一個子系統或程式庫名稱空間;另外賦予它一個縮寫或字首別名。請選擇一個不太可能會衝突的縮寫或字首別名,例如,ANSI C++ 初稿標準程式庫 [Plauger,
95] 定義 std
作為 iso_standard_library
的別名。
如果編譯器尚未支援名稱空間構造,請使用名稱字首來模擬名稱空間。例如,可以在系統管理子系統介面中的公用名稱前面加上 syma(System Management 的簡寫)。
使用名稱空間來含括潛在的廣域名稱,有助於避免當獨立開發(由子專案小組或供應商開發)程式碼時發生名稱衝突。必然的結果是只有名稱空間名稱是廣域的。
使用採單數形式的一般名詞或名詞短語,賦予類別一個表達其抽象化的名稱。使用較為一般的名稱代表基本類別,並使用特殊化的名稱代表衍生類別。
typedef ... reference; // 來自標準程式庫 typedef ... pointer; // 來自標準程式庫 typedef ... iterator; // 來自標準程式庫 class bank_account {...}; class savings_account : public bank_account {...}; class checking_account : public bank_account {...};
當物件及類型的名稱有衝突或是缺少適宜的名稱時,請對物件使用簡稱,並加上諸如 mode、
kind、code
等字尾作為類型名稱。
當表達代表物件集合的抽象化時,請使用複數格式。
typedef some_container<...> yellow_pages;
當除了物件集合之外還需要其他的語意時,請使用標準程式庫中的下列各項作為行為型樣和名稱字尾:
對於沒有傳回值的函數(宣告為 void 傳回類型的函數)或是按指標或按參照參數傳回值的函數,請使用動詞或動作詞組。
對於按非 void 函數傳回類型傳回單一值的函數,請使用名詞或名詞性實詞。
對於具有共同操作指令(行為型樣)的類別,請使用從所選專案清單中所取得的操作指令名稱。例如:begin、end、insert、erase(標準程式庫中的儲存區操作指令)。
避免 "get" 和 "set" 命名心態(在函數前置字首 "get" 和 "set"),特別是針對 getting 和 setting 物件屬性的公用操作指令。操作指令命名應停留在服務層次的類別抽象化和供應;getting 和 setting 物件屬性是低階實作詳細資料,若是公開會使封裝減弱。
對於傳回 Boolean(述詞)的函數,請使用形容詞(或過去分詞)。針對述詞,在名詞前置字首 is 或 has 往往非常有用,可以使名稱被讀為正面的確認。當已經對物件、類型名稱或列舉文字使用簡稱時,這也非常有用。在時態方面,請注意精確和一致。
void insert(...); void erase(...); Name first_name(); bool has_first_name(); bool is_found(); bool is_available();
請勿使用負面名稱,因為這可能導致有雙重負面的表示式(例如,!is_not_found
);使程式碼更難以瞭解。在某些情況下,也可以藉由使用反義字(例如 "is_invalid
"
而非 "is_not_valid
")來使負面述詞成為正面述詞,而不會改變其語意。
bool is_not_valid(...); void find_client(name with_the_name, bool& not_found);應重新定義為:
bool is_valid(...); void find_client(name with_the_name, bool& found);
當作業具有相同的用途時,請使用超載而非嘗試尋找同義字: 這可以將系統中的概念和差異數減至最少,從而降低其整體的複雜度。
當超載運算子時,請確定保持了該運算子的語意;如果無法維持運算子的慣用意義,請選擇另一個函數名稱而非超載運算子。
如果要指出獨特性,或是顯示這個實體是動作的主要焦點,請在物件或參數名稱前面冠上 "the
" 或
"this
"。如果要指出次要、暫時、輔助的物件,請在它前面冠上 "a
" 或 "current
":
void change_name( subscriber& the_subscriber, const subscriber::name new_name) { ... the_subscriber.name = new_name; ... } void update(subscriber_list& the_list, const subscriber::identification with_id, structure& on_structure, const value for_value); void change( object& the_object, const object using_object);
由於異常狀況必須只能用來處理錯誤狀況,因此請使用能明確傳達負面想法的名詞或名詞短語:
overflow、threshold_exceeded、bad_initial_value
請使用專案所同意清單中的單字(如 bad、incomplete、invalid、wrong、missing 或 illegal)作為名稱的一部分,而非照計劃使用 error 或 exception,這些名稱並沒有傳達特定的資訊。
浮點數文字中的字母 'E' 以及十六進位數 'A' 到 'F' 應一律為大寫。
本章提供有關各種 C++ 宣告種類的用法和格式指引。
在有 C++ 語言中的名稱空間特性之前,管理名稱範圍的方法非常有限;因此,廣域名稱空間變成過於廣泛採用,導致了一些衝突,使某些程式庫無法在同一個程式中一起使用。新的名稱空間語言特性解決了廣域名稱空間的氾濫問題。
這表示只有名稱空間名稱可以為廣域;其他的所有宣告都應在某個名稱空間的範圍內。
若忽略這個規則,則最終可能會導致名稱衝突。
如果需要非類別功能的邏輯分組(如類別種類),或是範圍遠大於類別的功能(如程式庫或子系統),請使用名稱空間來邏輯統一宣告(請參閱「使用名稱空間來按子系統或程式庫分割潛在的廣域名稱」)。
在名稱中表達功能的邏輯分組。
namespace transport_layer_interface { /* ... */ }; namespace math_definitions { /* ... */ };
使用廣域及名稱空間範圍資料與封裝原則相對。
類別是 C++ 中的基本設計和實作單位。它們應用來擷取領域和設計抽象化,並作為實作「抽象資料類型 (ADT)」的封裝機制。
class
而非 struct
來實作抽象資料類型使用 class
類別-鍵值而非 struct
來實作類別(抽象資料類型)。
如在 C 中一樣,使用 struct
類別-鍵值定義舊式資料結構
(POD),特別是在與 C 程式碼連結時。
雖然 class
與 struct
相等,而且可以交互使用,但是 class
有偏好的預設存取控制增強 (private),可以取得較佳的封裝。
採用一致的作法以區別 class
與 struct
引入了超越語言規則的語意差異:
class
變成用於攫取抽象化和封裝的最首要建構;而 struct
則代表純資料結構,可以在混合的程式設計語言程式中交換。
類別宣告中的存取指定元應該按 public、protected、private 的順序出現。
成員宣告的 public、protected、private 排序可確保對類別使用者最重要的資訊最先呈現,因而降低了類別使用者遍覽一些無關或實作詳細資料的需要。
使用 public 或 protected 資料成員降低了類別的封裝,並影響系統對於變化的彈性: public 資料成員會將類別的實作顯現給其使用者;protected 資料成員會將類別的實作顯現給其衍生類別。對類別的 public 或 protected 資料成員的任何變更,對於使用者及衍生類別都會有重大影響。
初次看到時,直覺上這項準則似乎自相矛盾: 夥伴關係顯現了一個私密組件給夥伴,因此它如何能夠保持封裝?在類別高度互相依賴,且需要彼此內部認知的情況下,授予夥伴關係會比透過類別介面來匯出內部詳細資料來得好。
當作公用成員匯出內部詳細資料會賦予對類別用戶端的存取權,這並不是很令人滿意。匯出受保護的成員會賦予對潛在後代的存取權,這助長了階層式設計,也不是很理想。夥伴關係在沒有施行次分類限制下授予選擇性的專用存取權,因而能保護封裝,免於被那些需要存取權以外的所有存取者存取。
使用夥伴關係來保留封裝的一個很好的例子,就是授予夥伴關係給夥伴測試類別。藉由查看類別內容,夥伴測試類別可以實作適當的測試程式碼,但是在稍後,可以從交付的程式碼中除去夥伴測試類別。因此,既不會流失封裝,而且交付程式碼中也不會加入任何程式碼。
類別宣告應只包含函數宣告,不可包含函數定義(實作)。
在類別宣告中提供函數定義,會在類別規格上佈滿了實作細節;使類別介面較難以識別,且較難以讀取,並且增加了編譯相依關係。
在類別宣告中提供函數定義也會降低對於函數列入的控制(另請參閱「使用 No_Inline
條件式編譯符號來推翻列入編譯」)。
為了能夠在陣列或是任何 STL 儲存區中使用類別,類別必須提供公用的預設建構子,或是容許編譯器產生建構子。
上述規則有個例外,是在類別具有參照類型的非靜態成員時,在此情況下,經常無法建立有意義的預設建構子。因此,使用對物件資料成員的參照就是個疑問。
如果有必要,而且未明確宣告的話,編譯器將會隱含地為類別產生複製建構子和指派運算子。編譯器定義的複製建構子和指派運算子,會實作 Smalltalk 專有名詞中通稱的「表層副本」: 明確地說,就是以指標的逐位元複製進行逐成員複製。使用編譯器產生的複製建構子和預設指派運算子必定會洩漏記憶體。
// 改編自 [Meyers, 92]。void f() { String hello("Hello");// 假設 String 是 // 使用指向字元 // 陣列的指標來實作。{ // 輸入新的範圍(建構) String world("World"); world = hello; // 指派流失了 world 的 // 原始記憶體 } // 從建構結束時 // 解構 world; // 也間接解構 hello String hello2 = hello; // 指派已解構的 hello 給 // hello2 }
在上述程式碼中,存放字串 "World
" 的記憶體在指派之後流失。在離開內部建構,world
被摧毀;因此也流失 hello
所參照的記憶體。已解構的 hello
被指派給 hello2
。
// 改編自 [Meyers, 1992]。void foo(String bar) {}; void f() { String lost = "String that will be lost!"; foo(lost); }
在上述程式碼中,當使用引數 lost
呼叫 foo
時,會使用編譯器定義的複製建構子,將 lost
複製到 foo
中。由於使用指標的逐位元複製將 lost
複製到 "String that will be lost!"
,因此在從 foo
離開時,lost
的複本將會連同存放 "String that will be lost!" 的記憶體一起被摧毀(假設正確實作瞭解構子以釋放記憶體)。
// 取自 [X3J16, 95; section 12.8] 的範例 class X { public: X(const X&, int); // int 參數 // 未起始設定 // 沒有使用者宣告的複製建構子, // 因此編譯器隱含地加以宣告。}; // int 參數延遲起始設定 // 將建構子變成複製建構子。// X::X(const X& x, int i = 0) { ... }
在類別宣告中未看到 "standard" 複製建構子簽章的編譯器,會隱含地宣告一個複製建構子。然而,預設參數延遲起始設定可能會將建構子變成複製建構子: 導致使用複製建構子時含混不清。因此,使用複製建構子都會因為含混不清而造成格式不正確 [X3J16, 95; section 12.8]。
除非類別被明確設計為不可衍生,否則它的解構子應一律宣告為虛擬。
透過對基本類別類型的指標或參照刪除衍生的類別物件將會導致未定義的行為,除非將基本類別解構子宣告為虛擬。
// 為求簡潔而使用不當的風格 class B { public: B(size_t size) { tp = new T[size]; } ~B() { delete [] tp; tp = 0; } //... private: T* tp; }; class D : public B { public: D(size_t size) : B(size) {} ~D() {} //... }; void f() { B* bp = new D(10); delete bp; // 未定義的行為, // 原因是非虛擬的 // 基本類別解構子 }
可以防止使用單一參數建構子進行隱含的轉換,方法為使用 explicit
指定元宣告它們。
非虛擬函數實作不變的行為,並沒有要讓衍生類別予以特殊化。違反這項準則可能導致非預期的行為: 同一個物件可能在不同的時間展現不同的行為。
非虛擬函數為靜態連結;因此,針對物件所呼叫的函數是由變數的靜態類型(而不是物件的實際類型)所控管,此變數在以下的範例中分別參照 object-pointer-to-A 和 pointer-to-B。
// 改編自 [Meyers, 92]。class A { public: oid f(); // 非虛擬:靜態連結 }; class B : public A { public: void f(); // 非虛擬:靜態連結 }; void g() { B x; A* pA = &x; // 靜態類型:pointer-to-A B* pB = &x; // 靜態類型:pointer-to-B pA->f(); // 呼叫 A::f pB->f(); // 呼叫 B::f }
由於非虛擬函數藉由限制特殊化和多型性來限制子類別,因此在將它宣告為非虛擬之前,請小心確定所有子類別的作業的確不會改變的。
在建構期間,物件狀態的起始設定應由建構子起始設定程式(成員起始設定程式清單)來執行,而不是在建構子主體內使用指派運算子。
class X { public: X(); private Y the_y; }; X::X() : the_y(some_y_expression) { } // // "the_y" 由建構子起始設定程式起始設定而不要這麼做:
X::X() { the_y = some_y_expression; } // // "the_y" 由指派運算子起始設定。
物件建構在建構子主體執行之前,牽涉到建構所有的基本類別和資料成員。如果是在建構子主體內執行,資料成員的起始設定需要兩項作業(建構加上指派),相對於使用建構子起始設定程式執行時,只需要一項作業(使用起始值建構)。
以大型的巢狀聚集類別(類別包含類別,所含類別又包含類別等等)而言,多項作業(建構加上成員指派)的效能額外負荷可能非常可觀。
class A { public: A(int an_int); }; class B : public A { public: int f(); B(); }; B::B() : A(f()) {} // 未定義:呼叫成員函數,但是 A // 尚未起始設定 [X3J16, 95]。
如果在基本類別的所有成員起始設定程式完成之前,就從建構子起始設定程式直接或間接呼叫成員函數,則作業的結果為未定義 [X3J16, 95]。
在建構子中呼叫成員函數時應小心注意;請注意,即使呼叫了虛擬函數,所執行的是定義於建構子或解構子之類別或它的其中一個基本類別的虛擬函數。
static const
在定義整數的(整數)類別常數時,請使用 static const
資料成員,而不要使用 #define
的常數或廣域常數。如果編譯器不支援 static
const
,請改用 enum
。
class X { static const buffer_size = 100; char buffer[buffer_size]; }; static const buffer_size;或是這麼做:
class C { enum { buffer_size = 100 }; char buffer[buffer_size]; };但是不要這麼做:
#define BUFFER_SIZE 100 class C { char buffer[BUFFER_SIZE]; };
當編譯器指陳遺失沒有宣告為明確傳回類型之函數的傳回類型時,這可以防止混淆。
另請在函數的宣告和定義中都使用相同的名稱;這可將意外減至最少。提供參數名稱可以增進程式碼註解和可讀性。
傳回陳述式任意散佈在函數主體上,就好像是 goto
陳述式,會使程式碼較難以判讀及維護。
只有在非常小的函數中可以接受多個傳回值,但是條件是可以同時看到所有的傳回值
,而且程式碼有非常固定的結構:
type_t foo() { if (this_condition) return this_value; else return some_other_value; }
具有 void 傳回類型的函數不應有傳回陳述式。
應該盡量少建立會產生廣域副作用(變更它們的內部物件狀態以外的未公佈資料: 如廣域和名稱空間資料)的函數(另請參閱「將廣域及名稱空間範圍資料的使用減至最少」)。不過若是無法避免,則函數規格中應清楚載明任何副作用。
作為參數傳入必要物件會使程式碼較不依附於其內容、更為健全,而且更容易理解。
站在呼叫方的觀點,宣告參數的順序非常重要。
這個排序可以利用預設值來減少函數呼叫中的引數數目。
具有變動參數數值之函數的引數無法進行類型檢查。
在進一步重新宣告函數時,避免將預設值加入函數: 除了正向宣告之外,函數應只宣告一次。否則,這可能會導致不明瞭後續宣告的讀者困惑。
檢查函數是否有任何常數行為(傳回常數值;接受常數引數;或是運作時沒有副作用);並使用 const
指定元確認行為。
const T f(...); // 傳回常數 // 物件。T f(T* const arg); // 帶常數指標 // 的函數。// 可以變更指標指向的物件,// 但是無法變更指標。T f(const T* arg); // 帶有常數物件指標的函數,以及 T f(const T& arg); // 帶有常數物件參照的函數。 // 指標可以 // 變更,但不能指向 // 物件。T f(const T* const arg); // 帶有指向常數物件之常數指標 // 的函數。 // 指標及指標指向的物件 // 都不能變更。T f(...) const; // 沒有副作用的函數: // 不要變更其物件狀態; // 因此可以套用至 // 常數物件。
按值傳遞及傳回物件可能會引起沉重的建構子和解構子額外負荷。按參考位置來傳遞及傳回物件,可以避免建構子和解構子的額外負荷。
可以使用 Const
參照來指定按參考位置傳遞的引數不能修改。典型的用法範例為複製建構子和指派運算子:
C::C(const C& aC); C& C::operator=(const C& aC);
考量下式:
the_class the_class::return_by_value(the_class a_copy) { return a_copy; } the_class an_object; return_by_value(an_object);
當以 an_object
作為引數呼叫 return_by_value
時,呼叫了 the_class
複製建構子來將 an_object
複製到 a_copy
。然後再度呼叫 the_class
複製建構子,將 a_copy
複製到函數傳回暫時物件。在從函數返回時,呼叫了 the_class
解構子來摧毀 a_copy
。稍後將會再度呼叫 the_class
解構子來摧毀 return_by_value
所傳回的物件。上述不執行任何動作的函數呼叫之整體成本,是兩個建構子以及兩個解構子。
如果 the_class
是衍生類別,而且含有其他類別的成員資料,則情況甚至會更糟;基本類別及所含類別的建構子和解構子也會被呼叫,因而提升了函數呼叫所引發的建構子和解構子呼叫數。
上述的準則似乎是建議開發人員一律按參考位置來傳遞及傳回物件,不過,在需要物件時,請小心不要傳回對區域物件或參照的參照。傳回對區域物件的參照將會招致災禍,原因是在函數返回時,所傳回的參照會隨即連結到已被摧毀的物件!
區域物件在離開函數範圍時會隨即被摧毀;使用已被摧毀的物件將會招致災禍。
違反這項準則將會導致記憶體洩漏
class C { public: ... friend C& operator+( const C& left, const C& right); }; C& operator+(const C& left, const C& right) { C* new_c = new C(left..., right...); return *new_c; } C a, b, c, d; C sum; sum = a + b + c + d;
由於在計算總和時不會儲存運算子 + 的中間結果,因此不能刪除中間物件,這會導致記憶體洩漏。
違反這項準則即違反資料封裝,可能導致不好的意外。
#define
進行巨集展開但是要明智地使用列入;僅適用於極小的函數;列入大型函數可能會導致膨脹。
列入函數也會增加模組之間的編譯相依關係,因為必須使列入函數可供用戶端程式碼的編譯時使用。
[Meyers, 1992] 提供下列有點極端的不當巨集用法範例的詳細討論:
不要這麼做:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
而應該要這麼做:
inline int max(int a, int b) { return a > b ? a : b; }
巨集 MAX
有若干個問題:它不是安全類型;而且其行為是不確定的:
int a = 1, b = 0; MAX(a++, b); // a 遞增兩次 MAX(a++, b+10); // a 遞增一次 MAX(a, "Hello"); // 將整數與指標比較
當可以利用單一演算法,而且可以用小量的參數來參數化該演算法時,請使用預設參數而非函數超載。
使用預設參數有助於縮減超載函數的數目、 提升可維護性,以及減少函數呼叫中所需的引數數,而增進程式碼的可讀性。
當同一個語意作業(但是具有不同的引數類型)需要多個實作時,請使用函數超載。
在超載運算子時,請保留慣用的意義。別忘了定義相關的運算子,例如:operator==
和 operator!=
。
避免以具有單一整數引數的函數超載具有單一指標引數的函數:
void f(char* p); void f(int i);
下列呼叫可能會導致意外:
f(NULL); f(0);
超載解析程序解析成 f(int)
而非 f(char*)
。
operator=
傳回對 *this
的參照C++ 容許鏈結指派運算子:
String x, y, z; x = y = z = "A string";
由於指派運算子是向右結合,因此字串 "A
string
" 會指派給 z,z 指派給 y,y 指派給 x。對於 = 右邊的每一個表示式,會以由右至左的順序,有效地呼叫 operator=
一次。這也表示每一個 operator=
的結果都是物件,不過,左邊或右邊物件的傳回選項都是可能的。
由於好的作法指出指派運算子的簽章格式應一律為:
C& C::operator=(const C&);
只有左邊的物件是可能的(rhs 是 const 參照,lhs 是非 const 參照),因此應傳回 *this
。請參閱 [Meyers,
1992] 以取得詳細的討論。
operator=
檢查自行指派執行這項檢查有兩個很好的理由: 首先,衍生類別物件指派牽涉到呼叫繼承階層的每一個基本類別之指派運算子,跳過這些作業可以節省極可觀的執行時間。第二點,指派牽涉到解構 "lvalue" 物件,這是在複製 "rvalue" 物件之前。在自行指派的情況下,rvalue 物件在被指派之前就先被摧毀,因此指派的結果是未定義的。
請勿撰寫過長的函數,例如超過 60 行的程式碼。
將傳回陳述式的數目減至最少;1 是理想的數字。
盡力使 Cyclomatic Complexity 小於 10(如果是單一結束陳述式函數,為決策陳述式的總和 + 1)。
盡力使 Extended Cyclomatic Complexity 小於 15(如果是單一結束陳述式函數,為決策陳述式的總和 + 邏輯運算子 + 1)。
將平均的最大參照跨距減至最小(區域物件宣告與使用它的第一個實例之間的距離,以行為單位)。
在大型的專案中,通常會有在整個系統中都經常用到的類型集合;在此情況下,將這些類型一起收集在一或多個低階的廣域公用程式名稱空間中,可說是相當合理的(請參閱「避免使用基本類型」的範例)。
當目標是高度的可攜性,或是需要控制數值物件所占用的記憶體空間,或是需要特定範圍的值時,則不應使用基本類型。在這些情況下,最好是使用適當的基本類型,宣告具有大小限制的明確類型名稱。
請確定這些基本類型沒有透過迴圈計數器、 陣列索引等等,回到程式碼中。
namespace system_types { typedef unsigned char byte; typedef short int integer16; // 16 位元帶正負號整數 typedef int integer32; // 32 位元帶正負號整數 typedef unsigned short int natural16; // 16 位元不帶正負號整數 typedef unsigned int natural32; // 32 位元不帶正負號整數 ... }
基本類型的表示視實作而定。
typedef
來建立同義字以強化本端意義使用 typedef
來建立現有名稱的同義字,以提供更有意義的本端名稱及增進易讀性(這麼做並不沒有執行時間上的損失)。
typedef
也可用來提供完整名稱的標準。
// 來自標準程式庫的向量宣告 // namespace std { template <class T, class Alloc = allocator> class vector { public: typedef typename Alloc::types<T>reference reference; typedef typename Alloc::types<T>const_reference const_reference; typedef typename Alloc::types<T>pointer iterator; typedef typename Alloc::types<T>const_pointer const_iterator; ... } }
在使用 typedef
所建立的 typedef 名稱時,請勿在同一段程式碼中混用原始名稱與同義字。
優先使用具名常數。
請使用 const
或 enum
。
不要這麼做:
#define LIGHT_SPEED 3E8而應該要這麼做:
const int light_speed = 3E8;或是這麼做來調整陣列的大小:
enum { small_buffer_size = 100, large_buffer_size = 1000 };
除錯會困難得多,因為 #defines
所引進的名稱會在編譯前置處理期間被置換,不會出現在符號表中。
不是宣告為 extern
的 const
物件具有內部鏈結,在宣告時起始設定這些常數物件,可讓您在編譯時間使用起始設定程式。
常數物件可以存在唯讀記憶體中。
請在物件定義中指定起始值,除非該物件是自行起始設定。如果無法指派有意義的起始值,則指派 "nil" 值或考慮稍後才宣告該物件。
如果是大型物件,則通常不建議建構物件,然後稍後再使用指派加以起始設定,因為這麼做的代價可能極高(另請參閱「使用建構子起始設定程式而不是在建構子內指派」)。
如果在建構時無法適當地起始設定物件,則使用慣用的 "nil" 值(表示「未起始設定」)來起始設定物件。nil 值僅用於宣告「無法使用而只是已知的值」的起始設定: 其可能被演算法以受管制的方式拒絕:在物件未適當起始設定之前就使用時,指出變數未起始設定的錯誤。
請注意,並不是所有的類型都能夠宣告 nil 值,特別是模數類型,例如一個角度。在此情況下,請選擇最不可能的值。
本章提供有關各種 C++ 表示式和陳述式的用法和格式指引。
表示式的巢狀結構層次被定義為巢狀結構的括弧集數目,這些括弧需用來從左到右求表示式的值(如果忽略運算子優先順序的規則)。
太多層巢狀結構會使表示式較難以理解。
除非有運算子指定求值順序(逗號運算子、三元表示式,以及連結和分離),否則請勿假設任何特定的求值順序;假設可能導致不好的意外以及非可攜性。
例如,請不要在同一個陳述式中合併使用變數與變數的增量或減量。
foo(i, i++); array[i] = i--;
NULL
作為空值指標使用 0 或 NULL
代表空值指標是一個具高度爭議性的課題。
C 和 C++ 都將任何零值常數表示式定義為可以解譯為空值指標。由於 0 難以讀取,而且非常不鼓勵採用文字,因此在傳統上程式設計師都使用巨集 NULL
作為空值指標。可惜的是,並沒有 NULL
的可攜性定義。某些 ANSI C 編譯器使用 (void *)0,但這證明並不是個很好的 C++ 選項:
char* cp = (void*)0; /* 合法 C,但不是合法 C++ */
因此任何 (T*)0 格式的 NULL
定義都需要在 C++ 中強制轉型,而不只是零。在歷史上,提倡使用 0 作為空值指標的準則試圖減輕強制轉型的需求,並使程式碼更具可攜性。不過,許多的 C++ 開發人員使用 NULL
比使用 0 更自在,並且主張現今大部分的編譯器(說得更精確一點,是大部分的標頭檔)都將 NULL
實作為 0。
本準則的規則較贊同 0,因為不管 NULL
的值是多少,0 都一定能夠運作,不過由於有所爭論,這個觀點已被降級為提示層次,可以視適當與否加以遵循或忽略。
請使用新式的強制轉型運算子(dynamic_cast、static_cast、reinterpret_cast、const_cast
)而不是舊式的強制轉型。
如果您沒有新的強制轉型運算子,請避免完全強制轉型,特別是向下強制轉型(將基本類別物件轉換成衍生類別物件)。
請按照下列方式使用強制轉型運算子:
dynamic_cast
-使用執行時期資訊(具有虛擬函數的類別有執行時期類型資訊),在相同類別階層(子類型)的成員之間進行強制轉型。在這類類別之間進行強制轉型保證一定安全。static_cast
-在不使用執行時期資訊下,在相同類別階層的成員之間進行強制轉型;因此不保證一定安全。如果程式設計師無法保證類型安全,則使用
dynamic_cast
。reinterpret_cast
-在不相關的指標類型與整數的(整數)類型之間進行強制轉型;並不安全,應只在所提及的類型之間使用。const_cast
-強制轉型被指定為 const
參數之函數引數的「常數性」。請注意,const_cast
並不是有意要強制轉型真正被定義為 const 物件之物件的「常數性」(它可能在唯讀記憶體中)。請勿使用 typeid
來實作類型切換邏輯:
讓強制轉型運算子自動執行類型的檢查和轉換,請參閱 [Stroustrup,
1994] 以取得深入的討論。
請勿執行下列動作:
void foo (const base& b) { if (typeid(b) == typeid(derived1)) { do_derived1_stuff(); else if (typeid(b) == typeid(derived2)) { do_derived2_stuff(); else if () { } }
舊式的強制轉型凌駕類型系統,並可能導致編譯器未捕捉到的一些難以偵測的錯誤: 記憶體管理系統可會毀損、虛擬函數表格可能遭到損害,同時非相關物件可能會在物件被當作衍生類別物件存取時損壞。請注意,甚至讀取都可能造成損壞,因為不存在的指標或欄位可能會被參照。
新式的強制轉型運算子使類型轉換更為安全(在大部分的情況下),而且更為明確。
請勿使用舊式的 Boolean 巨集或常數;並沒有標準的 Boolean 值 true;請改用新的 bool
類型。
由於傳統上並沒有 true(1 或 ! 0)的標準值,因此非零表示式對 true 的比較可能會失敗。
請改為使用 Boolean 表示式。
請避免這麼做:
if (someNonZeroExpression == true) // 求值的結果可能不是 true最好是這麼做:
if (someNonZeroExpression) // 一律求值為 true 條件。
這類作業的結果幾乎永遠都是毫無意義的。
將指向已刪除物件的指標設為空值來避免災禍: 重複刪除非空值指標是有害的,但是重複刪除空值指標卻是無害的。
即使是在函數返回之前,也請在刪除之後一律指派空值指標值,因為稍後可能會加入新的程式碼。
當分支條件是離散值時,請使用 switch 陳述式而非一系列的 "else if"。
預設
分支以捕捉錯誤switch 陳述式應固定包含預設
分支,而且應使用預設
分支來捕捉錯誤。
這項原則可確保當引入新的 switch 值,並省略用來處理新值的分支時,現有的預設分支將會捕捉錯誤。
當反覆和迴圈是依據迴圈計數器來終止時,請使用 for 陳述式而不要使用 while 陳述式。
避免從迴圈終止狀況之外離開迴圈(使用 break、return
或 goto
);要提早跳到下一個反覆時,請用 continue
。此舉可以減少控制路徑流程數,使程式碼較易於瞭解。
goto
陳述式這似乎是個通用的準則。
這可能導致讀者混淆以及維護上的潛在風險。
本章提供有關記憶體管理及錯誤報告之主題的指引。
不應使用 C 程式庫 malloc
、calloc
和 realloc
函數來配置物件空間:
應使用 C++ 運算子 new 來達成這個目的。
唯一應使用 C 函數來配置記憶體的時機,是當記憶體要傳遞給 C 程式庫函數進行除去時。
請勿使用 delete 來釋出 C 函數所配置的記憶體,或是釋出用 new 所建立的物件。
若是在沒有空方括弧 ("[]") 註釋下,對陣列物件使用 delete,會導致第一個陣列元素遭到刪除,而造成記憶體洩漏。
由於並沒有太多使用 C++ 異常狀況機制所獲取的經驗,因此這裡呈現的準則將來可能會受到相當程序的修訂。
C++ 初稿標準定義兩個主要的錯誤種類: 邏輯錯誤和執行時期錯誤。邏輯錯誤是可以預防的程式設計錯誤。執行時期錯誤則是被定義為那些由於超出程式範圍的事件所引起的錯誤。
使用異常狀況的一般規則是系統在正常狀況以及沒有超載或硬體故障的情況下,不應引發任何異常狀況。
在開發期間,請使用函數的前置條件和後置條件確認來提供「極度的」錯誤偵測。
在實作最後的錯誤處理程式碼之前,確認提供了一個既簡單又實用的臨時錯誤偵測機制。確認還有一個額外的優點是能夠使用 "NDEBUG" 前置處理器加以編譯掉(請參閱「使用特定的值定義 NDEBUG 符號」)。
傳統上一直都使用確認巨集以達到這個目的,不過,參照 [Stroustrup, 1994] 可以提供範本替代方案,請參閱下列範例。
template<class T, class Exception> inline void assert ( T a_boolean_expression, Exception the_exception) { if (! NDEBUG) if (! a_boolean_expression) throw the_exception; }
請勿對常見的、預期的事件使用異常狀況: 異常狀況會在程式碼的正常控制流程中造成分裂,使它較難以理解及維護。
預期的事件應在程式碼的正常控制流程中處理;視需要使用函數傳回值或 "out" 參數狀態碼。
也不該使用異常狀況來實作控制結構: 這會是另一種形式的 "goto" 陳述式。
這可確保所有的異常狀況都支援最少的一般作業集,並且可用少量的高階處理常式集加以處理。
邏輯錯誤(領域錯誤、無效的引數錯誤、長度錯誤以及超出範圍錯誤)應用來指出應用程式領域錯誤、 無效的引數傳遞至函數呼叫、物件的建構超過它們被允許的大小,以及引數值不在被允許的範圍內。
執行時期錯誤(範圍錯誤及溢位錯誤)應用來指出只有在執行時期可以偵測到的運算和配置錯誤、 資料毀損,或是資源耗盡的錯誤。
在大型系統中,處理每一個層次的大量異常狀況會使程式碼難以判讀及維護。異常狀況處理程序可能會阻礙正常處理程序的進展。
將異常狀況數減至最少的方法有:
利用少量的異常狀況種類數,在抽象化之間平攤異常狀況。
擲出從標準異常狀況衍生的特殊化異常狀況,但是處理一般化異常狀況。
將「異常狀況」狀態新增至物件,並提供基本元素以明確檢查物件的有效性。
產生異常狀況的函數(不只是傳遞異常狀況)應在它們的異常狀況指定中,將所有的異常狀況宣告為已擲出: 它們不應靜靜地產生異常狀況而不警告它們的用戶端。
在開發期間,儘早由適當的記載機制報告異常狀況,其中包括在「擲出點」的異常狀況。
異常狀況處理常式應按照最多衍生到最基本類別的次序定義,以避免撰寫無法呼叫到的處理常式的程式碼;請參閱以下的 how-not-to-it 範例。這也可以確保最適當的處理常式捕捉到異常狀況,因為是按照宣告次序比對處理常式。
不要這麼做:
class base { ... }; class derived : public base { ... }; ... try { ... throw derived(...); // // 擲出衍生類別異常狀況 } catch (base& a_base_failure) // // 但是基本類別處理常式「捕捉」到,// 因為它先符合! { ... } catch (derived& a_derived_failure) // // 無法呼叫到這個處理常式! { ... }
避免捕捉所有的異常狀況處理常式(使用 ... 進行異常狀況處理常式宣告),除非異常狀況被重新擲出。
捕捉-所有的處理常式都應只用於區域內務處理,然後應重新擲出異常狀況,防止遮蔽了在這個層次上無法處理異常狀況的事實:
try { ... } catch (...) { if (io.is_open(local_file)) { io.close(local_file); } throw; }
在當作函數參數傳回狀態碼時,請一律指派一個值給參數,作為函數主體中的第一個可執行陳述式。有系統地使所有的狀態依預設成功或失敗。考量離開函數的所有可能的出口,包括異常狀況處理常式。
如果函數在沒有被提供適當的輸入時可能會產生錯誤輸出,請在函數中安裝程式碼,以受控制的方式來偵測及報告無效的輸入。請勿依賴註解來告知用戶端傳遞適當的值。註解幾乎遲早一定會被忽略,因此若是未偵測到無效的參數,則會導致難以除錯的錯誤。
本節論及事前不可攜的語言特性。
在作業系統之間並不是以標準的方式表示路徑名稱。使用它們將會引入平台相依關係。
#include "somePath/filename.hh" // Unix #include "somePath\filename.hh" // MSDOS
類型的表示和對齊與機器的架構非常有關。針對表示法和調整值所做的假設可能導致不好的意外以及可攜性降低。
特別是,不可試圖將指標儲存在 int
、long 或任何其他的數值類型中,這會非常難以移轉。
可伸展的常數可以避免字體大小變異的問題。
const int all_ones = ~0; const int last_3_bits = ~0x7;
機器架構可能指定某些類型的調整值。從調整值需求較為鬆散的類型轉換成調整值需求較為嚴格的類型,可能導致程式失效。
本章提供有關重複使用 C++ 程式碼的指引。
如果無法使用標準程式庫,請依據標準程式庫介面來建立類別:這會促進將來的移轉。
當行為不是取決於特定的資料類型時,請使用範本來重複使用行為。
使用 public
繼承關係來表達 "isa" 關係及重複使用基本類別介面,以及選擇性地重複使用它們的實作。
在重複使用實作或建立「部分/全部」關係的模型時,請避免使用 private 繼承關係。使用 containment 而非 private 繼承關係,最能在沒有重新定義下重複使用實作。
在重新定義基本類別作業時,需要使用 private 繼承關係。
多重繼承應謹慎使用,因為它會帶來很多額外的複雜性。[Meyers, 1992] 提供有關由於可能的名稱語義不明確以及繼承關係重複所帶來之複雜性的詳細討論。複雜性起源自:
語義不明確,這是當多個類別使用相同的名稱、 對名稱的任何未限定的參照原本就語意不明時。用其類別名稱來限定成員名稱可以解決語義不明確。不過,這具有使多型性失效以及將虛擬函數轉變成靜態連結函數的不適宜效果。
從同一個基底重複繼承(衍生類別透過繼承階層中的不同路徑,衍生基本類別多次)多個資料成員集,會引發不知道要使用多個資料成員集中的哪些資料成員的問題?
可以利用虛擬繼承(繼承虛擬基本類別)來防止多重繼承資料成員。那麼為何不一直使用虛擬繼承呢?虛擬繼承有改變基礎物件表示以及降低存取效率的負面影響。
制定原則來要求所有繼承都必須是虛擬的,同時強制所有包含的空間和時間懲罰將會過於獨斷。
因此,多重繼承需要類別設計師是一個對於將來使用他們的類別有洞察力的人: 以便能夠作出要使用虛擬還是非虛擬繼承的決定。
本章提供有關編譯問題的指引
請勿在模組規格中併入只有模組的實作需要的其他標頭檔。
當只需要指標或參照有效範圍時,請避免為了獲得其他類別之有效範圍的目的而在規格中併入標頭檔;而應使用正向宣告。
// 模組 A 的規格,內含於 "A.hh" 檔 #include "B.hh" // Don't include when only required by // 請勿併入。 #include "C.hh" // Don't include when only required by // 只有參照需要時請勿併入;而應使用正向宣告。 class C; class A { C* a_c_by_reference; // Has-a by reference. }; // "A.hh" 結束
將編譯相依關係降至最低是某些設計慣用句或型樣的基本原理,各自名為:Handle 或 Envelope [Meyers, 1992],或是 Bridge [Gamma] 類別。藉由均攤兩個相關類別之間的類別抽象化責任,一個提供類別介面,另一個提供實作;類別與其用戶端之間的相依關係會被降至最低,因為對實作(實作類別)的任何變更已不再導致重新編譯用戶端。
// 模組 A 的規格,內含於 "A.hh" 檔 class A_implementation; class A { A_implementation* the_implementation; }; // "A.hh" 結束
這種方法也可讓介面類別和實作類別被特殊化為兩個各別的類別階層。
NDEBUG
符號傳統上使用 NDEBUG
符號來編譯掉使用確認巨集所實作的確認程式碼。傳統的用法參照範例在需要符號來消除確認時加以定義;不過,開發人員經常未注意到出現確認,因此從未定義該符號。
我們主張使用確認的範本版;在此情況下,如果需要確認程式碼,則必須賦予明確值 0 給 NDEBUG
符號;賦予非零值來加以消除。在沒有提供特定值給 NDEBUG
符號下所編譯的任何確認程式碼,都會產生編譯錯誤;因此,請讓開發人員注意確認程式碼的存在。
以下是這本小冊子中所呈現的所有準則的摘要。
善用常識
一律使用 #include
來獲得對模組規格的存取權
不可宣告開頭為一或多個底線 ('_') 的名稱
將廣域宣告限定為名稱空間
一律對具有明確宣告建構子的類別提供預設建構子
一律對具有指標類型資料成員的類別宣告複製建構子和指派運算子
不可將建構子參數重新宣告為具有預設值
一律將解構子宣告為虛擬
不可重新定義非虛擬函數
不可從建構子起始設定程式呼叫成員函數
不可傳回區域物件的參照
不可傳回 new 所起始設定的解除參照指標
不可傳回對成員資料的非常數參照或指標
讓 operator=
傳回對 *this
的參照
讓 operator=
檢查自行指派
不要將常數物件的「常數性」強制轉型
不要假設任何特定的表示式評估順序
不要使用舊式的強制轉型
對 Boolean 表示式使用新的 bool
類型
不要針對 Boolean 值 true 直接比較
不要將指標與同一個陣列內的物件比較
一律指派空值
指標值給已刪除的物件指標
一律對 switch 陳述式提供預設分支以捕捉錯誤
不要使用 goto 陳述式
避免混合 C 和 C++ 的記憶體作業
在刪除 new 所建立的陣列物件時,一律 delete[]
不要使用寫在程式中的檔案路徑
不要假設類型的表示法
不要假設類型的調整值
請勿依賴特定的下溢或溢位行為
不要從「較短的」類型轉換成「較長的」類型
使用特定值定義 NDEBUG
符號
將模組的規格和實作放在分開的檔案中
挑選一組副檔名集,以區別標頭與實作檔案
每個模組規格避免定義多個類別
避免將實作 private 宣告放在模組規格中
將模組列入函數定義放在各別的檔案中
如果顧慮到程式的大小,請將大型模組分成多個轉換單位
隔離平台相依關係
防止併入重複的檔案
使用 "No_Inline
" 條件式編譯符號來推翻列入編譯
對巢狀陳述式使用小型、一致的縮排樣式
從函數名稱或範圍名稱後面開始縮排函數參數
使用會合乎標準印出紙張大小的最大行長度
使用一致的行摺疊
使用 C++ 樣式的註解而非 C 樣式的註解
盡可能使註解靠近程式碼
避免使用行尾註解
避免使用註解標頭
使用空註解行來分隔註解段落
避面冗餘
撰寫自編文件碼而非註解
文件類別和函數
選擇命名慣例並保持一致地套用它
避免使用只有大小寫不同的類型名稱
避免使用縮寫
避免使用字尾來表示語言建構
選擇明確、易辨認、有意義的名稱
在名稱中使用正確的拼字
對 Boolean 使用正面的述詞子句
使用名稱空間來按子系統或按程式庫分割潛在的廣域名稱
使用名詞或名詞短語作為類別名稱
使用動詞作為程序類型的函數名稱
希望有相同的一般意義時,請使用函數超載
以符合語法規則的元素增強名稱來強調意義
選擇含有負面意義的異常狀況名稱
使用專案定義的形容詞作為異常狀況名稱
使用大寫字母代表浮點指數和十六進位數。
使用名稱空間來將非類別功能分組
將廣域及名稱空間範圍資料的使用減至最少
使用 class 而非 struct 來實作抽象資料類型
按可存取性遞減的順序宣告類別成員
避免宣告 public 或 protected 資料成員作為抽象資料類型
使用夥伴來保持封裝
避免在類別宣告中提供函數定義
避免宣告太多轉換運算子和單一參數建構子
審慎地使用非虛擬函數
使用建構子起始設定程式而不是在建構子內指派
在建構子及解構子中呼叫成員函數時請小心
對整數類別常數使用 static const
一律宣告明確的函數傳回類型
一律在函數宣告中提供形式參數名稱
盡力使函數具有單一傳回點
避免建立有廣域副作用的函數
按重要性和變化性遞減的順序宣告函數參數
避免宣告具有變動參數數值的函數
避免以預設參數重新宣告函數
在函數宣告中盡量使用常數
避免按值來傳遞物件
優先使用列入函數而非 #define
進行巨集展開
使用預設參數而非函數超載
使用函數超載來表達共同語意
避免使用帶指標和整數的超載函數
將複雜度降至最低
避免使用基本類型
避免使用文字值
避免使用前置處理器 #define
指令來定義常數
在靠近物件最先使用的地方加以宣告
一律在宣告時起始設定 const 物件
在定義時起始設定物件
分支 Boolean 表示式時使用 if 陳述式
分支離散值時使用 switch 陳述式
當迴圈中需要反覆前測試時,使用 for 陳述式或 while 陳述式
當迴圈中需要反覆後測試時,使用 do-while 陳述式
避免在迴圈中使用 jump 陳述式
避免在巢狀範圍內遮蔽 ID
在開發期間任意使用確認來偵測錯誤
僅對真正的異常狀況使用異常狀況
從標準異常狀況衍生專案異常狀況
將給定的抽象化所用的異常狀況數減至最少
將所有的異常狀況宣告為已擲出
按最多衍生到最基本類別的次序定義異常狀況處理常式
避免捕捉所有的異常狀況處理常式
請確定函數狀態碼具有適當的值
在本端環境執行安全檢查;不要期待您的用戶端這麼做
盡可能使用「可伸展的」常數
盡可能使用標準程式庫元件
定義全專案的廣域系統類型
使用 typedef 來建立同義字以強化本端意義
使用冗餘括弧來使複合表示式更明確
避免將表示式巢狀結構過深
使用 0 而非 NULL
作為空值指標
在最先出現時就報告異常狀況