改善C++程序的150個建議
第一部分 語法篇
建議1:區分0的4種面孔
- 整型0,32位(4個字節);
- 空指針NULL,指針與int類型所佔空間是一樣的,都是32位;
- 字符串結束標誌’\0’,8位,一個字節,與’0’有區別;
- 邏輯FALSE/false,FALSE/TRUE是int類型,而false/true是bool類型。
建議5:不要忘記指針變量的初始化
- 可以將其初始化為空指針0(NULL);
- 對於全局變量來說,在聲明的同時,編譯器會悄悄完成對變量的初始化。
建議6:明晰逗號分隔表達式的奇怪之處
- 在使用逗號分隔表達式時,C++會確保每個表達式都被執行,而整個表達式的值則是最右邊表- 達式的結果;
- 在C++中,逗號分隔表達式既可以用作左值,也可以用作右值。
建議9:防止重複包含頭文件
注意在大型項目中的形式應類似下面:
#ifndef _PROJECT_PATH_FILE_H
#define _PROJECT_PATH_FILE_H
// ...
#endif
建議10:優化結構體中元素的佈局
把結構體中的變量按照類型大小從小到大依次聲明,儘量減少中間的填充字節
。
建議11:將強制轉型減到最少
- const_cast
(a):它用於從一個類中去除以下這些屬性:const、volatile和__unaligned; - dynamic_cast
(a):它將a值轉換成類型為T的對象指針,“安全的向下轉型”,cost較大; - reinterpret_cast
(a):它能夠用於諸如One_class到Unrelated_class這樣的不相關類型之間的轉換,“強行轉換”,因此它是不安全的; - static_cast
(a):它將a的值轉換為模板中指定的類型T,但是,在運行時轉換過程中,它不會進行類型檢查,不能確保轉換的安全性,最常用。
建議12:優先使用前綴操作符
對於整型和長整型的操作,前綴操作和後綴操作的性能區別通常是可以忽略的。
對於用戶自定義類型,優先使用前綴操作符。因為與後綴操作符相比,前綴操作符因為無須構造臨時對象而更具性能優勢
。
class A; // 類名為A
A A::operator ++ (); // 前綴操作符
A A::operator ++ (A); // 後綴操作符,有函數參數
建議13:掌握變量定義的位置與時機
在定義變量時,要三思而後行,掌握變量定義的時機與位置,在合適的時機於合適的位置上定義變量。 儘可能推遲變量的定義,直到不得不需要該變量為止;同時,為了減少變量名汙染,提高程序的可讀性,儘量縮小變量的作用域。
越local越好,儘量縮小scope。
建議17:提防隱式轉換帶來的麻煩
提防隱式轉換所帶來的微妙問題,儘量控制隱式轉換的發生;通常採用的方式包括:
- 使用非C/C++關鍵字的具名函數,用operator as_T()(具名函數,不會隱式調用)替換operator T()(T為C++數據類型)。
- 為單參數的構造函數加上explicit關鍵字。
建議18:正確區分void與void*
void是“無類型”,所以它不是一種數據類型;void*則為“無類型指針”,即它是指向無類型數據的指針,也就是說它可以指向任何類型的數據。
void發揮的真正作用是限制程序的參數與函數返回值:
- 如果函數沒有返回值,那麼應將其聲明為void類型;
- 如果函數無參數,那麼聲明函數參數為void。
對於void*:
- 任何類型的指針都可以直接賦值給它,無須強制轉型;
- 如果函數的參數可以是任意類型指針,那麼應聲明其參數為void*。
√: 其他類型指針 —> void*指針
×: void*指針 —> 其他類型指針
建議19:明白在C++中如何使用C
若想在C++中使用大量現成的C程序庫,就必須把它放到extern "C" {/ code /}中; 原因:C與C++編譯、鏈接方式有區別,”函數簽名”不同;C的函數簽名不帶函數類型信息,而C++中會附加函數類型信息;extern "C"的作用就是告訴C++鏈接器尋找調用函數的符號時,採用C的方式。
要實現在C++代碼中調用C的代碼,具體方式有以下幾種:
- 修改C代碼的頭文件,當其中含有C++代碼時,在聲明中加入extern "C";
在C++代碼中重新聲明一下C函數,在重新聲明時添加上extern "C";
- 在包含C頭文件時,添上extern "C"。
建議22:靈活地使用不同風格的註釋
版權、版本聲明:
/* ... */
內部註釋:
//
宏定義尾端註釋:
/* ... */
建議23:儘量使用C++標準的iostream
除了防止OJ中的TLE,一般推薦用C++的iostream。
建議25:儘量用const、enum、inline替換#define
- 對於簡單的常量,應該儘量使用const對象或枚舉類型數據,避免使用#define;
- 對於形似函數的宏,儘量使用內聯函數,避免使用#define。
總之一句話,儘量將工作交給編譯器,而不是預處理器
。
建議26:用引用代替指針
與指針不同,引用與地址沒有關聯,甚至不佔任何存儲空間
。
建議27:區分內存分配的方式
C++中,內存被分成了5個區:
- 棧區:執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元將自動被釋放。棧內存分配運算內置於處理器的指令集中,效率很高,但是所分配的內存容量有限;
- 堆區:new / delete;如果程序員沒有釋放掉,那麼在程序結束後,操作系統就會自動回收;
- 自由存儲區:malloc() / free() ;
- 全局/靜態存儲區:全局變量和靜態變量被分配到同一塊內存中,在以前的C語言中,全局變量又分為初始化的和未初始化的,在C++裡面沒有作此區分,它們共同佔用同一塊內存區;
- 常量存儲區:這是一塊比較特殊的存儲區,裡面存放的是常量,不允許修改。
建議29:區分new的三種形態
- 如果是在堆上建立對象,那麼應該使用new operator,它會為你提供最為周全的服務;
- 如果僅僅是分配內存,那麼應該調用operator new,但初始化不在它的工作職責之內。如果你對默認的內存分配過程不滿意,
想單獨定製,重載operator new是不二選擇
; - 如果想在一塊已經獲得的內存裡建立一個對象,那就應該用placement new。但是通常情況下不建議使用,除非是在某些對時間要求非常高的應用中,因為相對於其他兩個步驟,選擇合適的構造函數完成對象初始化是一個時間相對較長的過程。
建議31:瞭解new_handler的所作所為
在使用operator new申請內存失敗後,編譯器並不是不做任何的努力直接拋出std::alloc異常,在這之前,它會調用一個錯誤處理函數(這個函數被稱為new-handler),進行相應的處理。通常,一個好的new-handler函數的處理方式必須遵循以下策略之一:
- 使更大塊內存有效;
- 裝載另外的new-handler;
- 卸載new-handler;
- 拋出異常;
- 無返回。
建議32:藉助工具檢測內存洩露問題
內存洩露一般指的是堆內存的洩露
。檢測內存洩露的關鍵是能截獲對分配內存和釋放內存的函數的調用。通過截獲的這兩個函數,我們就能跟蹤每一塊內存的生命週期。每當成功分配一塊內存時,就把它的指針加入一個全局的內存鏈中;每當釋放一塊內存時,再把它的指針從內存鏈中刪除。這樣當程序運行結束的時候,內存鏈中剩餘的指針就會指向那些沒有被釋放的內存。這就是檢測內存洩露的基本原理。
檢測內存洩露的常用方法有如下幾種:
- MS C-Runtime Library內建的檢測功能,要在非MFC程序中打開內存洩露的檢測功能非常容易,只須在程序的入口處添加以下代碼:
CrtSetDbgFlag(_CrtSetDbgFlag(_CRTDBG_REPORT_FLAG)|_CRTDBG_LEAK_CHECK_DF);
- 外掛式的檢測工具:MS下BoundsChecker或Insure++;Linux下RationalPurify或Valgrind.
建議34:用智能指針管理通過new創建的對象
解決內存洩漏:
- 垃圾回收(Java);
- 智能指針(C++)
STL中的智能指針auto_ptr,要使用它,需要包含memory頭文件:
- auto_ptr對象不可作為STL容器的元素;
- auto_ptr缺少對動態配置而來的數組的支持;
- auto_ptr在被複制的時候會發生所有權轉移。
- 結論:不要使用auto_ptr!(C++11中已將其廢棄)
Boost庫中有更好用的智能指針
建議35:使用內存池技術提高內存申請效率與性能
經典的內存池技術,是一種用於分配大量大小相同的小對象的技術
。通過該技術可以極大地加快內存分配/釋放過程。內存池技術通過批量申請內存,降低了內存申請次數,從而節省了時間。
建議37:瞭解C++悄悄做的那些事
The “Big Three”:
- 構造函數,one or more
- 析構函數,one
- 拷貝構造函數,one
建議38:首選初始化列表實現類成員的初始化
類成員的初始化可採用兩種形式來完成:
- 在構造函數體重賦值完成;
- 用初始化類成員列表完成; Others:
- const成員變量
只能用成員初始化列表來完成初始化
,而不能在構造函數內被賦值; 如果類B中含有A類型的成員變量,而類A中又禁止了賦值操作,此時要想順利地完成B中成員變量的初始化,就必須採用初始化列表方式
。即使沒有禁用賦值操作,還是不推薦採用函數體內的賦值初始化方式。因為這種方式存在著兩種問題:第一,比起初始化列表,此方式效率偏低;第二,留有錯誤隱患。
對於初始化列表,初始化的順序與構造函數中的賦值方式不同,初始化列表中成員變量出現的順序並不是真正初始化的順序,初始化的順序取決於成員變量在類中的聲明順序。只有保證成員變量聲明的順序與初始化列表順序一致才能真正保證其效率。
議39:明智地拒絕對象的複製操作
在某些需要禁止對象複製操作的情形下,可以將這個類相應的拷貝構造函數、賦值操作符operator = 聲明為private
,並且不要給出實現。或者採用更簡單的方法:使用boost::noncopyable
作為基類。
建議40:小心,自定義拷貝函數
如果類內部出現了動態配置的資源
,我們就不得不自定義實現其拷貝函數了。在自定義拷貝函數時,應該保證拷貝一個對象的All Parts
:所有數據成員及所有的基類部分。
建議41:謹防因構造函數拋出異常而引發的問題
判斷構造對象成功與否,解決辦法:拋出一個異常。構造函數拋出異常會引起對象的部分構造,因為不能自動調用析構函數,在異常發生之前分配的資源將得不到及時的清理,進而造成內存洩露問題。所以,如果對象中涉及了資源分配,一定要對構造之中可能拋出的異常做謹慎而細緻的處理。
建議42:多態基類的析構函數應該為virtual
虛函數的最大目的就是允許派生類定製實現
。所以,用基類指針刪除一個派生類對象時,C++會正確地調用整個析構鏈,執行正確的行為,以銷燬整個對象。
在實際使用虛析構函數的過程中,一般要遵守以下規則:當類中包含至少一個虛函數時,才將該類的析構函數聲明為虛。因為一個類要作為多態基類使用時,它一定會包含一個需要派生定製的虛函數。相反,如果一個類不包含虛函數,那就預示著這個類不能作為多態基類使用
。同樣,如果一個類的析構函數非虛,那你就要頂住誘惑,決不能繼承它
,即使它是“出身名門”。比如標準庫中的string、complex、以及STL容器。
多態基類的析構函數應該是virtual的,也必須是virtual的
,因為只有這樣,虛函數機制才會保證派生類對象的徹底釋放;如果一個類有一個虛函數,那麼它就該有一個虛析構函數
;- 如果一個類不被設計為基類,那麼這個類的析構就應該拒絕為虛。
建議43:絕不讓構造函數為虛
虛函數的工作機制:虛函數的多態機制是通過一張虛函數表來實現的。在構造函數調用返回之前,虛函數表尚未建立,不能支持虛函數機制,所以構造函數不允許設為虛。
建議44:避免在構造/析構函數中調用虛函數
成員函數、包括虛成員函數,都可以在構造、析構的過程中被調用。當一個虛函數被構造函數(包括成員變量的初始化函數)或者析構函數直接或間接地調用時,調用對象就是正在構造或者析構的那個對象。其調用的函數是定義於自身類或者其基類的函數,而不是其派生類或者最低派生類的其他基類的重寫函數。
如果在構造函數或析構函數中調用了一個類的虛函數,那它們就變成普通函數了,失去了多態的能力。
對象不能在生與死的過程中讓自己表現出多態。
建議45:默認參數在構造函數中給你帶來的喜與悲
合理地使用默認參數可以有效地減少構造函數中的代碼冗餘,讓代碼簡潔而有力。但是如果不夠小心和謹慎,它也會帶來構造函數的歧義,增加你的調試時間。
建議46:區分Overloading、Overriding、Hiding之間的差異
- 重載(Overloading):是指同一作用域的不同函數使用相同的函數名,但是函數的參數個數或類型不同;
- 重寫(Overriding):是指在派生類中對基類中的虛函數重新實現,即函數名和參數都一樣,只是函數的實現體不一樣,派生類對基類中的操作進行個性化定製就是重寫。重寫需要注意的問題:
- 函數的重寫與訪問層級(public、private、protected)無關;
- const可能會使虛成員函數的重寫失效;
- 重寫函數必須和原函數具有相同的返回類型;
- 隱藏(Hiding):是指派生類中的函數屏蔽基類中具有相同名字的非虛函數。
建議47:重載operator=的標準三步走
- 不要讓編譯器幫你重載賦值運算符;
- 一定要檢查自賦值;
- 賦值運算符重載需返回*this的引用,引用之於對象的優點在於效率,為了能夠更加靈活地使用賦值運算符,選擇返回引用絕對是明智之舉;
- 賦值運算符重載函數不能被繼承。如果需要給類的數據成員動態分配空間,則必須實現賦值運算符。
建議48:運算符重載,是成員函數還是友元函數
運算符重載的四項基本原則:
- 不可臆造運算符;
- 運算符原有操作數的個數、優先級和結合性不能改變;
- 操作數中至少一個是自定義類型;
- 保持重載運算符的自然含義。
運算符的重載可採用兩種形式:成員函數形式和友元函數形式。
- 重載為成員函數時,已經隱含了一個參數,它就是this指針;對於雙目運算符,參數僅有一個;
- 當重載友元函數時,將不存在隱含的參數this指針;如果運算符被重載為友元函數,那麼它就獲得一種特殊的屬性,能夠接受左參數和右參數的隱式轉換,如果是成員函數版的重載則只允許右參數的隱式轉換。
- 一般說來,建議遵守一個不成文的規定:對雙目運算符,最好將其重載為友元函數,因為這樣更方便些;而對於單目運算符,則最好重載為成員函數。
建議49:有些運算符應該成對實現
很多運算符重載時最好成對實現,比如==與!=、<與>、<=與>=、+與+=、-與-=、與=、/與/=。
建議50:特殊的自增自減運算符重載
- (1). 後綴形式的參數並沒有被用到,它只是語法上的要求,為了區分而已;
- (2). 後綴形式應該返回一個const對象;這樣做是大有深意的。 我們知道操作符重載本質上是一個函數而已,它應該和操作符原來意義、使用習慣相似,而對於int內置類型來說的話,i++++是有語法錯誤的,故為了保持一致,重載之後的後綴形式也應該是這樣的。假如返回的不是const類型,那麼對於
MyInt t(1);
t++++; // t.operator++(0).operator++(0)
這樣的形式將是正確的,這顯然不是我們期望的。另外,只要看上面的後綴形式的定義即可知道,這樣寫t只是增加了1而不是我們期望的2,為什麼呢?因為第二次的調用是用第一次返回的對象來進行的,而不是用t,它違反了程序員的直覺。因此,為了阻止這些行為,我們返回const對象。
- (3). 我們應該儘可能調用前綴形式;為什麼呢?看看後綴形式的定義就可以知道,我們定義了一個臨時對象,臨時對象的創建、析構還是很費時間的。而前綴形式則不一樣,它的效率相對好一些。
建議51:不要重載operator&&、operator||以及operator
“&&”、“||”、“,”(逗號運算符)都具有較為特殊的行為特性,重載會改變運算符的這些特性,進而影響原有的習慣,所以不要去重載這三個可以重載的運算符。
建議52:合理地使用inline函數來提高效率
內聯函數具有與宏定義相同的代碼效率
,但在其他方面卻要優於宏定義
。因為內聯函數還遵循函數的類型和作用域規則。內聯函數一般情況下都應該定義在頭文件中
。
內聯函數的定義分為兩種方式:
- 顯式方式:在函數定義之前添加inline關鍵字,
內聯函數只有和函數體聲明放在一起時inline關鍵字才具有效力
; - 隱式方式:
將函數定義於類的內部
。一個給定的函數是否得到內聯,很大程度上取決於你正在使用的編譯器。
使用內聯函數應該注意:
- 內聯函數的定義必須出現在內聯函數第一次被調用之前。所以,它一般會置於頭文件中;
在內聯函數內不允許用循環語句(for, while)和開關語句(if-else, switch-case),函數不能過於複雜
;- 依據經驗,內聯函數只適合於只有1~5行的小函數;
- 對於內存空間有限的機器而言,慎用內聯。過分地使用內聯會造成函數代碼的過度膨脹,會佔用太多空間;
不要對構造/析構函數進行內聯
;- 大多開發環境不支持內聯調試,所以為了調試方便,不要將內聯優化放在調試階段之前。
建議53:慎用私有繼承
私有繼承會使基類的所有東西(包括所有的成員變量與成員函數)在派生類中變成private的,也就是說基類的全部在派生類中都只能作為實現細節,而不能成為接口
。私有繼承意味著“只有implementation 應該被繼承,interface應該被忽略”
,代表著是“is-implemented-in-terms-of”的內在關係。通常情況下,這種關係可以採用組合的方式來實現,並提倡優先使用組合的方案
。但是如果存在虛函數和保護成員,就會使組合方案失效,那就應使用私有繼承
。
建議54:抵制MI的糖衣炮彈
MI(多重繼承)意味著設計的高複雜性、維護的高難度性,儘量少使用MI。
建議55:堤防對象切片
多態的實現必須依靠指向同一類族的指針或引用。否則,就可能出現著名的對象切片(Object Slicing)問題
。所以,在既有繼承又有虛函數的情況下,一定要提防對象切片問題。
建議56:在正確的場合使用恰當的特性
- 虛函數:虛函數機制的實現是通過
虛函數表和指向虛函數表的指針來完成的
。關鍵字virtual告訴編譯器該函數應該實現晚綁定,編譯器對每個包含虛函數的類創建虛函數表VTable,以放置類的虛函數地址。編譯器密碼放置了指向虛函數表的指針VPtr,當多態調用時,它會使用VPtr在VTable表中查找要執行的函數地址; - 多重繼承:對於多重繼承來說,對象內部會有多個VPrt,所以這就使偏移量計算變得複雜了,而且會使-對象佔用的空間和運行時開銷都變大;
- 虛基類:它與多重繼承的情況類似,因為虛基類就是為了多重繼承而產生的;
運行時類型檢測(RTTI)
:是我們在程序運行時得到對象和類有關信息的保證。
建議57:將數據成員聲明為private
將數據成員聲明為private是具有相當充分的理由的:
- 實現數據成員的訪問控制;
- 在將來時態下設計程序,為之後的各種實現提供彈性;
- 保持語法的一致性。
建議59:明瞭如何在主調函數啟動前調用函數
如果想在主程序main啟動之前調用某些函數,調用全局對象的構造函數絕對是一個很不錯的方法。因為從概念上說,全局對象是在程序開始前已經完成了構造,而在程序執行之後才會實施析構。
建議60:審慎地在動、靜多態之間選擇
虛函數
機制配合繼承機制,生效於運行期
,屬於晚綁定
,是動多態
;
而模板
將不同的行為和單個泛化記號相關聯發生在編譯期
,屬於早綁定
,被稱為靜多態
。
- 動多態:它的技術基礎是繼承機制和虛函數,它在繼承體系之間通過虛函數表來表達共同的接口;
- 靜多態:它的技術基礎是模板。與動多態相比,靜多態始終在和參數“較勁兒”,它適用於所有的類,與虛函數無關。
從應用形式上看,靜多態是發散式
的,讓相同的實現代碼應用於不同的場合
;動多態是收斂式
的,讓不同的實現代碼應用於相同的場合
。
從思維方式上看,前者是泛型式編程風格,它看重的是算法的普適性
;後者是對象式編程風格,它看重的是接口與實現的分離度
。
兩者區別:
- 動多態的函數需要通過指針或引用傳參,而靜多態則可以傳值、傳指針、傳引用等,“適應性”更強;
- 在性能上,靜多態優於動多態,因為靜多態無間接訪問的迂迴代碼,它是單刀直入的;
- 因為實現多態的先後順序不同,所以如果出現錯誤,它們拋出錯誤的時刻也不一樣,動多態會在運行時報錯,而靜多態則在編譯時報錯。
建議61:將模板的聲明和定義放置在同一個頭文件裡
模板類型不是一種實類型,它必須等到類型綁定後才能確定最終類型,所以在實例化一個模板時,必須要能夠讓編譯器“看到”在哪裡使用了模板,而且必須要看到模板確切的定義,而不僅僅是它的聲明,否則將不能正常而順利地產生編譯代碼。
函數模板、類模板不同於一般的函數、類,它們不能像一般的方式那樣進行聲明與定義,標準要求模板的實例化與定義體必須放在同一翻譯單元中。
實現這一目標有三種方法:
- 將模板的聲明和定義都放置在同一個.h文件中;
- 按照舊有的習慣性做法來處理,聲明是聲明,實現是實現,二者相互分離,但是需要包含頭文件的地方- 做一些改變,如,在使用模板時,必須用#include “Temp.cpp”替換掉#include “Temp.h”;
- 使用關鍵字export來定義具體的模板類對象和模板函數。
但是2、3需要編譯器支持,所以最優策略還是:將模板的聲明和定義都放置在同一個.h文件中
,雖然在某種程度上這破壞了代碼的優雅性。
建議62:用模板替代參數化類型的宏函數
參數化的宏函數有著兩個致命缺點: 缺乏類型檢查; 有可能在不該進行宏替換的時候進行了替換,違背了作者的意圖。 模板是實現代碼複用的一種工具,它可以實現類型參數化,達到讓代碼真正複用的目的。 宏:
#define min(a, b) ( (a) < (b) ? (a) : (b) )
用模板函數替換上面的宏:
template <typename T>
const T min(const T &t1, const T &t2)
{
return t1 > t2 ? t2 : t1;
}
建議63:區分函數模板與模板函數、類模板與模板類
函數模板的重點在於“模板”兩個字,前面的“函數”只是一個修飾詞。其表示的是一個專門用來生產函數的模板。而模板函數重點在“函數”,表示的是用模板所生成的函數。
函數模板生成模板函數。
函數模板:
template <typename T>
void Func(const T &a)
{
// ......
}
使用其生成的模板函數:
Func<int>(a);
Func<float>(a);
// ......
類模板:
template <class T>
class List_item
{
public:
T m_val;
// ......
}
使用其生成的模板類:
List_item<int> list1;
List_item<float> list2;
// ......
建議64:區分繼承與模板
模板的長處在於處理不同類型間“千篇一律”的操作。
建議66:傳值throw異常,傳引用catch異常
異常處理標準形式:throw byvalue, catch by reference
try
{
// ...
throw exception; // throw a value
}
catch(const exception &e) // by const reference
{
// ...
}
建議67:用”throw ;”來重新拋出異常
對於異常的重新拋出,需要注意:(1)重新拋出的異常對象只能出現在catch塊或catch調用的函數中;(2)如果在處理代碼不執行時碰到”throw ;”語句,將會調用terminate函數。
try
{
// ...
throw exception; // throw a value
}
catch(const exception &e) // by const reference
{
// ...
throw ; // 重新拋出異常
}
建議68:瞭解異常捕獲與函數參數傳遞之間的差異
異常與函數參數的傳遞之間差異:(1)控制權;(2)對象拷貝的次數;(3)異常類型轉換;(4)異常類型匹配。
建議69:熟悉異常處理的代價
異常處理在帶來便利的同時,也會帶來時間和空間上的開銷,使程序效率降低,體積增大,同時會加大代碼調試和管理的成本。
建議70:儘量保證異常安全
如果採用了異常機制,請儘量保證異常安全:努力實現強保證,至少實現基本保證。
Google不使用C++異常處理:(見Google C++ Style Guide上的說明) We do not use C++ exceptions.
建議71:儘量熟悉C++標準庫
- C++標準庫主要包含的組件:
- C標準函數庫;
- 輸入/輸出(input/output);
字符串(string);
容器(containers);
算法(algorithms);
迭代器(iterators);
- 國際化(internationalization);
- 數值(numerics);
- 語言支持(languagesupport);
- 診斷(diagnostics);
- 通用工具(general utilities)。
字符串、容器、算法、迭代器四部分採用了模板技術,一般被統稱為STL(Standard Template Library,即標準模板庫)。
在C++標準中,STL被組織成了13個頭文件:
<algorithm>
<deque>
<functional>
<iterator>
<vector>
<list>
<map>
<memory>
<numeric>
<queue>
<set>
<stack>
<utility>
建議72:熟悉STL中的有關術語
- 容器:是一個對象,它將對象作為元素來存儲;
- 泛型(Genericity):泛型就是通用,或者說是類型獨立;
- 算法:就是對一個對象序列所採取的某些操作,例如std::sort()、std::copy()、std::remove();
適配器(Adaptor)
:是一個非常特殊的對象,它的作用就是使函數轉化為函數對象,或者是將多參數的函數對象轉化為少參數的函數對象;
- O(h):它是一個表示算法性能的特殊符號,在STL規範中用於表示標準庫算法和容器操作的最低性能極限;
- 迭代器:是一種可以
當做通用指針
來使用
的對象,迭代器可以用於元素遍歷、元素添加和元素刪除。
建議73:刪除指針的容器時避免資源洩露
STL容器雖然智能,但尚不能擔當刪除它們所包含指針的這一責任。
所以,在要刪除指針的容器時須避免資源洩露:或者在容器銷燬前手動刪除容器中的每個指針,或者使用智能引用計數指針對象(比如Boost的shared_ptr)來代替普通指針。
建議74:選擇合適的STL容器
容器分為:
- 標準STL序列容器:vector、 string、 deque和list;
- 標準STL關聯容器:set、 multiset、 map和multimap;
- 非標準序列容器:slist(單向鏈表)和rope(重型字符串);
- 非標準關聯容器:hash_set、 hash_multiset、 hash_map和hash_multimap;
- 標準非STL容器:數組、 bitset、 valarray、 stack、 queue和priority_queue。
建議75:不要在STL容器中存儲auto_ptr對象
auto_ptr是C++標準中提供的智能指針,它是一個RAII對象,它在初始化時獲得資源,析構時自動釋放資源。
C++標準中規定:STL容器元素必須能夠進行拷貝構造和賦值操作。
禁止
在STL容器中存儲auto_ptr對象原因有兩個:
- auto_ptr拷貝操作不安全,會使原指針對象變NULL;
- 嚴重影響代碼的可移植性。
建議76:熟悉刪除STL容器中元素的慣用法
刪除容器中具有特定值的元素:如果容器是vector、 string或deque,
使用erase-remove的慣用法
(remove只會將不應該刪除的元素前移,然後返回一個迭代器,該迭代器指向的是那個應該刪除的元素,所以如果要真正刪除這一元素,在調用remove之後還必須調用erase);如果容器是list,使用list::remove;如果容器是標準關聯容器,使用它的erase成員函數;刪除容器中滿足某些條件的所有元素:如果容器是vector、 string或deque,
使用erase-remove_if慣用法
;如果容器是list,使用list::remove_if;如果容器是標準關聯容器,使用remove_copy_if & swap組合算法,或者自己寫一個遍歷刪除算法。
建議77:小心迭代器的失效
迭代器是一個對象,其內存大小為12(sizeof(vector
建議78:儘量使用vector和string代替動態分配數組
相較於內建數組,vector和string具有幾方面的優點:
- 它們能夠自動管理內存;
- 它們提供了豐富的接口;
- 與C的內存模型兼容;
- 集眾人智慧之大成。
建議79:掌握vector和string與C語言API的通信方式
使用vector::operator[]和string::c_str是實現STL容器與C語言API通信的最佳方式。
vector<int> intContainer;
......
int *pData = NULL;
pData = &(intContainer[0]); // 因為vector的存儲區連續
建議80:多用算法調用,少用手寫循環
用算法調用代替手工編寫的循環,具有幾方面的優點:
- 效率更高;
- 不易出錯;
- 可維護性更好。
// 使用循環
for (iter = Container.begin(); iter != Container.end(); )
{
iter->DoSomething();
}
// 使用算法
for_each(Container.begin(), Container.end(), mem_fun_ref(&ContainerElement::DoSomething));
第二部分 編碼習慣和規範篇
建議81:避免無意中的內部數據裸露
對於const成員函數,不要返回內部數據的句柄,因為它會破壞封裝性
,違反抽象性,造成內部數據無意中的裸露,這會出現很多“不可思議”的情形,比如const對象的非常量性。
建議82:積極使用const為函數保駕護航
const的真正威力體現在幾個方面:
- 修飾函數形式的參數:const只能修飾輸入參數,對於內置數據類型的輸入參數,不要將“值傳遞”的方式改為“const 引用傳遞”;
- 修飾函數返回值;
- 修飾成員函數:用const修飾成員函數的目的是提高程序的健壯性。const成員函數不允許對數據成員進行任何修改。
關於const成員函數,須遵循幾個規則:
- const對象只能訪問const成員函數,而非const對象可以訪問任意的成員函數;
- const對象的成員是不可修改的,然而const對象通過指針維護的對象卻是可以修改的;
- const成員函數不可以修改對象的數據,不管對象是否具有const性質。
建議83:不要返回局部變量的引用
局部變量的引用是一件不太靠譜的事兒,所以儘量避免讓函數返回局部變量的引用。同時也不要返回new生成對象的引用,因為這樣會讓代碼層次混亂,讓使用者苦不堪言。
建議84:切忌過度使用傳引用代替傳對象
相較於傳對象,傳引用的優點:它減少了臨時對象的構造與析構,所以更具效率。但須審慎地使用傳引用替代傳對象,必須傳回內部對象時,就傳對象,勿傳引用。
建議85:瞭解指針參數傳遞內存中的玄機
用指針參數傳回一塊動態申請的內存,是很常見的一種需求。然而如果不甚小心,就很容易造成嚴重錯誤:程序崩潰+內存洩露,解決之道就是用指針的指針來傳遞,或者換種內存傳遞方式,用返回值來傳遞。
// 指針型變量在函數體中需要改變的寫法
void f(int *&x) // 使用指針變量的引用
{
++x;
}
建議86:不要將函數參數作為工作變量
工作變量,就是在函數實現中使用的變量。應該防止將函數參數作為工作變量,而對於那些必須改變的參數,最好先用局部變量代替之,最後再將該局部變量的內容賦給該參數
,這樣在一定程度上保護了數據的安全。
建議87:躲過0值比較的層層陷阱
- 0在不在該類型數據的取值範圍內?
- 浮點數不存在絕對0值,所以浮點零值比較需特殊處理;
- 區分比較操作符==與賦值操作符=,切忌混淆。
const float FLOAT_ZERO = 0.00000001f;
float f = 1.33f;
if (f > FLOAT_ZERO)
{
// ......
}
建議88:不要用reinterpret_cast去迷惑編譯器
reinterpret_cast,簡單地說就是保持二進制位不變,用另一種格式來重新解釋
,它就是C/C++中最為暴力的類型轉換
,所實現的是一個類型到一個毫不相關、完全不同類型的映射。
reiterpret_cast僅僅重新解釋了給出對象的比特模型,它是所有類型轉換中最危險的。儘量避免使用reinterpret_cast
,除非是在其他轉換都無效的非常情形下。
建議89:避免對動態對象指針使用static_cast
在類層次結構中
,用static_cast完成基類和子類指針(或引用)的下行轉換是不安全的。所以儘量避免對動態對象指針使用static_cast,可以用dynamic_cast來代替
,或者優化設計,重構代碼。
建議90:儘量少應用多態性數組
多態性數組一方面會涉及C++時代的基類指針與派生類指針之間的替代問題,同時也會涉及C時代的指針運算,而且常會因為這二者之間的不協調引發隱蔽的Bug。
建議91:不要強制去除變量的const屬性
在C++中,const_cast
建議95:為源代碼設置一定的目錄結構
如果一個軟件所涉及的文件數目比較多,通常要將其進行劃分,為其設置一定的目錄結構,以便於維護,如include、 lib、 src、 doc、 release、 debug。
建議96:用有意義的標識代替Magic Numbers
用宏或常量替代信息含量較低的Magic Numbers,絕對是一個好習慣,這樣可提高代碼的可讀性與可維護性。
建議97:避免使用“聰明的技巧”
建議98:運算符重載時堅持其通用的含義
建議99:避免嵌套過深與函數過長
建議100:養成好習慣,從現在做起
建議101:用移位實現乘除法運算
在大部分的C/C++編譯器中,用移位的方法比直接調用乘除法子程序生成代碼的效率要高。只要是乘以或除以一個整數常量,均可用移位的方法得到結果,如a=a9可以拆分成a=a(8+1),即a=a(a<<3)+a。移位只對整數運算起作用。
建議102:優化循環,提高效率
應當將最長的循環放在最內層,最短的循環放在最外層
,以減少CPU跨切循環層的次數,提高效率。
建議103:改造switch語句
對於case的值,推薦按照它們發生的相對頻率來排序,把最可能發生的情況放在第一位,最不可能的情況放在最後
。
建議104:精簡函數參數
函數在調用時會建立堆棧來存儲所需的參數值,因此函數的調用負擔會隨著參數列表的增長而增加。所以,參數的個數會影響進棧出棧的次數
,當參數很多的時候,這樣的操作就會花費很長的時間。因此,精簡函數參數,減少參數個數可以提高函數調用的效率。如果精簡後的參數還是比較多,那麼可以把參數列表封裝進一個單獨的類中,並且可以通過引用進行傳遞
。
建議106:努力減少內存碎片
經常性地動態分配和釋放內存會造成堆碎片
,尤其是應用程序分配的是很小的內存塊時。避免堆碎片:
儘可能少地使用動態內存
,在大多數情況下,可以使用靜態或自動儲存,或者使用STL容器,減少對動態內存的依賴;儘量分配和重新分配大塊的內存塊
,降低內存碎片發生的機率。內存碎片會影響程序執行的效率。
建議108:用初始化取代賦值
以用戶初始化代替賦值,可以使效率得到較大的提升,因為這樣可以避免一次賦值函數operator =的調用。因此,當我們在賦值和初始化之間進行選擇時,初始化應該是首選。需要注意的是,對基本的內置數據類型而言,初始化和賦值之間是沒有差異的,因為內置類型沒有構造和析構過程
。
建議109:儘可能地減少臨時對象
臨時對象產生的主要情形及避免方法:
- 參數:採用傳常量引用或指針取代傳值;
- 前綴或後綴:
優先採用前綴操作
; - 參數轉換:儘量避免這種轉換;
- 返回值:遵循single-entry/single-exit原則,避免同一個函數中存在多個return語句。
建議110:最後再去優化代碼
“Premature optimization is the root of all evil.” — Donald Knuth
在大的結構還沒有確定的時候,不要投入精力在一些細小的地方做“優化”。
在進行代碼優化之前,需要知道:
- 算法是否正確;
- 如何在代碼優化和可讀性之間進行選擇;
- 該如何優化:代碼分析(profiling)工具;
- 如何選擇優化方向:先算法,再數據結構,最後才是實現細節。
先粗後細。
建議111:採用相對路徑包含頭文件
一個“點”(“.\”)代表的是當前目錄所在的路徑,兩個“點”(“..\”)代表的是相對於當前目錄的上一次目錄路徑。 當寫#include語句時,推薦使用相對路徑;此外,要注意使用比較通用的正斜線“/”,而不要使用僅在Windows下可用的反斜線“\”。
建議112:讓條件編譯為開發出力
條件編譯中的預處理命令主要包括:#if、 #ifndef、 #ifdef、 #endif和#undef等,它們的主要功能是在程序編譯時進行有選擇性的挑選,註釋掉一些指定的代碼,以達到版本控制、防止對文件重複包含等目的。
建議113:使用.inl文件讓代碼整潔可讀
.inl文件是內聯函數的源文件,.inl文件還可用於模板的定義。.inl文件可以將頭文件與內聯函數的複雜定義隔離開來,使代碼整潔可讀,如果將其用於模板定義,這一優點更加明顯。
建議115:優先選擇編譯和鏈接錯誤
靜態檢查:編譯器必須檢查源程序是否符合源語言規定的語法和語義要求,靜態檢查的主要工作就是語義分析,它是獨立於數據和控制流的,可信度相對較高,而且不會增加程序的運行時開銷。
動態檢查:是在運行時刻對程序的正確性、安全性等做檢查,比如內存不足、溢出、數組越界、除0等,這類檢查對於數據和控制流比較依賴。
C/C++語言屬於一種靜態語言。一個設計較好的C++程序應該是較少地依賴動態檢查,更多地依賴靜態檢查。
建議117:儘量減少文件之間的編譯依賴
不要在頭文件中直接包含要使用的類的頭文件(除了標準庫),直接包含頭文件這種方式相對簡單方便,但是會耗費大量的編譯時間。推薦使用類的前向聲明來減少文件直接的編譯依賴。用對類聲明的依賴替代對類定義的依賴,這是減少編譯依賴的原則。
為了加快編譯進程,減少時間的浪費,我們應該儘量減少頭文件依賴,其中的可選方案包括前向聲明、柴郡貓技術等。
關於“柴郡貓(Cheshire Cat Idiom)技術”,即PImpl,Private Implementaion。主類中只定義接口,將私有數據成員封裝在一個實現類中。
建議118:不用在頭文件中使用using
名空間是C++提供的一種機制,可以有效地避免函數名汙染。然而在應用時要十分注意:任何情況下都不應在頭文件中使用“using namespace XXX”這樣的語句,而應該在定義時直接用全稱。
// A.h
// 頭文件中絕不使用using !
// using namespace std;
class A
{
public:
A()
{
std::cout << "hello" << std::endl; // 使用名空間全稱
}
}
建議119:劃分全局名空間避免名汙染
使用自己的名空間將全局名空間合理劃分,會有效地減少名汙染問題,不要簡單地將所有的符號和名稱統統扔進全局名空間裡。
第三部分 程序架構和思想篇
建議120:堅持“以行為為中心”
的類設計
“以數據為中心”關注類的內部數據結構,習慣將private
類型的數據寫在前面,而將public
類型的函數寫在後面。
“以行為為中心”關注的重心放在了類的服務和接口上,習慣將public類型的函數寫在前面,而將private類型的數據寫在後面。
建議121:用心做好類設計
- 類應該如何創建和銷燬呢?這會影響到類的構造函數和析構函數的設計。首先應該確定類是否需要分配資源,如果需要,還要確定這些資源又該如何釋放。
- 類是否需要一個無參構造函數?如果需要,而恰恰此時這個類已經有了構造函數,那麼我們就得顯示地寫一個。
- 類需要複製構造函數嗎?其參數上加上了const修飾嗎?它是用來定義這個類傳值(pass-by-value)的具體實現的。
- 所有的數據成員是不是都已經在構造函數中完成了初始化呢?
- 類需要賦值操作符嗎?賦值操作符能正確地將對象賦給對象本身嗎?它與初始化有什麼不同?其參數上加上了const修飾嗎?
- 類的析構函數需要設置為virtual嗎?
- 類中哪些值得組合是合法的?合法值的限定條件是什麼?在成員函數內部是否對變量值得合法性做了檢查?其次,類的設計是對現實對象進行抽象的一個過程。再次,數據抽象的過程其實是綜合考慮各方面因素進行權衡的一個過程。
建議122:以指針代替嵌入對象或引用
設計類的數據成員時,可以有三種選擇:
- 嵌入對象;
- 使用對象引用;
- 使用對象指針。
如果在類數據成員中使用到了自定義數據類型,使用指針是一個較為明智的選擇,它有以下幾方面的優點:
- 成員對象類型的變化不會引起包含類的重編譯;
- 支持惰性計算,不創建不使用的對象,效率更高;
- 支持數據成員的多態行為。
建議123:努力將接口最小化且功能完善
類接口的目標是完整且最小
。精簡接口函數個數,使每一個函數都具有代表性,並且使其功能恰好覆蓋class的智能,同時又可以獲得接口精簡所帶來的好處:
- 利於理解、使用,維護成本也相對較低;
- 可以縮小頭文件長度,並縮短編譯時間。
建議124:讓類的數據隱藏起來
堅持數據封裝,堅持信息隱藏,杜絕公有、保護屬性的存在(數據成員私有、柴郡貓技術)。
建議125:不要讓成員函數破壞類的封裝性
小心類的成員函數返回屬性變量的“直接句柄”,它會破壞辛辛苦苦搭建維護的封裝性,一種方法,將函數的返回值加上const修飾。
建議126:理解“virtual + 訪問限定符”的深層含義
virtual關鍵字是C++中用於實現多態的重要機制,其核心理念就是通過基類訪問派生類定義的函數。
- 基類中的一個虛擬私有成員函數,表示實現細節是可以被派生類修改的;
- 基類中的一個虛擬保護成員函數,表示實現細節是必須被派生類修改的;
- 基類中的一個虛擬公有成員函數,則表示這是一個接口,不推薦,建議用protected virtual來替換。
經典設計模式:Template Method
class CBaseTemplate
{
private:
void Step_1() {...} // 不可被派生類修改
virtual void Step_2() {...} // 可以被派生類修改
protected:
virtual void Step_3() = 0; // 必須被派生類修改
public:
void Function() // 算法骨架函數
{
Step_1();
Step_2();
Step_3();
}
};
建議127:謹慎恰當地使用友元機制
通常說來,類中的私有成員一般是不允許外面訪問的。但是友元可以超脫這條禁令,它可以訪問該類的私有成員。所帶來的最大好處就是避免了類成員函數的頻繁調用,節約了處理器的開銷,提高了程序的效率。但是,通常,大家認為“友元破壞了類的封裝性”。採用友元機制,一般是基於這樣的需求:一個類的部分成員需要對個別其他類公開。
建議128:控制對象的創建方式
棧和堆是對象的主要分佈區,它們對應著兩種基本的對象創建方式:以new方式手動管理的堆創建和只需聲明就可使用的棧創建。
控制對象的創建方式:
要求在堆中建立對象:為了執行這種限制,必須找到一種方法保證調用new是建立對象的唯一手段。非堆對象是在定義它時自動構造的,而且是在生存期結束時自動釋放的。
將析構函數聲明為private,而構造函數保持為public
;禁止在堆中建立對象:
要禁止調用new來建立對象,可以通過將operator new函數聲明為private來實現。
建議129:控制實例化對象的個數
當實例化對象唯一時,採用設計模式中的單件模式
;當實例化對象為N(N>0)個時,設置計數變量
是一個思路。
單件模式:Singleton,將constructor聲明為private,提供一個static對象及獲取該static對象的方法。
建議130:區分繼承與組合
繼承:C++的“繼承”特性可以提高程序的可複用性。繼承規則:若在邏輯上B是一種A,並且A的所有功能和屬性對B而言都有意義,則允許B繼承A的功能和屬性。繼承易於修改或擴展那些被複用的實現。但它的這種“白盒複用”卻容易破壞封裝性,因為這會將父類的實現細節暴露給子類。當父類實現更改時,子類也不得不隨之更改,所以,從父類繼承來的實現將不能在運行期間進行改變;
組合:在邏輯上表示的是“有一個(Hase-A)”的關係,即A是B的一部分。組合屬於“黑盒”複用,被包含對象的內部細節對外是不可見的。所以,
它的封裝性相對較好,實現上的相互依賴性比較小
。並且可以通過獲取指向其他的具有相同類型的對象引用,在運行期間動態地定義組合。而其缺點就是致使系統中的對象過多。
- Is-A關係用繼承表達,Has-A關係用組合表達。
優先使用對象組合,而不是類繼承。
建議131:不要將對象的繼承關係擴展至對象容器
A是B的基類,B是一種A,但是B的容器卻不能是這種A的容器。
建議132:杜絕不良繼承
在繼承體系中,派生類對象必須是可以取代基類對象的。
典型問題:“圓是不是橢圓?”
圓繼承自橢圓是不良繼承!
建議133:將RAII作為一種習慣
RAII(ResourceAcquisition Is Initialization),資源獲取即初始化,RAII是C++語言的一種管理資源、避免洩露的慣用方法。RAII的做法是使用一個對象,在其構造時獲取資源,在對象生命週期中控制對象資源的訪問,使之始終保持有效,最後再對象析構時釋放資源。實現這種功能的類即採用了RAII方式,這樣的類被稱為封裝類。
建議134:學習使用設計模式
設計模式是用來“封裝變化、降低耦合”
的工具,它是面向對象設計時代的產物,其本質就是充分運用面向對象的三個特性(即:封裝、繼承和多態),並進行靈活的組合。
建議135:在接口繼承和實現繼承中做謹慎選擇
在接口繼承和實現繼承之間進行選擇時,需要考慮的一個因素就是:基類的默認版本。對於那些無法提供默認版本的函數接口我們選擇函數接口繼承;而對於那些能夠提供默認版本的,函數實現繼承就是最佳選擇。
class CShape
{
public:
virtual void Draw() = 0; // 用於接口繼承
virtual void SetColor(const COLOR &color); // 用於實現繼承
private:
COLOR m_color;
}
class CCircle : public CShape {...}
class CRectangle : public CShape {...}
建議136:遵循類設計的五項基本原則
- 單一職責原則(SRP):一個類,最好只做一件事。SRP可以看作是低耦合、高內聚在面向對象原則上的引申;
- 開閉原則(OCP):
對擴展開放,對更改關閉
,應該能夠不用修改原有類就能擴展一個類的行為
; - 替換原則(LSP ):子類應當可以替換父類並出現在父類能夠出現的任何地方。反過來則不成立,子類可以替換基類,但是基類不一定能替換子類;
- 依賴倒置原則(DIP):
高層模塊不依賴於底層模塊,而是二者都依賴於抽象,即抽象不依賴於具體,具體依賴於抽象
。依賴一定會存在類與類、模塊與模塊之間。當兩個模塊之間存在緊密的耦合關係時,最好的方法就是分離接口和實現:在依賴之間定義一個抽象的接口使得高層模塊調用接口,底層模塊實現接口的定義,從而有效控制耦合關係,達到依賴於抽象的設計目的; - 接口分離原則(ISP):使用多個小的專門的接口,而不要使用一個大的總接口。接口有效地將細節和抽象隔離開來,體現了對抽象編程的一切好處,接口隔離強調接口的單一性。分離的手段主要有兩種方式:一個是利用委託分離接口,另一個是利用多重繼承分離接口。
建議137:用表驅動取代冗長的邏輯選擇
表驅動法(Table drivenmethod),是一種不必用很多的邏輯語句(if或case)就可以把表中信息找出來的方法。它是一種設計模式,可用來代替複雜的if/else或switch-case邏輯判斷。 一個簡單的例子:
static int s_nMonthDays[12] =
{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
// 使用表驅動可以代替冗長的邏輯選擇
int GetMonthDays(int iMonth)
{
return s_nMonthDays[iMonth - 1];
}
MFC中的消息映射機制也使用了表驅動。
表驅動的好處在於將冗長的邏輯選擇改為維護表,符合”元編程思想”。
建議139:編碼之前需三思
在讓電腦運行你的程序之前,先讓你的大腦編譯運行。
程序員最重要的工作是在遠離鍵盤時完成的。
Want to write some code? Get away from your computer! Think first, program later.
你開始編碼的時間越早,項目持續的時間越長。
三思而後碼。
建議140:重構代碼
重構無止境,重構你的代碼,精雕細琢,千錘百煉。
重構是一門藝術。
建議142:在未來時態下開發C++程序
在未來時態下開發C++程序,需要考慮代碼的可重用性、可維護性、健壯性,以及可移植性。
建議143:根據你的目的決定造不造輪子
在編程語言中這些輪子表現為大量的通用類和庫。在工程實踐中,不要重複造輪子;而在學習研究中,鼓勵重複造輪子。
建議144:謹慎在OO與GP之間選擇
面向對象(OO)和泛型編程(GP)是C++提供給程序員的兩種矛盾的思考模式。OO是我們難以割捨的設計原則,世界是對象的,我們面向對象分析、設計、編程;而泛型編程則關注於產生通用的軟件組件,讓這些組件在不同的應用場合都能很容易的重用。
建議145:讓內存管理理念與時俱進
學習STL allocator,更新內存管理理念。
建議146:從大師的代碼中學習編程思想與技藝
閱讀代碼需要方法:剛開始不要糾結於代碼的細節
,將關注的重點放在代碼的高層結構上
,理解代碼的構建過程;之後,再有重點的深入研究,理解對象的構造,明晰算法的步驟,嘗試著深入理解其中的繁雜細節。