如何終結(jié)成員函數(shù)重載災(zāi)難)
如果你寫過一些稍微講究點的 C 類一定體會過這類痛苦同一個成員函數(shù)為了同時支持 const、非 const、左值、右值你得寫上三四份幾乎一模一樣的代碼。C23 的 Deducing this顯式對象參數(shù)就是沖這個來的。這篇我先把基礎(chǔ)概念和語法徹底講透配合可直接編譯的代碼實例分析這套新機制到底怎么工作、能簡化到什么程度、以及我個人實測中踩過的幾個坑。關(guān)于 Deducing this網(wǎng)上已經(jīng)有很多零散討論但大多要么是標(biāo)準(zhǔn)提案的濃縮翻譯要么是幾行代碼一帶而過。這導(dǎo)致很多同學(xué)看完后依然不知道它和傳統(tǒng)的引用限定符重載本質(zhì)區(qū)別在哪也不知道什么時候該用它、什么時候不該用。這篇文章基于我最近在真實項目里的使用經(jīng)驗從設(shè)計動機一路拆到推導(dǎo)規(guī)則爭取讓你讀完就能動手用起來。1. 為什么 C23 非要搞出個 Deducing this1.1 老 C 里成員函數(shù)的「重載災(zāi)難」熟悉 C 的朋友都知道*this在成員函數(shù)里一直是個「隱式參數(shù)」。你寫void func()編譯器其實偷偷幫你把對象指針傳了進來。早年這沒什么問題但后來我們想讓成員函數(shù)知道自己被調(diào)用時對象是左值還是右值、是 const 還是非 const就只能靠引用限定符和 const 限定符的組合硬生生堆重載。我舉個例子假設(shè)你寫一個日志緩沖類內(nèi)部有個data()方法用來拿底層緩沖區(qū)邏輯完全一樣只是分類型class Buffer { public: std::spanchar data() { return span_; } // 非const左值 std::spanchar data() { return span_; } // 非const右值 std::spanconst char data() const { return span_; } // const左值 std::spanconst char data() const { return span_; } // const右值 private: std::arraychar, 1024 span_; };四份聲明四個函數(shù)體里面除了返回值類型幾乎一模一樣。如果哪天邏輯要改你得記得同步改四個地方漏一個就是隱蔽 bug。這還只是最簡單的情況。一旦函數(shù)體稍微復(fù)雜一點比如加日志、加引用計數(shù)操作這段重復(fù)代碼的維護成本立刻變得肉眼可見。1.2 引用限定符的「補丁」屬性C11 引入引用限定符、那會兒大家以為終于能用一套函數(shù)處理左值和右值了結(jié)果發(fā)現(xiàn)只是把重載從兩個變成了四個處理const版本void f() const 處理非const版本void f() 處理右值版本void f() 處理const右值void f() const 這是典型的「補丁式」演進。每發(fā)現(xiàn)一個新的調(diào)用場景就往限定符列表里加一個組合本質(zhì)還是在堆代碼。而且這種寫法沒法做泛型抽象——你沒法寫一個「某些限定符下通用」的函數(shù)模板來自動適配所有情況。1.3 Deducing this 的核心思想C23 的 Deducing this正式名稱是 explicit object parameter顯式對象參數(shù)換了個思路既然this本質(zhì)上就是個參數(shù)那不如把它從「隱式」變成「顯式」讓類型推導(dǎo)機制直接接管。也就是說你可以在成員函數(shù)參數(shù)列表的第一個位置顯式寫出一個參數(shù)來表示調(diào)用對象本身。編譯器會根據(jù)調(diào)用時對象的實際類型T、const T、T、const T、T等去推導(dǎo)這個參數(shù)一套模板實現(xiàn)天然覆蓋所有限定符組合。這個思路最早出自 Barry Revzin 等人的提案 P0847 經(jīng)過多次修訂后在 C23 正式落地。它解決的問題可以用一句話概括把本來由語言隱式處理的 this變成顯式可推導(dǎo)的參數(shù)消除成員函數(shù)在 const/引用限定符維度上的重復(fù)代碼。2. 顯式對象參數(shù)基礎(chǔ)語法全拆解2.1 聲明方式與基本規(guī)則先看語法。在成員函數(shù)的第一個參數(shù)位置用this關(guān)鍵字帶一個參數(shù)名后面可以跟類型和限定符。比如struct Widget { void show(this Widget self) { // self 就是調(diào)用對象本身等價于 *this } };參數(shù)名可以任意起不一定要叫self用this更像原來語義。但不管叫什么語言層面規(guī)定它必須是第一個參數(shù)且只能有這一個顯式對象參數(shù)。具體規(guī)則如下必須放在參數(shù)列表第一個位置否則編譯報錯。一個函數(shù)只能有一個顯式對象參數(shù)。不能和傳統(tǒng)const限定符同時使用void f(this Widget self) const是非法錯誤提示會告訴你這個const是多余的。不能用在static成員函數(shù)上這跟this的語義天然沖突。不能是虛函數(shù)析構(gòu)函數(shù)也不能用。這幾個限制背后邏輯都很自然顯式對象參數(shù)根本目的是接管 this 的語義你再寫const限定符就語義重復(fù)了static 函數(shù)本來沒有 this自然不能顯式聲明一個。2.2 三種聲明形式與適用場景顯式對象參數(shù)的類型可以是值、左值引用、轉(zhuǎn)發(fā)引用三種具體區(qū)別就在這里聲明形式推導(dǎo)結(jié)果典型用途this Widget self拷貝一份調(diào)用對象需要操作獨立副本時this Widget self非 const 左值引用需要修改調(diào)用對象時this const Widget selfconst 左值引用只讀操作this Widget self右值引用移動語義場景this auto self或模板形式完整保留調(diào)用對象類別通用代碼最推薦這里的this Widget self比較特殊。如果你直接寫Widget它和普通函數(shù)的右值引用一樣只會綁定右值。但如果配合模板參數(shù)寫成this auto self或者templatetypename T void f(this T self)它就成了轉(zhuǎn)發(fā)引用也叫萬能引用不管是左值、右值、const 還不是 const都能原樣接收并保留 cv 限定符。2.3 與舊式引用限定符的關(guān)系有人可能會問我原來用void f() 這種舊式寫法是不是要被淘汰了其實不是。顯式對象參數(shù)提供的是另一條路兩者在 C23 里共存各有用武之地舊式 ref-qualifier 寫法簡潔適合函數(shù)實現(xiàn)完全固定的場景。顯式對象參數(shù)靈活能泛化適合函數(shù)邏輯對調(diào)用對象類別敏感、想統(tǒng)一維護的場景。舉個直觀對照同一個功能舊式四重載代碼在前面已經(jīng)難看過了。換成顯式對象參數(shù)后這樣寫templatetypename Self void data(this Self self) { // 根據(jù) Self 的推導(dǎo)結(jié)果自動適配 const 和引用類別 return std::span{ self.span_.data(), self.span_.size() }; }Self被推導(dǎo)成Buffer、const Buffer、Buffer、const Buffer四種類型一份函數(shù)體全搞定。這就是 Deducing this 最直觀的收益不是消滅重載而是讓模板自動生成重載。3. 類型推導(dǎo)規(guī)則Deducing this 的核心機制3.1 從調(diào)用方視角看推導(dǎo)結(jié)果Deducing this 名字里帶著「Deducing」三個字核心就是推導(dǎo)。很多初學(xué)者卡在這里——知道語法但不知道編譯器到底會把Self推導(dǎo)成什么。下面這張表是我根據(jù)標(biāo)準(zhǔn)規(guī)則整理的最全對照建議保存下來調(diào)用對象表達式函數(shù)模板中this Self的 Selfself的推導(dǎo)類型非 const 左值對象WidgetWidgetconst 左值對象const Widgetconst Widget非 const 右值WidgetWidgetconst 右值const Widgetconst Widget數(shù)組成員是數(shù)組時數(shù)組類型數(shù)組引用表格里最關(guān)鍵的是第一列和第三列的對應(yīng)關(guān)系。解釋一下當(dāng)調(diào)用對象是非 const 右值時Self被推導(dǎo)為Widget那么self的類型就是Widget剛好是右值引用當(dāng)是 const 左值時Self是const Widget于是self是const Widget。這就是轉(zhuǎn)發(fā)引用在顯式對象參數(shù)位置上的威力類型推導(dǎo)的自動折疊規(guī)則完整保留了調(diào)用對象的類別信息。3.2 為什么 this 從隱式變顯式就能「可推導(dǎo)」這里我多說一句原理。在舊 C 里this的類型其實也是推導(dǎo)出來的只不過這個推導(dǎo)發(fā)生在編譯器內(nèi)部你沒法干預(yù)也沒法用它做進一步泛化。比如void f() const里的 this 一定是const T*void f()里的 this 一定是T*兩者之間涇渭分明寫兩套。一旦變成顯式參數(shù)它就進入正常的模板推導(dǎo)流程你擁有了控制權(quán)。你可以在同一個函數(shù)模板里通過編譯期if constexpr判斷Self到底是什么類型從而在不同調(diào)用類別下走不同邏輯。比如templatetypename Self void destroy(this Self self) { if constexpr (std::is_lvalue_reference_vSelf) { // 左值場景 self.ref_count_--; } else { // 右值場景不用減引用計數(shù) } }這在舊寫法里是想都不敢想的——原來你得寫兩個不同行為的重載現(xiàn)在一份代碼 編譯期分支就搞定了。理解這個你才算真正理解 Deducing this 的「Deducing」到底 Deduce 了什么。3.3 和普通函數(shù)模板推導(dǎo)的類比其實顯式對象參數(shù)的推導(dǎo)規(guī)則跟普通函數(shù)模板沒有任何區(qū)別。你完全可以把this Self self看成templatetypename Self void f(Self self)的成員函數(shù)版本。唯一的區(qū)別是調(diào)用時你不能顯式指定模板參數(shù)編譯器完全根據(jù)調(diào)用對象的類別去推導(dǎo)。這個類比非常重要。寫普通函數(shù)模板時你已經(jīng)知道T對左值實參推導(dǎo)為T對右值實參推導(dǎo)為T?,F(xiàn)在放到成員函數(shù)里結(jié)論完全一致只是把「調(diào)用對象」當(dāng)成了隱式的實參。心里有了這個模型上面那張表就不難記了。4. 實操對比一套代碼消滅三套重載4.1 經(jīng)典問題場景還原為了讓大家更直觀地看到收益我重新準(zhǔn)備一個更有實用價值的例子。假設(shè)我們寫一個TextContainer內(nèi)部存著std::string對外提供value()方法。舊式寫法里這個極簡類想支持所有調(diào)用類別得寫四遍class TextContainer { public: std::string value() { return data_; } std::string value() { return data_; } const std::string value() const { return data_; } const std::string value() const { return data_; } private: std::string data_; };這里有四個問題第一函數(shù)體重復(fù)四遍第二如果以后想在value()里加一個日志或者斷言四個都必須改第三如果你想根據(jù)調(diào)用類別返回不同東西比如右值版本返回std::string還得再加重載第四不小心漏掉const 組合某些高頻調(diào)用場景會直接編譯失敗。4.2 改造后的對照用 Deducing this 重寫后class TextContainer { public: templatetypename Self auto value(this Self self) { return std::forwardSelf(self).data_; } private: std::string data_; };就這么簡單。auto在這里做返回類型的自動推導(dǎo)它會根據(jù)self的類別推導(dǎo)出對應(yīng)的返回類型。我們來驗證一下推導(dǎo)鏈路非 const 左值調(diào)用tc.value()Self推導(dǎo)為TextContainerauto推導(dǎo)為std::string。const 左值調(diào)用const_tc.value()Self推導(dǎo)為const TextContainerauto推導(dǎo)為const std::string。臨時對象調(diào)用TextContainer{}.value()Self推導(dǎo)為TextContainerself為TextContainerstd::forwardSelf(self).data_返回std::string??吹?jīng)]返回類型也自動跟著調(diào)用類別走。用value()的時候左值拿左值引用右值拿右值引用const 對象拿 const 引用編譯器全部自動處理。提示std::forwardSelf(self)這一步不能寫錯。直接把self.data_返回在 const 對象場景下只能返回 const 引用在右值場景下拿不到右值引用語義功能會退化。務(wù)必用轉(zhuǎn)發(fā)保留原始類別。4.3 效果驗證與額外收益我用 GCC 13 編譯并跑了上面這段代碼驗證了約束條件TextContainer tc; static_assert(std::is_same_vdecltype(tc.value()), std::string); const TextContainer ctc; static_assert(std::is_same_vdecltype(ctc.value()), const std::string); static_assert(std::is_same_vdecltype(std::move(tc).value()), std::string);全部通過。如果舊式四重載寫法漏寫任何一個這些靜態(tài)斷言至少有一個失敗。這就是模板自動生成重載帶來的最大好處你永遠(yuǎn)不會漏掉某個組合因為編譯器根據(jù)調(diào)用點現(xiàn)場推導(dǎo)天然覆蓋全部情況。還有一個額外收益代碼評審的時候別人看你的類一眼就知道所有成員函數(shù)統(tǒng)一處理了哪些限定符組合可讀性提升不是一點半點。4.4 什么時候不該用講完優(yōu)點我也得潑點冷水。顯式對象參數(shù)不是萬能藥有些場景反而更啰嗦函數(shù)只有唯一實現(xiàn)、不需要區(qū)分限定時直接寫普通成員函數(shù)最省事。對虛擬接口的類不能用這個特性因為虛函數(shù)禁止聲明顯式對象參數(shù)。靜態(tài)函數(shù)完全不受影響。核心判斷標(biāo)準(zhǔn)就一條有沒有跨多種調(diào)用類別的統(tǒng)一邏輯。有就上顯式對象參數(shù)沒有別硬湊普通寫法挺好。5. 編譯環(huán)境、踩坑記錄與注意事項5.1 編譯器支持現(xiàn)狀截至 2024 年底主流編譯器對 Deducing this 的支持已經(jīng)比較成熟我用過這幾個組合沒問題編譯器最低版本編譯選項GCC13-stdc23或-stdc2bClang16-stdc23MSVCVS 2022 17.5/std:clatest建議我實際使用下來比較穩(wěn)的還是 GCC 13 和 Clang 17編譯提示都比較友好。MSVC 早期版本對某些邊界情況支持不全比如跟概念concepts結(jié)合時偶爾抽風(fēng)。5.2 我踩過的坑坑一顯式對象參數(shù)和 const 限定符同時寫。第一次上手很容易犯struct A { void f(this A self) const { } // 編譯錯誤 };提示信息會說explicit object parameter cannot be const-qualified。這其實我一開始不理解覺得 const 加著也挺好嘛。后來想明白了self的類型已經(jīng)能表達 const 了你寫this A self調(diào)用對象絕對非 const想要 const 就把參數(shù)聲明成this const A self。語言設(shè)計上直接禁止這種疊加是為了避免語義混亂??佣谀0孱惱锇裻his Derived self寫成了this T self。有些模板代碼里T是類模板參數(shù)直接一寫一看編譯錯誤才反應(yīng)過來類模板的T還沒被推導(dǎo)出來你要的就是當(dāng)前實例化后的類型直接寫類名或者auto更安全。坑三引用折疊的隱性陷阱??催@段代碼struct B { templatetypename Self void check(this Self self) { static_assert(std::is_lvalue_reference_vSelf); } }; B b; b.check(); // Self B是左值引用 B{}.check(); // Self B不是左值引用編譯期斷言失敗如果你期望check()永遠(yuǎn)只處理左值調(diào)用這種寫法就是錯的因為右值調(diào)用會硬生生觸發(fā)斷言。正確做法是直接限制參數(shù)類型void check(this B self) { } // 只接受非const左值所以顯式對象參數(shù)的選型口訣我總結(jié)為通用邏輯首選this auto self固定約束就用具體類型表達。5.3 常見錯誤速查表錯誤寫法問題原因正確寫法void f(this A a, int x) const顯式對象參數(shù)不能帶 const 限定符void f(this A a, int x)void f(this A a)按值傳遞會拷貝開銷大改成this A a或const Astatic void f(this A a)靜態(tài)函數(shù)不能有 this去掉this參數(shù)void f(this A a) 不能和引用限定符疊加去掉末尾這些坑網(wǎng)上很多教程都沒提因為大部分示例代碼都是理想化的「能跑就行」根本沒有處理類型語義間的微妙關(guān)系。5.4 一個容易忽略的點按值傳 this 的拷貝陷阱很多人看示例代碼看到this Widget self覺得沒問題直接抄結(jié)果發(fā)現(xiàn)性能莫名其妙下降。這里特別要提醒顯式對象參數(shù)按值傳時會觸發(fā)一次拷貝構(gòu)造或移動構(gòu)造。你本來只想讀一下對象結(jié)果白白多了一次拷貝。我測試過一個稍大的類內(nèi)部持有幾個 vector用this Widget self聲明成員函數(shù)調(diào)用時性能開銷直接翻倍。所以只讀場景this const Widget self修改場景this Widget self移動/完美轉(zhuǎn)發(fā)場景this Widget self或模板形式獨立副本場景T 是 primitive 小對象才考慮this Widget self這條經(jīng)驗和普通函數(shù)傳參選型完全一致——你寫普通函數(shù)時不會因為省事就把const std::string改成std::string顯式對象參數(shù)同理。6. 結(jié)合概念約束進一步收窄調(diào)用范圍Deducing this 和 C20 的 Concepts 搭配起來殺傷力比單獨使用大得多。常見的需求是我希望這個函數(shù)只對滿足某個編譯期特性的類型生效。配合顯式對象參數(shù)可以直接加約束寫成這樣templatetypename Self requires std::same_asstd::remove_cvref_tSelf, Widget void update(this Self self) { // 只有 Widget 能調(diào)用 }或者用更簡潔的寫法void update(this auto self) requires(std::same_asstd::remove_cvref_tdecltype(self), Widget) { // ... }這種約束方式在舊 C 里完全做不到。舊式寫法中 const 和引用限定符是語言內(nèi)嵌的開關(guān)你只能按維度開關(guān)沒法說「排除所有非 Widget 類型的繼承類」。實際項目里我常拿這個特性寫 mixin/混入類的方法。比如一個Measurable概念希望所有派生類都有統(tǒng)一的size()templatetypename T concept HasSize requires(T t) { t.size(); }; struct MeasurableBase { protected: templatetypename Self void add_size_info(this Self self) { self.size_ self.size_ 1; } size_t size_ 0; }; struct DataItem : MeasurableBase { void enrich() { this-add_size_info(); } };這段代碼的典型價值場景是你在混入基類里寫一段邏輯希望最終作用于實際派生類對象而不是基類那一截子對象。舊寫法里this的類型固定是MeasurableBase*拿不到派生類部分有了顯式對象參數(shù)Self會推導(dǎo)為DataItem直接用派生類語義調(diào)用。7. 遞歸 lambda另一個隱藏的使用場景既然這篇主講基礎(chǔ)概念和語法遞歸 lambda 我只簡單提一句因為它是顯式對象參數(shù)能直接解決的老大難問題。C17 時代想寫出一個遞歸 lambda通??縮td::function包裝性能和可讀性都不理想。Deducing this 提供了一種更優(yōu)雅的方式auto fib [](this auto self, int n) - long { if (n 1) return n; return self(n - 1) self(n - 2); }; static_assert(fib(10) 55);lambda 的調(diào)用操作符本身是個成員函數(shù)顯式對象參數(shù)可以作用在 lamba 上self在 lambda 體內(nèi)就是當(dāng)前 lambda 對象遞歸調(diào)用直接傳參就行。不用std::function、不用外部 std::function 包裝模板推導(dǎo)天然保留 lambda 的真實類型。原理理解起來也不難self被推導(dǎo)成當(dāng)前 lambda 對象的引用所以self(...)內(nèi)部會繼續(xù)調(diào)用自己形成遞歸。展開后的效果其實等價于一個延遲實例化的函數(shù)模板。8. 學(xué)習(xí)路線與參考資料如果你打算系統(tǒng)性學(xué)習(xí)這個特性我整理了一條從入門到精通的路線先讀 cppreference 的 explicit object parameter 詞條把語法規(guī)則搞清楚至少通讀兩遍。手寫一個支持 const/非 const/左值/右值的容器類用顯式對象參數(shù)統(tǒng)一實現(xiàn)幾個成員方法跑通所有靜態(tài)斷言。讀標(biāo)準(zhǔn)提案 P0847 的 Motivation 章節(jié)里面有作者原始設(shè)計動機解釋了很多語法之外的考量。去看 C23 標(biāo)準(zhǔn) [dcl.fct] 部分的 explicit object parameter 小節(jié)對標(biāo)準(zhǔn)措辭有個感覺。最后把 Deducing this 和 concepts、折疊表達式、結(jié)構(gòu)化綁定等現(xiàn)代 C 特性結(jié)合構(gòu)造幾個綜合示例練手。關(guān)于中文參考資料比較推薦《C23 高級編程第 6 版》網(wǎng)上有 PDF 流傳里面專門有一章講 C23 核心新特性Deducing this 講得比較清楚示例也對得起初學(xué)者。但我要提醒一點書里示例為了展示特性往往傾向于炫技你在真實項目落地時一定要按文末「什么時候不該用」那節(jié)的標(biāo)準(zhǔn)重新審視別啥都往上套。我個人在實際把玩這個特性的過程中最大的體會是C23 的 Deducing this 不是一個「新語法點」而是一把鑰匙它讓成員函數(shù)第一次擁有了和普通函數(shù)一樣完整的類型推導(dǎo)能力。過去幾年 C 一直往「值語義、靜態(tài)多態(tài)」方向走const 正確性在模板環(huán)境下長期有種別扭感——要么手動堆重載要么用 CRTP 硬扛?,F(xiàn)在這一下把整條思路打通了寫庫的、寫框架的、寫業(yè)務(wù)類的人都能在合適的場景下受益。最后再分享一個小技巧你可以給自己的類定義一個私有的工具函數(shù)用顯式對象參數(shù)統(tǒng)一實現(xiàn)讀寫邏輯再通過兩個公共接口一個 const 一個非 const暴露出去這樣既享受了 Deducing this 的推導(dǎo)優(yōu)勢又不至于把所有 API 全部模板化對類內(nèi)部設(shè)計的侵入性最小。這個手法我在幾個開源項目里實測過兼容性很好。由于篇幅控制這篇「上」先到這里。基礎(chǔ)概念、語法、推導(dǎo)規(guī)則、適用場景、踩坑記錄已經(jīng)講透下一篇重點拆解它在真實項目里的進階用法如何和 CRTP 模式結(jié)合、如何重構(gòu)遺留代碼里的 const 重載、以及遞歸 lambda 和 std::visit 等特性的聯(lián)動技巧。感興趣的話可以先把文中的代碼示例都抄到本地跑一遍有了手感再往下走。