摘要:本文結合作者的工作經驗和學習心得,對C++語言的一些高階特性,做了簡單介紹;對一些常見的誤解,做瞭解釋澄清;對比較容易犯錯的地方,做了歸納總結;希望藉此能增進大家對C++語言瞭解,減少程式設計出錯,提升工作效率。
C++是一門被廣泛使用的系統級程式語言,更是高效能後端標準開發語言;C++雖功能強大,靈活巧妙,但卻屬於易學難精的專家型語言,不僅新手難以駕馭,就是老司機也容易掉進各種陷阱。
本文結合作者的工作經驗和學習心得,對C++語言的一些高階特性,做了簡單介紹;對一些常見的誤解,做瞭解釋澄清;對比較容易犯錯的地方,做了歸納總結;希望藉此能增進大家對C++語言瞭解,減少程式設計出錯,提升工作效率。
我的程式裡用了全域性變數,爲何進程退出會莫名其妙的core掉?
Rule:C++在不同模組(原始檔)裡定義的全域性變數,不保證構造順序;但保證在同一模組(原始檔)裡定義的全域性變數,按定義的先後順序構造,按定義的相反次序解構。
我們程式在a.cpp裡定義了依次全域性變數X和Y;
按照規則:X先構造,Y後構造;進程停止執行的時候,Y先解構,X後解構;但如果X的解構依賴於Y,那麼core的事情就有可能發生。
結論:如果全域性變數有依賴關係,那麼就把它們放在同一個原始檔定義,且按正確的順序定義,確保依賴關係正確,而不是定義在不同原始檔;對於系統中的單件,單件依賴也要注意這個問題。
std::sort()的比較函數有很強的約束,不能亂來
相信工作5年以上至少50%的C/C++程式設計師都被它坑過,我已經聽到過了無數個悲傷的故事,《聖鬥士星矢》,《仙劍》,還有別人家的專案《天天愛消除》,都有人掉坑,程式執行幾天莫名奇妙的Crash掉,一臉懵逼。
如果要用,要自己提供比較函數或者函數物件,一定搞清楚什麼叫「嚴格弱排序」,一定要滿足以下3個特性:
儘量對索引或者指針sort,而不是針對物件本身,因爲如果物件比較大,交換(複製)物件比交換指針或索引更耗費。
注意操作符短路
考慮遊戲玩家回血回藍(魔法)重新整理給用戶端的邏輯。玩家每3秒回一點血,玩家每5秒回一點藍,回藍回血共用一個協定通知用戶端,也就是說只要有回血或者回藍就要把新的血量和魔法值通知用戶端。
玩家的心跳函數heartbeat()在主邏輯執行緒被回圈呼叫
void GamePlayer::Heartbeat()
{
if (GenHP() || GenMP())
{
NotifyClientHPMP();
}
}
如果GenHP回血了,就返回true,否則false;不一定每次呼叫GenHP都會回血,取決於是否達到3秒間隔。
如果GenMP回藍了,就返回true,否則false;不一定每次呼叫GenMP都會回血,取決於是否達到5秒間隔。
實際執行發現回血回藍邏輯不對,Word麻,原來是操作符短路了,如果GenHP()返回true了,那GenMP()就不會被呼叫,就有可能失去回藍的機會。你需要修改程式如下:
void GamePlayer::Heartbeat()
{
bool hp = GenHP();
bool mp = GenMP();
if (hp || mp)
{
NotifyClientHPMP();
}
}
邏輯與(&&)跟邏輯或(||)有同樣的問題, if (a && b) 如果a的表達式求值爲false,b表達式也不會被計算。
有時候,我們會寫出 if (ptr != nullptr && ptr->Do())這樣的程式碼,這正是利用了操作符短路的語法特徵。
別讓回圈停不下來
for (unsigned int i = 5; i >=0; --i)
{
//...
}
程式跑到這,WTF?根本停不下來啊?問題很簡單,unsigned永遠>=0,是不是心中一萬隻馬奔騰?
解決這個問題很簡單,但是有時候這一類的錯誤卻沒這麼明顯,你需要罩子放亮點。
記憶體拷貝小心記憶體越界
memcpy,memset有很強的限制,僅能用於POD結構,不能作用於stl容器或者帶有虛擬函式的類。
帶虛擬函式的類物件會有一個虛擬函式表的指針,memcpy將破壞該指針指向。
對非POD執行memset/memcpy,免費送你四個字:自求多福
注意記憶體重疊
記憶體拷貝的時候,如果src和dst有重疊,需要用memmov替代memcpy。
理解user stack空間很有限
不能在棧上定義過大的臨時物件。一般而言,使用者棧只有幾兆(典型大小是4M,8M),所以棧上建立的物件不能太大。
用sprintf格式化字串的時候,型別和符號要嚴格匹配
因爲sprintf的函數實現裡是按格式化串從棧上取參數,任何不一致,都有可能引起不可預知的錯誤; /usr/include/inttypes.h裡定義了跨平臺的格式化符號,比如PRId64用於格式化int64_t
用c標準庫的安全版本(帶n標識)替換非安全版本
比如用strncpy替代strcpy,用snprintf替代sprintf,用strncat代替strcat,用strncmp代替strcmp,memcpy(dst, src, n)要確保[dst,dst+n]和[src, src+n]都有有效的虛擬記憶體地址空間。多執行緒環境下,要用系統呼叫或者庫函數的安全版本代替非安全版本(_r版本),謹記strtok,gmtime等標準c函數都不是執行緒安全的。
STL容器的遍歷刪除要小心迭代器失效
vector,list,map,set等各有不同的寫法:
int main(int argc, char *argv[])
{
//vector遍歷刪除
std::vector v(8);
std::generate(v.begin(), v.end(), std::rand);
std::cout << "after vector generate...\n";
std::copy(v.begin(), v.end(), std::ostream_iterator(std::cout, "\n"));
for (auto x = v.begin(); x != v.end(); )
{
if (*x % 2)
x = v.erase(x);
else
++x;
}
std::cout << "after vector erase...\n";
std::copy(v.begin(), v.end(), std::ostream_iterator(std::cout, "\n"));
//map遍歷刪除
std::map m = {{1,2}, {8,4}, {5,6}, {6,7}};
for (auto x = m.begin(); x != m.end(); )
{
if (x->first % 2)
m.erase(x++);
else
++x;
}
return 0;
}
有時候遍歷刪除的邏輯不是這麼明顯,可能回圈裡調了另一個函數,而該函數在某種特定的情況下纔會刪除當前元素,這樣的話,就是很長一段時間,程式都執行得好好的,而當你正跟別人談笑風生的時候,忽然crash,這就尷尬了。
聖鬥士星矢專案曾經遭遇過這個問題,基本規律是一個禮拜game server crash一次,折磨團隊將近一個月。
比較low的處理方式可以把待刪元素放到另一個容器WaitEraseContainer裡儲存下來,再走一趟單獨的回圈,刪除待刪元素。
當然,我們推薦在遍歷的同時刪除,因爲這樣效率更高,也顯得行家裏手。
空間換取時間
通過空間換取時間是提高效能的慣用法,bitmap,int map[]這些慣用法要瞭然於胸。
減少拷貝 & COW
瞭解Copy On Write。
只要可能就應該減少拷貝,比如通過共用,比如通過參照指針的形式傳遞參數和返回值。
延遲計算和預計算
比如遊戲伺服器端玩家的戰力,由屬性a,b決定,也就是說屬性a,b任何一個變化,都需要重算戰力;但如果ModifyPropertyA(),ModifyPropertyB()之後,都重算戰力卻並非真正必要,因爲修改屬性A之後有可能馬上修改B,兩次重算戰力,顯然第一次重算的結果會很快被第二次的重算覆蓋。
而且很多情況下,我們可能需要在心跳裡,把最新的戰力值推播給用戶端,這樣的話,ModifyPropertyA(),ModifyPropertyB()裡,我們其實只需要把戰力置髒,延遲計算,這樣就能避免不必要的計算。
在GetFightValue()裡判斷FightValueDirtyFlag,如果髒,則重算,清髒標記;如果不髒,直接返回之前計算的結果。
預計算的思想類似。
分散計算
分散計算是把任務分散,打碎,避免一次大計算量,卡住程式。
雜湊
減少字串比較,構建hash,可能會多費一點儲存空間,但收益可觀,信我。
日誌節制
日誌的開銷不容忽視,要分級,可以把日誌作爲debug手段,但要release乾淨。
編譯器爲什麼不給區域性變數和成員變數做預設初始化
因爲效率,C++被設計爲系統級的程式語言,效率是優先考慮的方向,c++秉持的一個設計哲學是「不爲不必要的操作付出任何額外的代價」。所以它有別於java,不給成員變數和區域性變數做預設初始化,如果需要賦初值,那就由程式設計師自己去保證。
結論:從安全的角度出發,不應使用未初始化的變數,定義變數的時候賦初值是一個好的習慣,很多錯誤皆因未正確初始化而起,C++11支援成員變數定義的時候直接初始化,成員變數儘量在成員初始化列表裏初始化,且要按定義的順序初始化。
理解函數呼叫的效能開銷(棧幀建立和銷燬,參數傳遞,控制轉移),效能敏感函數考慮inline
X86_64體系結構因爲通用暫存器數目增加到16個,所以64位元系統下參數數目不多的函數呼叫,將會由暫存器傳遞代替壓棧方式傳遞參數,但棧幀建立、復原和控制轉移依然會對效能有所影響。
遞回的優點、缺點
雖然遞回函數能簡化程式編寫,但也常常帶來執行速度變慢的問題,所以需要預估好遞回深度,優先考慮非遞回實現版本。
遞回函數要有退出條件且不能遞回過深,不然有爆棧危險。
瞭解std::vector的方方面面和底層實現
常用數據結構
陣列:記憶體連續,隨機存取,效能高,區域性性好,不支援動態擴充套件,最常用。
鏈表:動態伸縮,脫離插入極快,特別是帶前後驅指針,記憶體通常不連續(當然可以通過從固定記憶體池分配規避),不支援隨機存取。
查詢:3種:bst,hashtable,基於有序陣列的bsearch。二元搜尋樹(RBTree),這個從begin到end有序,最壞查詢速度logN,壞處記憶體不連續,節點有額外空間浪費;hashtable,好的hash函數不好選,搜尋最壞退化成鏈表,難以估計捅數量,開大了浪費記憶體,擴容會卡一下,無序;基於有序陣列的bsearch,區域性性好,insert/delete慢。
對於在啓動時載入好,執行中不變化的查詢結構,可以考慮用sorted array替代map,hash表等
因爲有序陣列支援二分查詢,效率跟map差不多。對於只需要在程式啓動的時候構建(排序)一次的查詢結構,有序陣列相比map和hash可能有更好的記憶體命中性(區域性命中性)。
執行過程中,穩定的查詢結構(比如設定表,需要根據id查詢設定表項,執行過程中不增刪),有序陣列是個不錯的選擇;如果不穩定,則有序陣列的插入刪除效率比map,hashtable差,所以選用有序陣列需要注意適用場合。
std::map or std::unorder_map?
想清楚他們的利弊,map是用紅黑樹做的,unorder_map底層是hash表做的,hash表相對於紅黑樹有更高的查詢效能。hash表的效率取決於hash演算法和衝突解決方法(一般是拉鍊法,hash桶),以及數據分佈,如果負載因子高,就會降低命中率,爲了提高命中率,就需要擴容,重新hash,而重新hash是很慢的,相當於卡一下。
而紅黑樹有更好的平均複雜度,所以如果數據量不是特別大,map是勝任的。
積極的使用const
理解const不僅僅是一種語法層面的保護機制 機製,也會影響程式的編譯和執行。
const常數會被編碼到機器指令。
理解四種轉型的含義和區別
避免用錯,儘量少用向下轉型(可以通過設計加以改進)
static_cast, dynamic_cast,const_cast,reinterpret_cast,傻傻分不清?
C++磚家說:一句話,儘量少用轉型,強制型別轉換是C Style,如果你的C++程式碼需要型別強轉,你需要去考慮是否設計有問題。
理解位元組對齊
位元組對齊能讓記憶體存取速度更快。
位元組對齊跟cpu架構相關,有些cpu存取特定型別的數據必須在一定地址對齊的儲存器位置,否則會觸發異常。
位元組對齊的另一個影響是調整結構體成員變數的定義順序,有可能減少結構體大小,這在某些情況下,能節省記憶體。
牢記3 rules和5 rules,當然C++11又多了&&的copy ctor和op=版本
只在需要接管的時候才自定義operator=和copy constructor,如果編譯器提供的預設版本工作的很好,不要去自找麻煩,自定義的版本勿忘拷貝每一個成分,如果要接管就要處理好。
組合優先於繼承,繼承是一種最強的類間關係
典型的適配器模式有類適配器和物件適配器,一般而言,建議用物件適配的方式,而非用基於繼承的類適配方式。
減少依賴,注意隔離
嚴格配對
開啓的控制代碼要關閉,加鎖/解鎖,new/delete,new[]/delete[],malloc/free要配對,可以使用RAII技術防止資源泄露,編寫符合規範的程式碼
Valgrind對程式的記憶體使用方式有期望,需要乾淨的釋放,所以規範程式設計才能 纔能寫出valgrind乾淨的程式碼,不然再好的工具碰到不按規劃寫的程式碼也是武功盡廢啊。
理解多繼承潛在的問題,慎用多繼承
多繼承會存在菱形繼承的問題,多個基礎類別有相同成員變數會有問題,需要謹慎對待。
有多型用法抽象基礎類別的解構函式要加virtual關鍵字
主要是爲了基礎類別的解構函式能得到正確的呼叫。
virtual dtor跟普通虛擬函式一樣,基礎類別指針指向子類物件的時候,delete ptr,根據虛擬函式特徵,如果解構函式是普通函數,那麼就呼叫ptr顯式(基礎類別)型別的解構函式;如果解構函式是virtual,則會呼叫子類的解構函式,然後再呼叫基礎類別解構函式。
避免在建構函式和解構函式裡呼叫虛擬函式
建構函式裡,物件並沒有完全構建好,此時呼叫虛擬函式不一定能正確系結,解構亦如此。
從輸入流獲取數據,要做好數據不夠的處理,要加try catch;沒有被吞嚥的exception,會被傳播
從網路數據流讀取數據,從數據庫恢復數據都需要注意這個問題。
協定儘量不要傳float,如果傳float要瞭解NaN的概念,要做好檢查,避免惡意傳播
可以考慮用整數替代浮點,比如萬分之五(5%%),就儲存5。
定義宏要遵循常規
要對每個變數加括弧,有時候需要加do {} while(0)或者{},以便能將一條宏當成一個語句。要理解宏在預處理階段被替換,不用的時候要#undef,要防止污染別人的程式碼。
瞭解智慧指針和指針的誤用
理解基於參照計數法的智慧指針實現方式,瞭解所有權轉移的概念,理解shared_ptr和unique_ptr的區別和適用場景
考慮用std::shared_ptr管理動態分配的物件。
指針能帶來彈性,但不要誤用,它的彈性指一方面它能在執行時改變指向,可以用來做多型,另一方面對於不能固定大小的陣列可以動態伸縮,但很多時候,我們對固定大小的array,也在init裡new/malloc出來,其實沒必要,而且會多佔用sizeof(void*)位元組,而且增加一層間接存取。
size_t到底是個什麼?我該用有符號還是無符號整數?
size_t型別是被設計來儲存系統記憶體上能儲存的物件的最大個數。
32位元系統,一個物件最小的單位是一個位元組,那2的32次方記憶體,最多能儲存的物件數目就是4G/1位元組,正好一個unsigned int能儲存下來(typedef unsigned int size_t)。
同樣,64位元系統,unsigned long是8位元組,所以size_t就是unsigned long的型別別名。
對於像索引,位置這樣的變數,是用有符號還是無符號呢?像money這樣的屬性呢?
一句話:要講道理,用最自然,最順理成章的型別。比如索引不可能爲負用size_t,賬戶可能欠錢,則money用int。比如:
template <class T> class vector
{
T& operator(size_t index) {}
};
標準庫給出了最好的示範,因爲如果是有符號的話,你需要這樣判斷
if (index < 0 || index >= max_num) throw out_of_bound();
而如果是無符號整數,你只需要判斷 if (index >= max_num),你認可嗎?
整型一般用int,long就很好,用short,char需要很謹慎,要防止溢位
整型包括int,short,long,long long和char,沒錯,char也是整型,float是實型。
絕大多數情況下,用int,long就很好,long一般等於機器字長,能直接放到暫存器,硬體處理起來速度也通常更快。
很多時候,我們希望用short,char達到減少結構體大小的目的。但是由於位元組對齊的原因,可能並不能真正減少大小,而且1,2個位元組的整型位數太少,一不小心就溢位了,需要特別注意。
所以,除非在db、網路這些對儲存大小非常敏感的場合,我們才需要考慮是否以short,char替代int,long。其他情況下,就相當於爲省電而不開樓道的燈,省不了多少錢卻冒着摔斷腿的危險。
區域性變數更沒有必要用(unsigned) short,char等,棧是自動伸縮的,它既不節省空間,還危險,還慢。
瞭解c++高階特性
模板和泛型程式設計,union,bitfield,指向成員的指針,placement new,顯式解構,異常機制 機製,nested class,local class,namespace,多繼承、虛繼承,volatile,extern "C"等
有些高階特性只有在特定情況下纔會被用到,但技多不壓身,平時還是需要積累和瞭解,這樣在需求出現時,才能 纔能從自己的知識庫裡拿出工具來對付它。
瞭解C++新標準
關注新技術,c++11/14/17、lambda,右值參照,move語意,多執行緒庫等
c++98/03標準到c++11標準的推出歷經13年,13年來程式設計語言的思想得到了很大的發展,c++11新標準吸收了很多其他語言的新特性,雖然c++11新標準主要是靠引入新的庫來支援新特徵,核心語言的變化較少,但新標準還是引入了move語意等核心語法層面的修改,每個CPPer都應該瞭解新標準。
OOD設計原則並不是胡扯
熟悉常用設計模式,活學活用,不生搬硬套
神化設計模式和反設計模式,都不是科學的態度,設計模式是軟體設計的經驗總結,有一定的價值;GOF書上對每一個設計模式,都用專門的段落講它的應用場景和適用性,限制和缺陷,在正確評估得失的情況下,是鼓勵使用的,但顯然,你首先需要準確get到她。