部類、匿名對(duì)象與編譯器優(yōu)化的實(shí)戰(zhàn)解析)
寫C的人大多有這種感覺學(xué)完構(gòu)造函數(shù)、析構(gòu)函數(shù)、繼承和多態(tài)以為自己已經(jīng)掌握了類的全部結(jié)果一碰到友元、內(nèi)部類、匿名對(duì)象這類“邊角料”概念就犯迷糊——面試時(shí)可以背出定義真正寫代碼時(shí)卻不知道什么時(shí)候用、為什么用。更別提“編譯器優(yōu)化”很多人覺得那純粹是編譯器的事跟自己的代碼風(fēng)格毫無關(guān)系。其實(shí)這四個(gè)話題在真實(shí)項(xiàng)目中關(guān)系極為密切友元決定了類與類之間的信任邊界內(nèi)部類決定了類型之間誰是主人誰是仆人匿名對(duì)象的構(gòu)造和析構(gòu)時(shí)機(jī)直接影響程序行為和性能而編譯器優(yōu)化尤其是復(fù)制消除和移動(dòng)語義才是決定“看起來有拷貝”和“實(shí)際上沒拷貝”之間差異的根本原因。這篇文章不搞理論背誦我會(huì)把這四個(gè)概念拆開結(jié)合可運(yùn)行的代碼和分析思路把它們的本質(zhì)、適用場景、以及相互之間的聯(lián)動(dòng)關(guān)系講清楚。適合對(duì)C類有基本了解、想在細(xì)節(jié)和實(shí)戰(zhàn)層面更進(jìn)一步的學(xué)習(xí)者和一線開發(fā)者參考尤其建議準(zhǔn)備面試、或者在實(shí)際項(xiàng)目中反復(fù)糾結(jié)“這里要不要寫friend”“這樣寫會(huì)不會(huì)多構(gòu)造一次”的人仔細(xì)看看。1. 友元為什么存在封裝原則下的一扇設(shè)計(jì)出來的“后門”很多人剛接觸友元時(shí)都會(huì)有一個(gè)天然疑問C不是大力提倡封裝嗎把成員設(shè)置成private就是為了不讓別人亂動(dòng)結(jié)果又冒出個(gè)friend來開后門這不是自相矛盾嗎要回答這個(gè)問題得先理解封裝保護(hù)的不是“數(shù)據(jù)”而是“數(shù)據(jù)的內(nèi)部結(jié)構(gòu)”。1.1 運(yùn)算符重載困境友元背后真正的推動(dòng)力最典型的場景是運(yùn)算符重載。假設(shè)你設(shè)計(jì)了一個(gè)有理數(shù)類#include iostream class Rational { private: int num; // 分子 int den; // 分母 public: Rational(int n 0, int d 1) : num(n), den(d) {} }; Rational operator(const Rational lhs, const Rational rhs) { // 這里想拿到 lhs.num 和 rhs.num但 num 是 private編譯直接失敗 return Rational(lhs.num * rhs.den rhs.num * lhs.den, lhs.den * rhs.den); }想讓重載的加法運(yùn)算符讀取分子分母有兩條路要么把成員都設(shè)成public要么給這個(gè)算子函數(shù)提供公有的getter。前者等于把整個(gè)封裝推倒重來任何外部代碼都能隨意改分母為0后者則把內(nèi)部數(shù)據(jù)暴露給所有調(diào)用者為了一個(gè)操作符放棄整個(gè)防線。友元給出的答案是第三條路單獨(dú)信任這一個(gè)函數(shù)讓它能訪問私有成員其他外部代碼一律不放開。 注意友元本身不是主題真正的主題是“最小授權(quán)”——給任何一個(gè)函數(shù)或類開放私有成員訪問都意味著你在承擔(dān)額外的維護(hù)成本和封裝破壞風(fēng)險(xiǎn)所以授權(quán)面越小越好。把operator聲明為友元后它在語法上是一個(gè)普通全局函數(shù)卻擁有類私有成員的訪問權(quán)。這樣既保留了Rational內(nèi)部的私有性又讓操作符能自然地工作。這里有一個(gè)值得深入的點(diǎn)C雖然沒有像某些語言那樣提供“運(yùn)算符重載”的成員語法強(qiáng)制方案但設(shè)計(jì)者把“左右操作數(shù)對(duì)稱性”作為了核心考量——當(dāng)重載出現(xiàn)在左側(cè)操作數(shù)是本類型時(shí)可以做成成員函數(shù)但像cout rational或5 rational這樣的場景必須是非成員函數(shù)這時(shí)友元幾乎是唯一不破壞封裝的路徑。1.2 三種友元形態(tài)與授權(quán)粒度友元有三種聲明方式授權(quán)粒度完全不同class Device; class Manager { public: void reset(Device d); // 僅重置設(shè)備 }; class Device { private: int status; friend void debug(Device d); // 全局函數(shù)可以訪問 friend class Resetter; // 整個(gè) Resetter 類都可以訪問 friend void Manager::reset(Device d); // 只授權(quán) Manager::reset 這個(gè)成員函數(shù) };友元全局函數(shù)比如debug()、重載的operator最常用授權(quán)面最小推薦優(yōu)先使用。友元類如果你想讓Resetter的所有成員函數(shù)都能訪問Device的私有數(shù)據(jù)用這個(gè)。但它缺點(diǎn)是授權(quán)面太大后續(xù)增加任何成員函數(shù)都會(huì)自動(dòng)獲得訪問權(quán)限。友元成員函數(shù)只對(duì)一個(gè)具體成員函數(shù)授權(quán)粒度最細(xì)。代價(jià)是聲明順序有講究必須先聲明或定義Manager::reset并且Manager的定義要完整可見否則編譯器不認(rèn)識(shí)這個(gè)成員函數(shù)。實(shí)際工程里我維護(hù)過一個(gè)硬件驅(qū)動(dòng)模塊內(nèi)部狀態(tài)機(jī)有大量私有狀態(tài)需要被日志系統(tǒng)讀取當(dāng)時(shí)就是用一個(gè)日志函數(shù)作為友元而沒讓整個(gè)日志類成為友元避免后續(xù)日志類新增任何功能都自動(dòng)摸到硬件內(nèi)部寄存器。這就是經(jīng)驗(yàn)的差距不是能不能用friend而是要控制frontdoor開到多大。1.3 友元的三條規(guī)則不傳遞、不繼承、不回溯友元有三個(gè)容易踩坑的性質(zhì)雖然教科書提過但在實(shí)際項(xiàng)目中特別容易被忽略不傳遞A是B的友元B是C的友元不意味著A是C的友元。每次跨類的私有訪問都需要單獨(dú)建立信任關(guān)系。不繼承如果基類有個(gè)友元函數(shù)它在派生類對(duì)象上無法訪問派生類新增的私有成員。友元關(guān)系不會(huì)隨繼承傳遞下去。不回溯友元可以訪問聲明它的類的私有成員但反過來不行——如果A聲明B為友元A不能因此訪問B的私有成員。這三點(diǎn)看似簡單組合起來卻很容易引發(fā)設(shè)計(jì)問題項(xiàng)目中有人把friend寫在基類里派生類需要同樣訪問時(shí)發(fā)現(xiàn)報(bào)錯(cuò)訪問權(quán)限不足這才知道友元沒有被繼承。遇到這種情況不要試圖繞過而是考慮把那個(gè)需要訪問私有成員的邏輯抽成接口或干脆讓派生類通過受保護(hù)的成員走正常繼承路徑。2. 內(nèi)部類類型嵌套的真正價(jià)值不是“玩法”是邊界控制內(nèi)部類也就是嵌套類在C里一直存在但很多初學(xué)者把它當(dāng)成一種奇怪的噱頭——明明可以拆分出兩個(gè)平級(jí)的類為什么要嵌套在另一個(gè)類里面我的看法是內(nèi)部類是一種作用域和訪問控制雙重工具它最有價(jià)值的地方是“把不想給別人看的類型藏起來”以及“讓相關(guān)聯(lián)的類型從命名上就綁定在一起”。2.1 單向透明的訪問規(guī)則內(nèi)可看外外不可看內(nèi)C11之后內(nèi)部類對(duì)外圍類的私有成員擁有天然訪問權(quán)不需要顯式聲明friend。反之外圍類不能自動(dòng)訪問內(nèi)部類的私有成員。這個(gè)規(guī)則初看有點(diǎn)不對(duì)稱但邏輯是自洽的內(nèi)部類是外圍類的一部分就像公司的內(nèi)部部門能看公司的賬本但公司管理層不會(huì)自動(dòng)知道每個(gè)部門內(nèi)部的日程。class Outer { private: int secret 42; class Inner { public: // Inner 能訪問 Outer 的 private 成員 void print(const Outer o) { std::cout o.secret std::endl; } }; public: Inner getInner() { return Inner(); } }; // 在外部甚至無法提及 Outer::Inner 的類型因?yàn)樗?private 的在這個(gè)例子里Outer::Inner是私有的外面根本看不到這個(gè)類型只能通過外層類的成員函數(shù)獲取Inner對(duì)象。這就是一種強(qiáng)力的實(shí)現(xiàn)隱藏機(jī)制——比把內(nèi)部類放到public里然后靠注釋解釋“別用”干凈得多。有些項(xiàng)目里會(huì)這樣實(shí)現(xiàn)Pimpl模式把實(shí)現(xiàn)類定義成外圍類的private嵌套類頭文件里只有一個(gè)指針。這里有一個(gè)非常關(guān)鍵的區(qū)分點(diǎn)內(nèi)部類并不自動(dòng)持有外層類對(duì)象的指針或引用。C的內(nèi)部類和Java的inner class有本質(zhì)區(qū)別Java內(nèi)部類可以直接調(diào)用外部類實(shí)例的成員因?yàn)樗[式攜帶了一個(gè)outer引用C內(nèi)部類只是一個(gè)類型沒有對(duì)任何具體對(duì)象的隱式綁定。如果想讓內(nèi)部類操作某個(gè)外部對(duì)象必須顯式傳入引用或指針就像上面的print(const Outer o)。2.2 外部類能否訪問內(nèi)部類私有成員能但要走正確的路前面說“外不可看內(nèi)”嚴(yán)格說有一點(diǎn)例外如果外圍類主動(dòng)聲明自己是內(nèi)部類的友元就能訪問內(nèi)部類私有成員。但更常見的做法是通過內(nèi)部類自己的公有成員函數(shù)來協(xié)作。這里還有一種讓人容易混淆的情況內(nèi)部類是否是外圍類的友元不加任何聲明時(shí)C標(biāo)準(zhǔn)規(guī)定內(nèi)部類可以直接訪問外圍類私有成員無需額外聲明但如果你在內(nèi)部類里聲明了外圍類是它的友元外圍類也能訪問它的私有成員。這在設(shè)計(jì)嵌套數(shù)據(jù)結(jié)構(gòu)時(shí)很常見class Container { private: int items 10; class Node { private: int value 0; friend class Container; // 讓外圍類能訪問 Node 的私有成員 }; public: int getNodeValue(const Node n) { return n.value; // 因?yàn)?Container 是 Node 的友元 } };把這兩種訪問規(guī)則放一起可以畫出一個(gè)清晰的分工外層類通過友元關(guān)系進(jìn)入內(nèi)部類的私有區(qū)域內(nèi)部類則天然可以訪問外層類的私有區(qū)域。這種雙向協(xié)作在實(shí)現(xiàn)迭代器、建造者模式、狀態(tài)機(jī)節(jié)點(diǎn)等場景中非常常用。2.3 應(yīng)用場景隱藏實(shí)現(xiàn)細(xì)節(jié)和綁定類型關(guān)系內(nèi)部類最典型的應(yīng)用有兩個(gè)方向第一個(gè)方向是隱藏實(shí)現(xiàn)類型。比如給一個(gè)DatabaseConnection類實(shí)現(xiàn)連接池內(nèi)部需要維護(hù)一個(gè)ConnectionPool::PooledConnection類型來包裝原始連接、空閑標(biāo)記、借用時(shí)間等數(shù)據(jù)。這個(gè)PooledConnection只服務(wù)于連接池內(nèi)部邏輯完全沒有必要暴露給使用方。如果放在全局作用域頭文件里的類型數(shù)量會(huì)膨脹調(diào)用者也容易誤用作為private嵌套類使用方看到DatabaseConnection時(shí)根本不知道里面還藏著這么個(gè)東西。第二個(gè)方向是把強(qiáng)關(guān)聯(lián)類型的命名明確化。想想std::vectorT::iterator、std::mapK,V::value_type這些類型都是作為容器類的內(nèi)部類存在的。它們表示“這個(gè)類型專門服務(wù)于這個(gè)容器”。如果你把迭代器設(shè)計(jì)成全局類至少會(huì)產(chǎn)生兩個(gè)問題一是命名上無法表達(dá)歸屬關(guān)系二是它可能需要訪問容器的私有數(shù)據(jù)就得額外聲明友元而嵌套在這種情況下天然解決了訪問權(quán)限問題。我在實(shí)際代碼里維護(hù)過一個(gè)序列化框架里面有一個(gè)Message::Field內(nèi)部類表示消息中的一個(gè)字段。設(shè)計(jì)時(shí)故意把它設(shè)為private嵌套類框架使用方只能通過Message::addField()拿到它不能憑空構(gòu)造。這個(gè)設(shè)計(jì)讓非法字段態(tài)在編譯期就被杜絕了比任何運(yùn)行期檢查都便宜、可靠。3. 匿名對(duì)象沒有名字的臨時(shí)量生命周期大有講究匿名對(duì)象指通過直接調(diào)用構(gòu)造函數(shù)而沒有賦予任何名字的臨時(shí)對(duì)象比如std::string(hello)、BigData()。這在很多語言里都很自然但在C里它有一個(gè)繞不開的問題這個(gè)對(duì)象什么時(shí)候構(gòu)造、什么時(shí)候析構(gòu)會(huì)在程序行為上留下痕跡。尤其是析構(gòu)時(shí)機(jī)處理不好可能出現(xiàn)“明明執(zhí)行完了東西卻提前銷毀”的bug。3.1 生命周期基準(zhǔn)完整表達(dá)式結(jié)束時(shí)析構(gòu)匿名對(duì)象的生命周期從構(gòu)造開始到它所屬的“完整表達(dá)式”full expression結(jié)束時(shí)結(jié)束。完整表達(dá)式簡單說就是不被其他表達(dá)式包含的那個(gè)最大表達(dá)式通常對(duì)應(yīng)一個(gè)以分號(hào)結(jié)尾的語句。#include iostream struct Probe { Probe() { std::cout construct\n; } ~Probe() { std::cout destroy\n; } }; void take(Probe p) {} int main() { take(Probe()); std::cout after take\n; }這段代碼的輸出順序基本上是construct destroy after take也就是說匿名對(duì)象在傳參的過程中完成構(gòu)造調(diào)用結(jié)束后立即析構(gòu)然后程序才繼續(xù)打印after take。如果把Probe換成管理內(nèi)存、鎖、文件句柄的資源類這個(gè)精確的析構(gòu)時(shí)機(jī)就非常重要——比如在離開完整表達(dá)式后臨時(shí)量銷毀鎖釋放了但你的函數(shù)體還想繼續(xù)用這個(gè)鎖保護(hù)的內(nèi)容那就出問題了。3.2 引用綁定能讓匿名對(duì)象“續(xù)命”把一個(gè)匿名對(duì)象綁定到引用上是延長生命周期最直接的方式const Probe ref Probe(); // 生命周期延長到 ref 的作用域結(jié)束 Probe rref Probe(); // 右值引用綁定同樣延長綁定到const T或者T會(huì)讓臨時(shí)對(duì)象的析構(gòu)延后到引用的生命周期結(jié)束這條規(guī)則是C標(biāo)準(zhǔn)明確規(guī)定的。但有幾處例外特別容易在真實(shí)項(xiàng)目中踩到作為函數(shù)參數(shù)綁定引用時(shí)生命周期只延長到函數(shù)調(diào)用結(jié)束不會(huì)延長到調(diào)用者那邊。數(shù)組引用綁定臨時(shí)對(duì)象不允許不能通過這種做法堆疊生命周期。臨時(shí)對(duì)象的成員被引用綁定時(shí)不會(huì)延長整個(gè)臨時(shí)對(duì)象的生命只是延續(xù)到臨時(shí)對(duì)象本身被銷毀的那一瞬。最后一個(gè)坑我踩過一次有段代碼把匿名對(duì)象的某個(gè)字段的引用返回給了調(diào)用方看起來安全實(shí)際臨時(shí)對(duì)象在函數(shù)返回后就析構(gòu)了引用變成了懸空引用調(diào)試的時(shí)候數(shù)據(jù)時(shí)對(duì)時(shí)錯(cuò)折騰了一天才找到問題。所以日常寫代碼時(shí)別過度依賴引用延長這條保命條款尤其是當(dāng)你的對(duì)象還牽扯到成員引用時(shí)提前用命名對(duì)象更穩(wěn)妥。3.3 匿名對(duì)象在運(yùn)算符重載和鏈?zhǔn)秸{(diào)用中的獨(dú)特價(jià)值匿名對(duì)象最常出現(xiàn)在運(yùn)算符重載和鏈?zhǔn)秸{(diào)用的返回值中。a b c這樣的表達(dá)式a b產(chǎn)生的臨時(shí)對(duì)象會(huì)作為 c的左操作數(shù)然后整個(gè)表達(dá)式結(jié)束時(shí)統(tǒng)一銷毀。假如operator返回引用而不是值就會(huì)返回一個(gè)懸空引用所以重載運(yùn)算符時(shí)“返回值”設(shè)計(jì)必須謹(jǐn)慎能用值傳就用值移動(dòng)語義和編譯器優(yōu)化會(huì)處理多余拷貝。鏈?zhǔn)秸{(diào)用里也很經(jīng)典class Logger { public: Logger add(const std::string msg) { messages.push_back(msg); return *this; } }; Logger L; L.add(a).add(b).add(c);這里每調(diào)用一次add返回的是Logger不會(huì)產(chǎn)生匿名對(duì)象。但如果某個(gè)版本把返回值寫成了Logger那L.add(a)就會(huì)產(chǎn)生一個(gè)匿名Logger下一句add(b)是給臨時(shí)對(duì)象加的L本身只加上了a——這是相當(dāng)隱蔽的bug。排查的時(shí)候如果看到鏈?zhǔn)秸{(diào)用的效果全落在了一個(gè)臨時(shí)量上先檢查返回值是不是寫成了值類型。4. 編譯器優(yōu)化如何改變你寫代碼的方式從拷貝消除到移動(dòng)語義第四個(gè)話題在這里不是為了講編譯器內(nèi)部算法而是為了回答一個(gè)非常實(shí)際的問題你的代碼為什么會(huì)快為什么有時(shí)候“花了拷貝的代價(jià)卻看不到拷貝”。很多人以為C里寫return obj;一定會(huì)觸發(fā)拷貝構(gòu)造于是想盡辦法用指針、引用、輸出參數(shù)來“避免拷貝”結(jié)果寫出的代碼又丑又難維護(hù)。這種慣性思維的形成是因?yàn)樗麄儾恢谰幾g器對(duì)匿名對(duì)象和返回值有一整套優(yōu)化規(guī)則。4.1 RVO/NRVO與復(fù)制消除的底層邏輯RVOReturn Value Optimization表達(dá)的是函數(shù)return A();返回一個(gè)匿名對(duì)象時(shí)編譯器可以讓這個(gè)對(duì)象直接構(gòu)造在調(diào)用者預(yù)期的那個(gè)內(nèi)存位置上而不是先構(gòu)造一個(gè)臨時(shí)對(duì)象、再拷貝/移動(dòng)到目標(biāo)位置。NRVONamed Return Value Optimization則是對(duì)A obj; return obj;這種帶名字的局部對(duì)象做同樣的事。#include iostream struct BigData { BigData() { std::cout construct\n; } BigData(const BigData) { std::cout copy\n; } BigData(BigData) { std::cout move\n; } }; BigData makeData() { return BigData(); // RVO } BigData makeData2() { BigData tmp; return tmp; // NRVO } int main() { auto a makeData(); auto b makeData2(); }在較新的編譯器和默認(rèn)優(yōu)化級(jí)別下這兩次調(diào)用通常只會(huì)各輸出一次construct沒有任何copy或move。這正好回應(yīng)了上一篇討論的匿名對(duì)象生命周期問題匿名對(duì)象雖然概念上是一個(gè)臨時(shí)量但經(jīng)過優(yōu)化后它的構(gòu)造直接發(fā)生在目標(biāo)位置析構(gòu)也發(fā)生在目標(biāo)位置的作用域結(jié)束原本“拷貝到目標(biāo)”的中間步驟被整個(gè)擦掉了程序里根本不存在那個(gè)臨時(shí)的中間體。C17更進(jìn)一步把某些純右值場景的復(fù)制消除變成了語言承諾也就是結(jié)合RVO的返回值構(gòu)造場景編譯器“必須”不產(chǎn)生多余的臨時(shí)對(duì)象拷貝。這意味著你不需要依賴忘掉優(yōu)化級(jí)別的運(yùn)氣語言層面就保證了效率。4.2 寫代碼時(shí)怎樣配合編譯器而不是跟它較勁既然編譯器這么能干那我們是不是可以隨便寫返回值的代碼了不完全是。編譯器優(yōu)化是有條件的它的先決條件是可觀的復(fù)雜狀態(tài)和函數(shù)體可見性。RVO是否生效取決于函數(shù)定義是否在當(dāng)前編譯單元可見、返回路徑是否唯一、對(duì)象構(gòu)造是否可被明確追蹤。以下幾條實(shí)戰(zhàn)建議是我在多年開發(fā)中總結(jié)出來的優(yōu)先返回匿名對(duì)象或直接返回明確構(gòu)造的局部對(duì)象讓編譯器有最大優(yōu)化空間。不要為了“避免拷貝”特意改成輸出參數(shù)?,F(xiàn)代C里std::string foo()這種返回值設(shè)計(jì)是正常且高效的改成void foo(std::string out)反而搬石頭砸腳。如果你確實(shí)需要返回一個(gè)已經(jīng)存在的對(duì)象在無法利用NRVO時(shí)移動(dòng)構(gòu)造會(huì)兜底但前提是目標(biāo)類型正確地實(shí)現(xiàn)了移動(dòng)構(gòu)造并使用了std::move當(dāng)對(duì)象是具名局部變量且需要顯式轉(zhuǎn)義時(shí)。關(guān)注Debug構(gòu)建下的行為差異很多性能問題只在Debug下出現(xiàn)因?yàn)閮?yōu)化被關(guān)閉了拷貝/移動(dòng)會(huì)老老實(shí)實(shí)地執(zhí)行。判斷“這是不是優(yōu)化帶來的假象”和“這是不是真實(shí)的性能熱點(diǎn)”要兩套構(gòu)建都看。4.3 觀察拷貝行為的手段構(gòu)造輸出與優(yōu)化參數(shù)對(duì)比實(shí)際排查時(shí)我會(huì)習(xí)慣性地在關(guān)鍵類的構(gòu)造函數(shù)、拷貝構(gòu)造、移動(dòng)構(gòu)造、析構(gòu)函數(shù)里加上輸出標(biāo)記然后分別用三個(gè)編譯參數(shù)觀察g -stdc17 -fno-elide-constructors test.cpp -o test_noelide g -stdc17 -O2 test.cpp -o test_opt-fno-elide-constructors會(huì)關(guān)閉復(fù)制消除這時(shí)你能看到“如果沒有優(yōu)化代碼會(huì)產(chǎn)生多少拷貝”。對(duì)比一下兩次運(yùn)行的輸出差異就能知道編譯器到底替你省了多少成本。我在優(yōu)化一個(gè)字符串處理模塊時(shí)就用這種方法發(fā)現(xiàn)了一個(gè)隱藏瓶頸某個(gè)函數(shù)返回大字符串時(shí)關(guān)閉優(yōu)化會(huì)多構(gòu)造4次打開優(yōu)化后只剩1次構(gòu)造。之后調(diào)整了那個(gè)函數(shù)的結(jié)構(gòu)讓它更自然地返回匿名對(duì)象優(yōu)化效果比手動(dòng)改傳參方式好得多。這里還要提一嘴移動(dòng)語義和編譯器優(yōu)化的協(xié)同關(guān)系。C11引入移動(dòng)語義之后“拷貝消除”和“移動(dòng)”經(jīng)常被放在一起討論但它們不是一回事復(fù)制消除是從物理上抹掉一次構(gòu)造/拷貝移動(dòng)則是通過轉(zhuǎn)移資源所有權(quán)把成本從深拷貝降低為一次指針或句柄的交接。現(xiàn)代標(biāo)準(zhǔn)庫容器普遍做了移動(dòng)構(gòu)造所以按值返回一個(gè)std::vector在優(yōu)化下幾乎不產(chǎn)生深拷貝成本。反過來如果一個(gè)類型自己管理資源卻沒有正確實(shí)現(xiàn)移動(dòng)構(gòu)造和移動(dòng)賦值那么按值返回它就可能退化為深拷貝再牛的編譯器也沒轍。4.4 匿名對(duì)象與優(yōu)化一起用一件順手但容易忽略的事將匿名對(duì)象和編譯器優(yōu)化組合起來在傳值和返回兩大場景都有實(shí)際收益。傳參時(shí)如果函數(shù)是按值接收直接傳匿名對(duì)象比先創(chuàng)建命名對(duì)象再std::move進(jìn)去更簡潔——編譯器對(duì)純右值直接構(gòu)造進(jìn)參數(shù)位置幾乎總是零成本class Config { public: Config(std::string name, int level); }; void use() { Config cfg(service-a, 3); // 普通命名對(duì)象 Config cfg2(std::string(service-b), 5); // 匿名字符串是臨時(shí)量直接構(gòu)造進(jìn)參數(shù) }第二條看起來沒有刻意減少什么但它省去了一個(gè)局部臨時(shí)變量的構(gòu)造和析構(gòu)痕跡尤其當(dāng)字符串很長時(shí)這能顯著減少臨時(shí)數(shù)據(jù)結(jié)構(gòu)的開銷。實(shí)際項(xiàng)目中我們會(huì)在熱路徑里用匿名對(duì)象構(gòu)造參數(shù)既保持代碼可讀性又把臨時(shí)量控制在最小范圍。不過要提醒一點(diǎn)匿名對(duì)象在“需要復(fù)用同一個(gè)對(duì)象多次場景”下反而吃虧比如循環(huán)體內(nèi)反復(fù)構(gòu)造匿名對(duì)象再丟棄不如在循環(huán)前創(chuàng)建一個(gè)命名對(duì)象多次復(fù)用。5. 組合起來看友元、內(nèi)部類、匿名對(duì)象和優(yōu)化如何在一段代碼里協(xié)同四個(gè)概念分別拆開講是為了理清邏輯但在真實(shí)代碼里它們經(jīng)常同時(shí)出現(xiàn)。比如一個(gè)內(nèi)部類作為迭代器時(shí)它通常需要訪問外層容器的私有數(shù)據(jù)這就是內(nèi)部類訪問權(quán)的天然優(yōu)勢而它的構(gòu)造函數(shù)往往接收匿名對(duì)象作為參數(shù)、返回自身又牽扯到臨時(shí)量和移動(dòng)語義如果需要重載某個(gè)操作符友元很可能又冒出來了。我重構(gòu)過一個(gè)簡單的內(nèi)存塊緩存池把四個(gè)點(diǎn)湊齊了class MemoryPool { private: struct Block { void* data; size_t size; friend MemoryPool; // 讓外層類訪問塊內(nèi)部布局 }; public: MemoryPool take() { Block b acquireBlock(); return MemoryPool(b); // 匿名 MemoryPool 從 Block 構(gòu)造 } private: Block block; Block acquireBlock(); };這里MemoryPool::Block是私有內(nèi)部類外部無法知道內(nèi)存塊的布局MemoryPool作為友元能直接訪問Block內(nèi)部的data和sizetake()返回時(shí)構(gòu)造了一個(gè)匿名MemoryPool編譯器可以對(duì)它做RVO不產(chǎn)生多余的臨時(shí)對(duì)象拷貝。四段知識(shí)全部串在了一個(gè)實(shí)際結(jié)構(gòu)里這正是它們?cè)趯?shí)際代碼中協(xié)同工作的縮影。另一個(gè)常見組合是pimpl慣用法配合內(nèi)部類和移動(dòng)語義。類A持有unique_ptrImplImpl是A的私有嵌套類A的移動(dòng)構(gòu)造從unique_ptr里掏出內(nèi)部實(shí)現(xiàn)指針移動(dòng)賦值則析構(gòu)舊實(shí)現(xiàn)再接收新實(shí)現(xiàn)。這個(gè)模式下內(nèi)部類負(fù)責(zé)隱藏實(shí)現(xiàn)細(xì)節(jié)匿名對(duì)象和移動(dòng)語義負(fù)責(zé)降低成本友元反而較少出現(xiàn)因?yàn)镮mpl本來就不對(duì)外可見。設(shè)計(jì)時(shí)想清楚每個(gè)概念在這個(gè)場景中承擔(dān)什么角色就能避免生搬硬套。在我的實(shí)際體驗(yàn)里這四塊內(nèi)容還有個(gè)共同的隱藏主題C類機(jī)制的設(shè)計(jì)者始終在“安全”和“效率”之間找平衡。友元給你可控的突破封裝路徑內(nèi)部類給你可控的類型邊界匿名對(duì)象讓你能臨時(shí)持有資源又自動(dòng)釋放編譯器優(yōu)化則在語言保證的語義基礎(chǔ)上盡量幫你省錢。如果你在寫代碼時(shí)能帶著這層理解很多看似“奇怪”的設(shè)計(jì)就不再奇怪了反而能看出背后的權(quán)衡邏輯。比如看到一個(gè)類突然冒出friend class Something時(shí)先別急著批評(píng)它破壞封裝去看看這個(gè)Something是不是必須訪問私有數(shù)據(jù)的工具類或迭代器看到一個(gè)接口返回的是匿名對(duì)象時(shí)也別急著改成輸出參數(shù)先確認(rèn)RVO和移動(dòng)語義是不是已經(jīng)幫你把成本降到了接近零。C的深度通常就藏在這種權(quán)衡里把每個(gè)機(jī)制為什么存在想明白比記住一堆規(guī)則要管用得多。