言聯(lián)邦、const與初始化實(shí)戰(zhàn))
我很久沒(méi)翻開(kāi)那本橘黃色封面的 C 經(jīng)典了但每當(dāng)身邊有朋友問(wèn)“C 到底應(yīng)該怎么學(xué)”我總會(huì)提起這本書(shū)里的第一章節(jié)“讓自己習(xí)慣 C”。作為 Effective C 55 個(gè)具體做法的開(kāi)篇這一章只有四個(gè)條款卻奠定了整本書(shū)的方法論基礎(chǔ)。這些年我見(jiàn)過(guò)太多人把 C 當(dāng)成一門“帶類的 C”來(lái)寫(xiě)結(jié)果代碼越寫(xiě)越別扭問(wèn)題越查越深也有人一上來(lái)就扎進(jìn)模板元編程連基本對(duì)象生命周期都沒(méi)捋清。這篇摘錄想做的就是把第一章的四個(gè)條款掰開(kāi)揉碎結(jié)合我實(shí)際工程里的經(jīng)驗(yàn)講講每一條規(guī)則背后的“為什么”和落地時(shí)的注意點(diǎn)。如果你正準(zhǔn)備系統(tǒng)地讀 Effective C或者已經(jīng)讀過(guò)但覺(jué)得某些條款沒(méi)吃透這篇文章可以當(dāng)作一份帶注釋的閱讀筆記。書(shū)里每個(gè)條款讀起來(lái)都很短但每一條都能展開(kāi)成一篇長(zhǎng)文我會(huì)盡量用具體代碼和場(chǎng)景把它們講透。1. 航拍視角先認(rèn)清 C 是一個(gè)語(yǔ)言聯(lián)邦而不是一門單一語(yǔ)言1.1 為什么很多人學(xué) C 越學(xué)越混亂原書(shū)作者在條款 01 里提出了一個(gè)非常經(jīng)典的觀點(diǎn)C 不是一門單一語(yǔ)言而是由四個(gè)次語(yǔ)言組成的“語(yǔ)言聯(lián)邦”。這四個(gè)次語(yǔ)言分別是C、面向?qū)ο蟮?C、模板 C泛型編程、以及 STL。我第一次讀到這個(gè)說(shuō)法時(shí)覺(jué)得太抽象了但工作幾年后回頭再看這可能是全書(shū)最值得反復(fù)咀嚼的一句話。很多人覺(jué)得 C 難學(xué)本質(zhì)上是因?yàn)檫@四個(gè)次語(yǔ)言的使用方式、編程范式、慣用法差別太大。用純 C 的思路寫(xiě) C你會(huì)覺(jué)得類、命名空間、異常都是多余的負(fù)擔(dān)用面向?qū)ο蟮乃悸酚蔡姿袌?chǎng)景你會(huì)覺(jué)得模板和算法庫(kù)與你格格不入反過(guò)來(lái)如果你一上來(lái)就只用模板元編程那一套又會(huì)把簡(jiǎn)單的業(yè)務(wù)邏輯寫(xiě)得晦澀難懂。問(wèn)題不在于你選錯(cuò)了某一個(gè)范式而在于你沒(méi)有意識(shí)到C 允許你在不同場(chǎng)景下切換到不同范式關(guān)鍵是要知道“現(xiàn)在正站在哪塊領(lǐng)土上”。1.2 四個(gè)次語(yǔ)言分別對(duì)應(yīng)什么樣的編程策略我在讀這個(gè)條款時(shí)給自己畫(huà)了一張“心智地圖”這幾年一直在用可以分享給你次語(yǔ)言核心特征典型場(chǎng)景你的思考方式C無(wú)類、無(wú)模板、無(wú)異常面向過(guò)程底層模塊、嵌入式、系統(tǒng)調(diào)用封裝指針、內(nèi)存布局、結(jié)構(gòu)體、全局函數(shù)面向?qū)ο?C類、封裝、繼承、多態(tài)、虛函數(shù)業(yè)務(wù)模型、架構(gòu)分層、接口設(shè)計(jì)抽象、對(duì)象生命周期、接口與實(shí)現(xiàn)分離模板 C編譯期計(jì)算、類型推導(dǎo)、泛型抽象容器、算法、類型安全的工具庫(kù)類型層面的復(fù)用、編譯期行為STL容器、迭代器、算法、函數(shù)對(duì)象日常數(shù)據(jù)結(jié)構(gòu)和算法使用迭代器、算法復(fù)雜度、容器的選擇舉個(gè)例子寫(xiě)嵌入式驅(qū)動(dòng)時(shí)我基本在用次語(yǔ)言 C 的方式結(jié)構(gòu)體、位運(yùn)算、函數(shù)指針寫(xiě)一個(gè)大型業(yè)務(wù)系統(tǒng)時(shí)我切換到面向?qū)ο蟮拇握Z(yǔ)言抽象基類、虛函數(shù)、工廠模式寫(xiě)一個(gè)通用緩存組件時(shí)我可能大量使用模板和 traits處理一批數(shù)據(jù)時(shí)我直接用 STL 的 vector、sort、find_if。規(guī)則很簡(jiǎn)單當(dāng)遵循某一子語(yǔ)言的規(guī)約時(shí)你就只在這個(gè)子語(yǔ)言的框架內(nèi)思考這樣能顯著降低認(rèn)知負(fù)擔(dān)。條款 01 還提醒了一個(gè)很實(shí)用的點(diǎn)不同次語(yǔ)言的“高效編程守則”不同取決于你用哪種風(fēng)格編程。比如在傳統(tǒng)的 C 部分值傳遞通常沒(méi)問(wèn)題但在面向?qū)ο蟮?C 里值傳遞意味著拷貝構(gòu)造和析構(gòu)的額外開(kāi)銷常量引用往往更合適而在模板代碼里你又得考慮引用折疊和完美轉(zhuǎn)發(fā)。很多人寫(xiě) C 代碼“哪里都別扭”往往不是因?yàn)檎Z(yǔ)法不熟而是因?yàn)闆](méi)有意識(shí)到自己在混合使用多個(gè)子語(yǔ)言卻沒(méi)有切換對(duì)應(yīng)的策略。1.3 用這個(gè)視角重新審視你手頭的代碼讀完這個(gè)條款后我做了一次自查把最近一年寫(xiě)的某個(gè)模塊按四個(gè)次語(yǔ)言分類發(fā)現(xiàn)真正屬于“面向?qū)ο?C”的代碼只占三成另外三成是 C 風(fēng)格的實(shí)現(xiàn)四成是 STL 和模板的混合。這個(gè)分類幫我找到不少壞味道——比如我在 C 風(fēng)格的模塊里用了 dynamic_cast在泛型代碼里用虛函數(shù)實(shí)現(xiàn)多態(tài)這些都屬于“跨領(lǐng)地操作”雖然能編譯通過(guò)但設(shè)計(jì)上確實(shí)不夠干凈。條款 01 本身沒(méi)有任何代碼示例也不涉及具體語(yǔ)法但它給了你一個(gè)判斷標(biāo)準(zhǔn)當(dāng)你寫(xiě)某段代碼覺(jué)得“別扭”時(shí)先問(wèn)自己四個(gè)問(wèn)題——我在用哪個(gè)次語(yǔ)言寫(xiě)這個(gè)次語(yǔ)言的標(biāo)準(zhǔn)做法是什么我是不是把另一個(gè)次語(yǔ)言的規(guī)則帶過(guò)來(lái)了能不能換一種寫(xiě)法這四個(gè)問(wèn)題我在 Code Review 時(shí)也經(jīng)常用來(lái)反問(wèn)別人比直接改代碼有效得多。2. 從預(yù)處理時(shí)代到編譯器時(shí)代const、enum、inline 如何替換 #define2.1 #define 的三個(gè)典型隱患條款 02 是“盡量以 const、enum、inline 替換 #define”這一條表面上是說(shuō)風(fēng)格實(shí)際上是講“不要依賴預(yù)處理器做語(yǔ)言本身該做的事”。我剛?cè)胄袝r(shí)特別喜歡用宏定義常量用 #define寫(xiě)小工具函數(shù)用 #define覺(jué)得簡(jiǎn)單粗暴。后來(lái)在項(xiàng)目里吃過(guò)虧才明白宏的三大隱患。第一宏不進(jìn)入符號(hào)表出了編譯錯(cuò)誤你看到的只有數(shù)字。比如你寫(xiě)了 #define N 42編譯器報(bào)錯(cuò)信息里只顯示 42不會(huì)提示你 N 是什么排查起來(lái)全靠記憶。第二宏沒(méi)有類型檢查。你在一個(gè)需要 double 的地方使用了 int 宏常量編譯器不會(huì)給出任何警告一旦協(xié)議格式變化或者數(shù)據(jù)類型升級(jí)問(wèn)題就埋在深處。第三宏在預(yù)處理階段替換展開(kāi)后的副作用很難預(yù)測(cè)尤其是帶參數(shù)的宏比如 #define SQUARE(x) x * x調(diào)用 SQUARE(1 2) 會(huì)展開(kāi)成 1 2 * 1 2結(jié)果出乎所有人意料。2.2 常量替換的具體做法和場(chǎng)景選擇原書(shū)給出的替代方案很明確對(duì)于常量?jī)?yōu)先用 const 常量替代 #define如果要在 class 內(nèi)定義一個(gè)常量且希望它是編譯期整數(shù)常量可以用 enum 來(lái)實(shí)現(xiàn)“類似宏”的效果對(duì)于函數(shù)宏用 inline 函數(shù)替代。先看最常用的 const 場(chǎng)景。假設(shè)你需要定義一個(gè)全局常量// 原來(lái)的寫(xiě)法 #define MAX_CONNECTIONS 1024 // 替換后的寫(xiě)法 const int MaxConnections 1024;這里有個(gè)容易忽略的細(xì)節(jié)當(dāng)你用 const 定義全局常量時(shí)這個(gè)常量默認(rèn)具有內(nèi)部鏈接internal linkage也就是每個(gè)編譯單元都有一份獨(dú)立的副本。如果你想讓它在多個(gè)編譯單元之間共享需要加上 extern// 頭文件 extern const int MaxConnections; // 某個(gè) .cpp 文件 const int MaxConnections 1024;這一點(diǎn)在大型項(xiàng)目里特別重要。我見(jiàn)過(guò)好幾個(gè)項(xiàng)目頭文件里直接寫(xiě) const std::string kAppName xxx;結(jié)果每個(gè)包含這個(gè)頭文件的編譯單元都實(shí)例化了一份 std::string白白增加了二進(jìn)制體積。用 extern 聲明、在 cpp 中定義是更好的做法。再看 class 內(nèi)的常量。如果常量只在類內(nèi)使用而且你想讓它在類內(nèi)“可見(jiàn)”C11 之前有一種常見(jiàn)寫(xiě)法class GamePlayer { private: static const int NumTurns 5; // 常量聲明 int scores[NumTurns]; // 用它來(lái)聲明數(shù)組 };這種寫(xiě)法依賴“類內(nèi)初始化器”和“編譯期常量”的結(jié)合。C11 之后static constexpr 更推薦比如 static constexpr int NumTurns 5;語(yǔ)義更明確。但這里有一個(gè)“歷史遺留坑”如果你只是聲明了 static const int NumTurns 而沒(méi)有在類外定義取它的地址例如 int* p NumTurns;時(shí)某些編譯器會(huì)報(bào)鏈接錯(cuò)誤因?yàn)槿鄙俣x。解決方法是類外補(bǔ)一行 const int GamePlayer::NumTurns;而 enum 不存在這個(gè)問(wèn)題——enum 沒(méi)有地址的概念。這就是條款 02 提到的 enum hack當(dāng)你想在類內(nèi)聲明一個(gè)編譯期整數(shù)常量又不想讓它變成可被取地址的對(duì)象時(shí)enum 是一個(gè)非常合適的工具class GamePlayer { private: enum { NumTurns 5 }; int scores[NumTurns]; };enum 的意義在于它既是編譯期常量又有類型約束而且不能被取地址也沒(méi)有鏈接問(wèn)題。在工作中如果你寫(xiě)的是老代碼風(fēng)格的項(xiàng)目enum hack 依然能解決實(shí)際問(wèn)題如果你用的是 C17 及以后的標(biāo)準(zhǔn)static constexpr 通常更優(yōu)雅但理解 enum hack 背后的動(dòng)機(jī)依然重要因?yàn)樗谀0逶幊汤餆o(wú)處不在。2.3 函數(shù)宏的 inline 替換帶參數(shù)的宏是一門“危險(xiǎn)的藝術(shù)”典型的 #define CALL_WITH_MAX(a, b) ((a) (b) ? (a) : (b)) 即使加了括號(hào)也可能因?yàn)閷?shí)參自增表達(dá)式被多次求值而產(chǎn)生問(wèn)題。我實(shí)際踩過(guò)一次傳了一個(gè) map.find() 的返回值宏內(nèi)部把它比較了兩次、取用了三次迭代器狀態(tài)直接亂掉。inline 函數(shù)可以完美解決這個(gè)問(wèn)題。把宏改成普通函數(shù)加 inline 關(guān)鍵字不僅類型安全參數(shù)只求值一次而且行為可預(yù)測(cè)templatetypename T inline T callWithMax(const T a, const T b) { return a b ? a : b; }注意這個(gè)模板版本接受任意類型而且使用 const 引用避免了不必要的拷貝。這一點(diǎn)是宏無(wú)法做到的宏沒(méi)有類型概念不會(huì)幫你做拷貝優(yōu)化也不理解引用語(yǔ)義。在熱路徑上inline 函數(shù)有機(jī)會(huì)被內(nèi)聯(lián)展開(kāi)性能上與宏接近但可調(diào)試性和可讀性完勝。我自己的心得是不要試圖“完全消滅宏”而是要弄懂宏能做什么、不能做什么。配置文件里的版本號(hào)、條件編譯開(kāi)關(guān)、以及那些必須依賴預(yù)處理指令才能實(shí)現(xiàn)的功能宏依然是唯一選擇但凡是“語(yǔ)言層面能表達(dá)的東西”優(yōu)先用語(yǔ)言特性。這條邏輯直到今天依然成立。3. const 的多重身份指針、容器、函數(shù)返回值與成員函數(shù)3.1 指針和迭代器上的 const位置決定含義條款 03 的主題是“盡可能使用 const”。這一條看起來(lái)簡(jiǎn)單實(shí)際是 C 里最容易出 bug 的語(yǔ)法點(diǎn)之一尤其是 const 加在指針的哪個(gè)位置含義完全不同。char greeting[] hello; char* p greeting; // 非 const 指針?lè)?const 數(shù)據(jù) const char* p2 greeting; // 非 const 指針const 數(shù)據(jù) char* const p3 greeting; // const 指針?lè)?const 數(shù)據(jù) const char* const p4 greeting; // const 指針const 數(shù)據(jù)判斷方法很簡(jiǎn)單看 const 修飾的是誰(shuí)。const 在星號(hào)左邊修飾的是“被指向的數(shù)據(jù)”const 在星號(hào)右邊修飾的是“指針本身”。這里的“數(shù)據(jù)”到底是字符串還是對(duì)象在數(shù)組和指針場(chǎng)景中表現(xiàn)得特別明顯。我經(jīng)常在 Code Review 里看到有人把只讀的字符串參數(shù)寫(xiě)成 char*結(jié)果旁邊還掛著一堆 const 警告。改成 const char* 或 const std::string函數(shù)體里再也不會(huì)有人試圖修改傳入內(nèi)容。STL 迭代器也有同樣的規(guī)則。const_iterator 相當(dāng)于 const 數(shù)據(jù)的指針?biāo)赶虻娜萜髟夭荒芡ㄟ^(guò)該迭代器修改但迭代器本身可以移動(dòng)而 const 迭代器iterator 對(duì)象本身是 const不能移動(dòng)但指向的元素可以改。前者是“只讀視角”后者是“固定位置”。std::vectorint vec {1, 2, 3}; const std::vectorint::iterator it vec.begin(); // 迭代器本身不能移動(dòng) std::vectorint::const_iterator cit vec.begin(); // 指向的元素不能改 *it 10; // 可以元素非 const it; // 編譯錯(cuò)誤迭代器本身是 const cit; // 可以迭代器可移動(dòng) *cit 10; // 編譯錯(cuò)誤元素被 const 限制我第一次把這四種情況列在一起時(shí)才徹底弄清了 why。核心在于STL 迭代器用法上像指針語(yǔ)義上也分“指向的東西”和“自身”。把 const 用對(duì)位置能直接消除一整類編譯期問(wèn)題。3.2 函數(shù)返回值加 const防止語(yǔ)義被誤用const 不僅可以用在參數(shù)和變量上還可以修飾函數(shù)返回值。原書(shū)舉的例子是重載運(yùn)算符。像 operator* 返回 const Rational能防止用戶寫(xiě)出 (a * b) c 這種荒謬的代碼。這個(gè)例子放在今天某些人眼里會(huì)覺(jué)得小題大做但它背后的問(wèn)題在自定義類型里非常常見(jiàn)。我自己遇到過(guò)類似的場(chǎng)景某個(gè)類實(shí)現(xiàn)了 .value() 方法返回內(nèi)部值的引用調(diào)用方不小心把返回值賦給了一個(gè)可變引用然后修改了內(nèi)部狀態(tài)整個(gè)對(duì)象的封裝性被擊穿。如果返回值加了 const 限定編譯器會(huì)在第一時(shí)間報(bào)錯(cuò)。所以我的習(xí)慣是默認(rèn)情況下返回內(nèi)部狀態(tài)的 getter 要么返回值要么返回 const 引用只有當(dāng)調(diào)用方確實(shí)需要修改內(nèi)部對(duì)象時(shí)才提供非 const 版本。這個(gè)習(xí)慣能擋住很多無(wú)意識(shí)的破壞。另一個(gè)容易忽略的角度是const 返回值還能讓臨時(shí)對(duì)象讀起來(lái)更安全。比如 operator 返回 const 對(duì)象時(shí)臨時(shí)對(duì)象的生命周期不會(huì)變長(zhǎng)但它阻止了對(duì)臨時(shí)對(duì)象的修改。雖然 C11 移動(dòng)語(yǔ)義讓某些“返回 const 對(duì)象會(huì)阻礙移動(dòng)”的問(wèn)題浮現(xiàn)出來(lái)但具體問(wèn)題還要具體看如果返回值是自己封裝的小對(duì)象且沒(méi)有移動(dòng)需求const 返回值依然是合理選擇。3.3 const 成員函數(shù)接口的“讀寫(xiě)權(quán)限聲明”條款 03 后半段討論了 const 成員函數(shù)這是很多初學(xué)者最陌生、但實(shí)際項(xiàng)目中最常見(jiàn)的設(shè)計(jì)手段。給成員函數(shù)加上 const等于告訴調(diào)用方“這個(gè)函數(shù)不會(huì)修改對(duì)象的邏輯狀態(tài)”。好處有兩點(diǎn)一是從接口層面直接區(qū)分“讀”和“寫(xiě)”使用者一看便知二是 const 對(duì)象只能調(diào)用 const 成員函數(shù)這決定了你的類能否被 const 引用安全地使用。C 對(duì) const 成員函數(shù)的默認(rèn)要求是“bitwise constness”也就是成員變量一個(gè)字節(jié)都不能變。但實(shí)際需求往往是“l(fā)ogical constness”邏輯上不該變但某些內(nèi)部緩存字段可以變。這時(shí)候需要 mutable 成員變量。class TextBlock { public: size_t length() const { if (!lengthValid) { cachedLength computeLength(); // 緩存長(zhǎng)度 lengthValid true; } return cachedLength; } private: std::string text; mutable size_t cachedLength 0; mutable bool lengthValid false; };這個(gè)例子我拿它解釋 mutable 時(shí)幾乎所有人都能秒懂查詢長(zhǎng)度是個(gè)只讀操作但為了提升后續(xù)查詢速度需要第一次調(diào)用時(shí)偷偷緩存結(jié)果。如果沒(méi)有 mutableconst 成員函數(shù)里就寫(xiě)不了這種代碼。注意mutable 只能修飾成員變量不能修飾局部變量它是對(duì) const 成員函數(shù)“內(nèi)部可變性”的一種精確放行。還有一個(gè)經(jīng)常被寫(xiě)錯(cuò)的地方const 版本和非 const 版本的成員函數(shù)可以重載但實(shí)現(xiàn)時(shí)要注意避免代碼重復(fù)。原書(shū)給的技巧是“非 const 版本調(diào)用 const 版本”利用 const_cast 去掉返回值的 const 限制。我在實(shí)際項(xiàng)目中一般會(huì)反過(guò)來(lái)思考先把邏輯都放進(jìn) const 版本里非 const 版本只做轉(zhuǎn)發(fā)這樣最安全因?yàn)?const 版本對(duì)所有對(duì)象都可用。但我要提醒一句const 成員函數(shù)是“承諾”不是“強(qiáng)制”。如果你在 const 成員函數(shù)里調(diào)用了一個(gè)非 const 成員函數(shù)編譯器會(huì)拒絕但如果你用 const_cast 強(qiáng)轉(zhuǎn)那等于自己撕毀了承諾一旦對(duì)象本來(lái)是真正的 const 對(duì)象修改它就是未定義行為。所以 const_cast 只能用在“你知道對(duì)象本身不是 const”的場(chǎng)景里比如先查緩存、再返回可變引用這種經(jīng)典寫(xiě)法而不是用來(lái)繞過(guò) const 檢查。4. 初始化不是賦值對(duì)象使用前的“四大坑”與解決套路4.1 為什么 C 的初始化規(guī)則總把人繞暈條款 04 是“確定對(duì)象被使用前已被初始化”這大概是第一章里實(shí)際踩坑最多的一條。C 的初始化問(wèn)題非常擰巴有的變量不初始化就是垃圾值有的類成員的默認(rèn)構(gòu)造會(huì)自動(dòng)初始化有的類型比如內(nèi)置類型在你聲明時(shí)不給你任何默認(rèn)值。我剛工作那年就吃過(guò)虧一個(gè)局部 bool 變量沒(méi)初始化剛好是 true程序走錯(cuò)了分支排查了一下午。這里要分清兩個(gè)概念初始化和賦值。初始化是“對(duì)象一生下來(lái)就有值”賦值是“先有對(duì)象再把值放進(jìn)去”。對(duì)于內(nèi)置類型這兩者在形式上看起來(lái)一樣但對(duì)于類類型初始化調(diào)用的是構(gòu)造函數(shù)賦值調(diào)用的是 operator語(yǔ)義完全不同。用成員初始化列表就是初始化在構(gòu)造函數(shù)體內(nèi)賦值則是“先默認(rèn)構(gòu)造再賦值”后者多了一次無(wú)意義的默認(rèn)構(gòu)造開(kāi)銷有時(shí)還會(huì)導(dǎo)致不可預(yù)期的行為比如引用成員根本不能賦值。我對(duì)這條規(guī)則的執(zhí)行標(biāo)準(zhǔn)很簡(jiǎn)單所有內(nèi)置類型成員、指針成員一律在聲明處初始化或構(gòu)造函數(shù)初始化列表中顯式初始化所有類類型成員能用初始化列表就用初始化列表。哪怕你覺(jué)得“反正默認(rèn)構(gòu)造也是空的賦不賦值都一樣”也要把初始化列表寫(xiě)出來(lái)因?yàn)榇a的可維護(hù)性來(lái)自“一眼能看出這個(gè)成員的值從哪里來(lái)”而不是靠推理。4.2 初始化列表與成員聲明順序的“隱形炸彈”用初始化列表時(shí)有一個(gè)特別隱蔽的規(guī)則成員的初始化順序只由它們?cè)陬愔械穆暶黜樞驔Q定與初始化列表的書(shū)寫(xiě)順序無(wú)關(guān)。也就是說(shuō)即使你在初始化列表里把 y 寫(xiě)在 x 前面如果類中先聲明 x實(shí)際仍然是先初始化 x再初始化 y。這個(gè)坑在“用別的成員初始化當(dāng)前成員”時(shí)特別致命。例如class Point { public: Point() : y(2), x(y) {} // 看起來(lái) y 先初始化其實(shí)還是 x 先初始化 private: int x; int y; };實(shí)際運(yùn)行時(shí)x 先初始化而 x 的初始化用的是 y 的當(dāng)前值——此時(shí) y 還沒(méi)初始化是個(gè)垃圾值。于是 x 得到一個(gè)隨機(jī)數(shù)而不是預(yù)想的 2。這個(gè) bug 編譯器通常不會(huì)警告只有開(kāi)了 -Wreorder 之類的選項(xiàng)或者運(yùn)行時(shí)輸出異常才會(huì)發(fā)現(xiàn)。我的建議是把初始化列表的書(shū)寫(xiě)順序永遠(yuǎn)和成員聲明順序保持一致并且盡量避免在成員初始化時(shí)依賴其他成員的值。依賴關(guān)系如果真的存在優(yōu)先在構(gòu)造函數(shù)體內(nèi)處理或者干脆用一個(gè)靜態(tài)函數(shù)統(tǒng)一計(jì)算。這條經(jīng)驗(yàn)看著簡(jiǎn)單但能救你很多次。4.3 跨編譯單元的 static 對(duì)象初始化次序問(wèn)題經(jīng)典中的經(jīng)典條款 04 最后討論了一個(gè)更宏觀的問(wèn)題不同編譯單元里定義的 static 對(duì)象的初始化次序是不確定的。這里的 static 對(duì)象包括全局對(duì)象、namespace 作用域的對(duì)象、類里的 static 對(duì)象、函數(shù)里的 static 對(duì)象以及文件作用域的 static 對(duì)象。當(dāng)一個(gè) static 對(duì)象的初始化依賴另一個(gè)編譯單元中的 static 對(duì)象時(shí)你無(wú)法保證前者初始化時(shí)后者已經(jīng)初始化完畢。假設(shè) A.cpp 里有一個(gè)全局配置對(duì)象 ConfigManagerB.cpp 里有一個(gè)注冊(cè)表 RegistryRegistry 的構(gòu)造函數(shù)里要用 ConfigManager 去讀取配置。兩個(gè)文件誰(shuí)先初始化、誰(shuí)后初始化完全由鏈接順序和編譯器實(shí)現(xiàn)決定沒(méi)有任何標(biāo)準(zhǔn)保證。運(yùn)行起來(lái)可能這次正常、下次崩換個(gè)編譯器直接掛在啟動(dòng)階段。原書(shū)給出的解法我一直在用把“非局部 static 對(duì)象”替換成“函數(shù)內(nèi)的局部 static 對(duì)象”也就是用“構(gòu)造時(shí)初始化”的函數(shù)返回引用ConfigManager getConfigManager() { static ConfigManager instance; return instance; }這種做法被稱為 “Meyers’ Singleton” 模式雖然這里不強(qiáng)調(diào)單例但這個(gè)技術(shù)在單例場(chǎng)景最常用。C11 之后函數(shù)內(nèi) static 對(duì)象的初始化是線程安全的編譯器會(huì)生成相應(yīng)的保證一次性初始化的代碼所以這個(gè)方案還順便解決了多線程環(huán)境下的初始化競(jìng)爭(zhēng)問(wèn)題。我在一個(gè)后臺(tái)服務(wù)里用這個(gè)方式管理配置、日志、連接池等組件啟動(dòng)順序問(wèn)題從根上消失了。這個(gè)技巧也有需要注意的地方如果兩個(gè)函數(shù)的初始化存在循環(huán)依賴A 初始化時(shí)需要 BB 初始化時(shí)需要 A這個(gè)方案同樣救不了你只能靠重構(gòu)拆解依賴。另外在 C11 之前的老編譯器上函數(shù)內(nèi) static 初始化的線程安全問(wèn)題需要自己加鎖不過(guò)現(xiàn)在新項(xiàng)目基本默認(rèn) C17 或更高標(biāo)準(zhǔn)這個(gè)顧慮已經(jīng)很小了。5. 這些規(guī)則在今天還能怎么用我的幾條實(shí)戰(zhàn)經(jīng)驗(yàn)5.1 用“四個(gè)次語(yǔ)言”做 Code Review 的前置分類讀完條款 01 后我形成了一套自己的 Code Review 流程先看這段代碼屬于哪個(gè)次語(yǔ)言再用對(duì)應(yīng)次語(yǔ)言的標(biāo)準(zhǔn)去評(píng)價(jià)。比如一個(gè)用 STL 算法實(shí)現(xiàn)的過(guò)濾邏輯我會(huì)關(guān)注迭代器用法、算法復(fù)雜度、是否能用 lambda 簡(jiǎn)化一個(gè)用面向?qū)ο箫L(fēng)格設(shè)計(jì)的類我會(huì)關(guān)注接口設(shè)計(jì)、const 正確性、是否違反開(kāi)閉原則一個(gè)模板工具函數(shù)我會(huì)關(guān)注類型推導(dǎo)、引用折疊、是否需要 SFINAE 或概念約束。這種分類思維能讓你在面對(duì)“這代碼寫(xiě)得好不好”時(shí)給出一個(gè)更精準(zhǔn)的答案而不是籠統(tǒng)地說(shuō)“感覺(jué)不太優(yōu)雅”。C 的“好”永遠(yuǎn)是相對(duì)的同一段代碼放在 C 風(fēng)格模塊里可能不倫不類放在模板庫(kù)的擴(kuò)展里可能恰到好處。5.2 const 正確性是“傳染性”的早加比晚加便宜得多const 正確性有一個(gè)特點(diǎn)它像一種會(huì)“傳染”的屬性。一個(gè)沒(méi)有 const 的函數(shù)調(diào)用另一個(gè)函數(shù)時(shí)如果后者是 const 成員函數(shù)前者可以安全調(diào)用但如果一個(gè) const 對(duì)象調(diào)用了非 const 成員函數(shù)編譯器直接報(bào)錯(cuò)。所以 const 加得越早后面其他代碼就越容易保持 const 安全等代碼寫(xiě)完了再補(bǔ) const往往是一串連鎖修改。我建議新寫(xiě)的接口所有參數(shù)能加 const 引用的都加所有不修改內(nèi)部狀態(tài)的成員函數(shù)都標(biāo) const。你可以不加 const 也能編譯通過(guò)但加了 const 之后編譯器會(huì)強(qiáng)制你維護(hù)這個(gè)承諾長(zhǎng)期看能擋掉很多無(wú)意的修改。這里的額外價(jià)值是當(dāng)別的函數(shù)需要一個(gè) const 對(duì)象時(shí)你的類已經(jīng)準(zhǔn)備好了。5.3 初始化順序問(wèn)題編譯器不是你的朋友而是你的敵人我最后想強(qiáng)調(diào)的還是初始化。每次看到有人自信地說(shuō)“對(duì)象反正會(huì)默認(rèn)初始化”我就想起純 C 時(shí)代的局部變量它不會(huì)默認(rèn)初始化。C 在類類型上引入了默認(rèn)構(gòu)造的概念但它依然不會(huì)幫你初始化內(nèi)置類型成員。很多人以為的“默認(rèn)初始化”實(shí)際上是“默認(rèn)不初始化”直到程序在某個(gè)隨機(jī)時(shí)間點(diǎn)爆出奇怪的值才意識(shí)到。我的建議是給自己定一條鐵律所有需要初始化的對(duì)象要么在聲明處初始化要么在構(gòu)造函數(shù)的初始化列表中明確寫(xiě)出。不依賴“默認(rèn)值”不依賴“某個(gè)函數(shù)會(huì)幫我填”不依賴“運(yùn)氣”。這個(gè)習(xí)慣比任何靜態(tài)檢查工具都有效因?yàn)樗鼜脑搭^消除了未定義狀態(tài)。6. 讀這一章時(shí)我踩過(guò)的幾個(gè)坑最后聊點(diǎn)個(gè)人體會(huì)。我第一次讀條款 02 時(shí)覺(jué)得自己早就知道 const、enum、inline 的用法沒(méi)必要逐條看。結(jié)果沒(méi)過(guò)多久就在實(shí)際項(xiàng)目中犯了一個(gè)教科書(shū)級(jí)的錯(cuò)誤我在頭文件里寫(xiě)了 #define PI 3.1415926另一個(gè)模塊剛好也定義了一個(gè)同名宏預(yù)處理階段兩個(gè)宏互相污染編譯錯(cuò)誤信息里全是裸的數(shù)字。那一刻我想起條款 02 的副標(biāo)題才明白“讓自己習(xí)慣C”不是讓你知道這些技巧而是讓這些技巧成為你的默認(rèn)選項(xiàng)。另一個(gè)讓我印象深刻的坑是 static 初始化順序。當(dāng)年做一個(gè)中間件插件兩個(gè)插件各自維護(hù)一個(gè)靜態(tài)注冊(cè)表一個(gè)的注冊(cè)入口依賴另一個(gè)的版本號(hào)。開(kāi)發(fā)環(huán)境一切正常發(fā)布到線上偶發(fā)崩潰。后來(lái)查了幾天發(fā)現(xiàn)問(wèn)題就是跨編譯單元的 static 初始化順序不確定。用局部 static 函數(shù)替換全局對(duì)象后問(wèn)題徹底消失。這個(gè)經(jīng)歷讓我對(duì)“非局部 static 對(duì)象”產(chǎn)生了心理陰影——現(xiàn)在凡是看到全局配置、全局單例、全局注冊(cè)表我都會(huì)下意識(shí)去想它們的初始化依賴關(guān)系。還有一點(diǎn)const 成員函數(shù)里的 mutable 用得多了會(huì)讓類的線程安全性變差。比如上面那個(gè)緩存長(zhǎng)度的例子如果兩個(gè)線程同時(shí)調(diào)用 length()mutable 的緩存字段可能被并發(fā)寫(xiě)。實(shí)際工程里要么用 std::call_once 或 std::mutex 保護(hù)緩存邏輯要么干脆在文檔里注明這個(gè)類不是線程安全的。我后來(lái)給自己定了一個(gè)原則mutable 只能用于“線程邊界明確”的場(chǎng)景否則寧可放棄緩存也不引入數(shù)據(jù)競(jìng)爭(zhēng)。如果你正要開(kāi)始讀 Effective C 或者已經(jīng)讀到后面幾章我建議你時(shí)不時(shí)回到第一章看一眼。這不是溫故知新而是這一章講的是“思維方式”而不是“語(yǔ)法糖”。等你真正把“語(yǔ)言聯(lián)邦”“用編譯器替預(yù)處理器”“盡可能 const”“先初始化再使用”這四條刻進(jìn)寫(xiě)代碼的本能里后面 51 個(gè)條款讀起來(lái)會(huì)順很多。