ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++23 Deducing this 详解:显式对象参数如何终结成员函数重载灾难

C++23 Deducing this 详解:显式对象参数如何终结成员函数重载灾难 如果你写过一些稍微讲究点的 C 类一定体会过这类痛苦同一个成员函数为了同时支持 const、非 const、左值、右值你得写上三四份几乎一模一样的代码。C23 的 Deducing this显式对象参数就是冲这个来的。这篇我先把基础概念和语法彻底讲透配合可直接编译的代码实例分析这套新机制到底怎么工作、能简化到什么程度、以及我个人实测中踩过的几个坑。关于 Deducing this网上已经有很多零散讨论但大多要么是标准提案的浓缩翻译要么是几行代码一带而过。这导致很多同学看完后依然不知道它和传统的引用限定符重载本质区别在哪也不知道什么时候该用它、什么时候不该用。这篇文章基于我最近在真实项目里的使用经验从设计动机一路拆到推导规则争取让你读完就能动手用起来。1. 为什么 C23 非要搞出个 Deducing this1.1 老 C 里成员函数的「重载灾难」熟悉 C 的朋友都知道*this在成员函数里一直是个「隐式参数」。你写void func()编译器其实偷偷帮你把对象指针传了进来。早年这没什么问题但后来我们想让成员函数知道自己被调用时对象是左值还是右值、是 const 还是非 const就只能靠引用限定符和 const 限定符的组合硬生生堆重载。我举个例子假设你写一个日志缓冲类内部有个data()方法用来拿底层缓冲区逻辑完全一样只是分类型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_; };四份声明四个函数体里面除了返回值类型几乎一模一样。如果哪天逻辑要改你得记得同步改四个地方漏一个就是隐蔽 bug。这还只是最简单的情况。一旦函数体稍微复杂一点比如加日志、加引用计数操作这段重复代码的维护成本立刻变得肉眼可见。1.2 引用限定符的「补丁」属性C11 引入引用限定符、那会儿大家以为终于能用一套函数处理左值和右值了结果发现只是把重载从两个变成了四个处理const版本void f() const 处理非const版本void f() 处理右值版本void f() 处理const右值void f() const 这是典型的「补丁式」演进。每发现一个新的调用场景就往限定符列表里加一个组合本质还是在堆代码。而且这种写法没法做泛型抽象——你没法写一个「某些限定符下通用」的函数模板来自动适配所有情况。1.3 Deducing this 的核心思想C23 的 Deducing this正式名称是 explicit object parameter显式对象参数换了个思路既然this本质上就是个参数那不如把它从「隐式」变成「显式」让类型推导机制直接接管。也就是说你可以在成员函数参数列表的第一个位置显式写出一个参数来表示调用对象本身。编译器会根据调用时对象的实际类型T、const T、T、const T、T等去推导这个参数一套模板实现天然覆盖所有限定符组合。这个思路最早出自 Barry Revzin 等人的提案 P0847 经过多次修订后在 C23 正式落地。它解决的问题可以用一句话概括把本来由语言隐式处理的 this变成显式可推导的参数消除成员函数在 const/引用限定符维度上的重复代码。2. 显式对象参数基础语法全拆解2.1 声明方式与基本规则先看语法。在成员函数的第一个参数位置用this关键字带一个参数名后面可以跟类型和限定符。比如struct Widget { void show(this Widget self) { // self 就是调用对象本身等价于 *this } };参数名可以任意起不一定要叫self用this更像原来语义。但不管叫什么语言层面规定它必须是第一个参数且只能有这一个显式对象参数。具体规则如下必须放在参数列表第一个位置否则编译报错。一个函数只能有一个显式对象参数。不能和传统const限定符同时使用void f(this Widget self) const是非法错误提示会告诉你这个const是多余的。不能用在static成员函数上这跟this的语义天然冲突。不能是虚函数析构函数也不能用。这几个限制背后逻辑都很自然显式对象参数根本目的是接管 this 的语义你再写const限定符就语义重复了static 函数本来没有 this自然不能显式声明一个。2.2 三种声明形式与适用场景显式对象参数的类型可以是值、左值引用、转发引用三种具体区别就在这里声明形式推导结果典型用途this Widget self拷贝一份调用对象需要操作独立副本时this Widget self非 const 左值引用需要修改调用对象时this const Widget selfconst 左值引用只读操作this Widget self右值引用移动语义场景this auto self或模板形式完整保留调用对象类别通用代码最推荐这里的this Widget self比较特殊。如果你直接写Widget它和普通函数的右值引用一样只会绑定右值。但如果配合模板参数写成this auto self或者templatetypename T void f(this T self)它就成了转发引用也叫万能引用不管是左值、右值、const 还不是 const都能原样接收并保留 cv 限定符。2.3 与旧式引用限定符的关系有人可能会问我原来用void f() 这种旧式写法是不是要被淘汰了其实不是。显式对象参数提供的是另一条路两者在 C23 里共存各有用武之地旧式 ref-qualifier 写法简洁适合函数实现完全固定的场景。显式对象参数灵活能泛化适合函数逻辑对调用对象类别敏感、想统一维护的场景。举个直观对照同一个功能旧式四重载代码在前面已经难看过了。换成显式对象参数后这样写templatetypename Self void data(this Self self) { // 根据 Self 的推导结果自动适配 const 和引用类别 return std::span{ self.span_.data(), self.span_.size() }; }Self被推导成Buffer、const Buffer、Buffer、const Buffer四种类型一份函数体全搞定。这就是 Deducing this 最直观的收益不是消灭重载而是让模板自动生成重载。3. 类型推导规则Deducing this 的核心机制3.1 从调用方视角看推导结果Deducing this 名字里带着「Deducing」三个字核心就是推导。很多初学者卡在这里——知道语法但不知道编译器到底会把Self推导成什么。下面这张表是我根据标准规则整理的最全对照建议保存下来调用对象表达式函数模板中this Self的 Selfself的推导类型非 const 左值对象WidgetWidgetconst 左值对象const Widgetconst Widget非 const 右值WidgetWidgetconst 右值const Widgetconst Widget数组成员是数组时数组类型数组引用表格里最关键的是第一列和第三列的对应关系。解释一下当调用对象是非 const 右值时Self被推导为Widget那么self的类型就是Widget刚好是右值引用当是 const 左值时Self是const Widget于是self是const Widget。这就是转发引用在显式对象参数位置上的威力类型推导的自动折叠规则完整保留了调用对象的类别信息。3.2 为什么 this 从隐式变显式就能「可推导」这里我多说一句原理。在旧 C 里this的类型其实也是推导出来的只不过这个推导发生在编译器内部你没法干预也没法用它做进一步泛化。比如void f() const里的 this 一定是const T*void f()里的 this 一定是T*两者之间泾渭分明写两套。一旦变成显式参数它就进入正常的模板推导流程你拥有了控制权。你可以在同一个函数模板里通过编译期if constexpr判断Self到底是什么类型从而在不同调用类别下走不同逻辑。比如templatetypename Self void destroy(this Self self) { if constexpr (std::is_lvalue_reference_vSelf) { // 左值场景 self.ref_count_--; } else { // 右值场景不用减引用计数 } }这在旧写法里是想都不敢想的——原来你得写两个不同行为的重载现在一份代码 编译期分支就搞定了。理解这个你才算真正理解 Deducing this 的「Deducing」到底 Deduce 了什么。3.3 和普通函数模板推导的类比其实显式对象参数的推导规则跟普通函数模板没有任何区别。你完全可以把this Self self看成templatetypename Self void f(Self self)的成员函数版本。唯一的区别是调用时你不能显式指定模板参数编译器完全根据调用对象的类别去推导。这个类比非常重要。写普通函数模板时你已经知道T对左值实参推导为T对右值实参推导为T。现在放到成员函数里结论完全一致只是把「调用对象」当成了隐式的实参。心里有了这个模型上面那张表就不难记了。4. 实操对比一套代码消灭三套重载4.1 经典问题场景还原为了让大家更直观地看到收益我重新准备一个更有实用价值的例子。假设我们写一个TextContainer内部存着std::string对外提供value()方法。旧式写法里这个极简类想支持所有调用类别得写四遍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_; };这里有四个问题第一函数体重复四遍第二如果以后想在value()里加一个日志或者断言四个都必须改第三如果你想根据调用类别返回不同东西比如右值版本返回std::string还得再加重载第四不小心漏掉const 组合某些高频调用场景会直接编译失败。4.2 改造后的对照用 Deducing this 重写后class TextContainer { public: templatetypename Self auto value(this Self self) { return std::forwardSelf(self).data_; } private: std::string data_; };就这么简单。auto在这里做返回类型的自动推导它会根据self的类别推导出对应的返回类型。我们来验证一下推导链路非 const 左值调用tc.value()Self推导为TextContainerauto推导为std::string。const 左值调用const_tc.value()Self推导为const TextContainerauto推导为const std::string。临时对象调用TextContainer{}.value()Self推导为TextContainerself为TextContainerstd::forwardSelf(self).data_返回std::string。看到没返回类型也自动跟着调用类别走。用value()的时候左值拿左值引用右值拿右值引用const 对象拿 const 引用编译器全部自动处理。提示std::forwardSelf(self)这一步不能写错。直接把self.data_返回在 const 对象场景下只能返回 const 引用在右值场景下拿不到右值引用语义功能会退化。务必用转发保留原始类别。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);全部通过。如果旧式四重载写法漏写任何一个这些静态断言至少有一个失败。这就是模板自动生成重载带来的最大好处你永远不会漏掉某个组合因为编译器根据调用点现场推导天然覆盖全部情况。还有一个额外收益代码评审的时候别人看你的类一眼就知道所有成员函数统一处理了哪些限定符组合可读性提升不是一点半点。4.4 什么时候不该用讲完优点我也得泼点冷水。显式对象参数不是万能药有些场景反而更啰嗦函数只有唯一实现、不需要区分限定时直接写普通成员函数最省事。对虚拟接口的类不能用这个特性因为虚函数禁止声明显式对象参数。静态函数完全不受影响。核心判断标准就一条有没有跨多种调用类别的统一逻辑。有就上显式对象参数没有别硬凑普通写法挺好。5. 编译环境、踩坑记录与注意事项5.1 编译器支持现状截至 2024 年底主流编译器对 Deducing this 的支持已经比较成熟我用过这几个组合没问题编译器最低版本编译选项GCC13-stdc23或-stdc2bClang16-stdc23MSVCVS 2022 17.5/std:clatest建议我实际使用下来比较稳的还是 GCC 13 和 Clang 17编译提示都比较友好。MSVC 早期版本对某些边界情况支持不全比如跟概念concepts结合时偶尔抽风。5.2 我踩过的坑坑一显式对象参数和 const 限定符同时写。第一次上手很容易犯struct A { void f(this A self) const { } // 编译错误 };提示信息会说explicit object parameter cannot be const-qualified。这其实我一开始不理解觉得 const 加着也挺好嘛。后来想明白了self的类型已经能表达 const 了你写this A self调用对象绝对非 const想要 const 就把参数声明成this const A self。语言设计上直接禁止这种叠加是为了避免语义混乱。坑二在模板类里把this Derived self写成了this T self。有些模板代码里T是类模板参数直接一写一看编译错误才反应过来类模板的T还没被推导出来你要的就是当前实例化后的类型直接写类名或者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()永远只处理左值调用这种写法就是错的因为右值调用会硬生生触发断言。正确做法是直接限制参数类型void check(this B self) { } // 只接受非const左值所以显式对象参数的选型口诀我总结为通用逻辑首选this auto self固定约束就用具体类型表达。5.3 常见错误速查表错误写法问题原因正确写法void f(this A a, int x) const显式对象参数不能带 const 限定符void f(this A a, int x)void f(this A a)按值传递会拷贝开销大改成this A a或const Astatic void f(this A a)静态函数不能有 this去掉this参数void f(this A a) 不能和引用限定符叠加去掉末尾这些坑网上很多教程都没提因为大部分示例代码都是理想化的「能跑就行」根本没有处理类型语义间的微妙关系。5.4 一个容易忽略的点按值传 this 的拷贝陷阱很多人看示例代码看到this Widget self觉得没问题直接抄结果发现性能莫名其妙下降。这里特别要提醒显式对象参数按值传时会触发一次拷贝构造或移动构造。你本来只想读一下对象结果白白多了一次拷贝。我测试过一个稍大的类内部持有几个 vector用this Widget self声明成员函数调用时性能开销直接翻倍。所以只读场景this const Widget self修改场景this Widget self移动/完美转发场景this Widget self或模板形式独立副本场景T 是 primitive 小对象才考虑this Widget self这条经验和普通函数传参选型完全一致——你写普通函数时不会因为省事就把const std::string改成std::string显式对象参数同理。6. 结合概念约束进一步收窄调用范围Deducing this 和 C20 的 Concepts 搭配起来杀伤力比单独使用大得多。常见的需求是我希望这个函数只对满足某个编译期特性的类型生效。配合显式对象参数可以直接加约束写成这样templatetypename Self requires std::same_asstd::remove_cvref_tSelf, Widget void update(this Self self) { // 只有 Widget 能调用 }或者用更简洁的写法void update(this auto self) requires(std::same_asstd::remove_cvref_tdecltype(self), Widget) { // ... }这种约束方式在旧 C 里完全做不到。旧式写法中 const 和引用限定符是语言内嵌的开关你只能按维度开关没法说「排除所有非 Widget 类型的继承类」。实际项目里我常拿这个特性写 mixin/混入类的方法。比如一个Measurable概念希望所有派生类都有统一的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*拿不到派生类部分有了显式对象参数Self会推导为DataItem直接用派生类语义调用。7. 递归 lambda另一个隐藏的使用场景既然这篇主讲基础概念和语法递归 lambda 我只简单提一句因为它是显式对象参数能直接解决的老大难问题。C17 时代想写出一个递归 lambda通常靠std::function包装性能和可读性都不理想。Deducing this 提供了一种更优雅的方式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 的调用操作符本身是个成员函数显式对象参数可以作用在 lamba 上self在 lambda 体内就是当前 lambda 对象递归调用直接传参就行。不用std::function、不用外部 std::function 包装模板推导天然保留 lambda 的真实类型。原理理解起来也不难self被推导成当前 lambda 对象的引用所以self(...)内部会继续调用自己形成递归。展开后的效果其实等价于一个延迟实例化的函数模板。8. 学习路线与参考资料如果你打算系统性学习这个特性我整理了一条从入门到精通的路线先读 cppreference 的 explicit object parameter 词条把语法规则搞清楚至少通读两遍。手写一个支持 const/非 const/左值/右值的容器类用显式对象参数统一实现几个成员方法跑通所有静态断言。读标准提案 P0847 的 Motivation 章节里面有作者原始设计动机解释了很多语法之外的考量。去看 C23 标准 [dcl.fct] 部分的 explicit object parameter 小节对标准措辞有个感觉。最后把 Deducing this 和 concepts、折叠表达式、结构化绑定等现代 C 特性结合构造几个综合示例练手。关于中文参考资料比较推荐《C23 高级编程第 6 版》网上有 PDF 流传里面专门有一章讲 C23 核心新特性Deducing this 讲得比较清楚示例也对得起初学者。但我要提醒一点书里示例为了展示特性往往倾向于炫技你在真实项目落地时一定要按文末「什么时候不该用」那节的标准重新审视别啥都往上套。我个人在实际把玩这个特性的过程中最大的体会是C23 的 Deducing this 不是一个「新语法点」而是一把钥匙它让成员函数第一次拥有了和普通函数一样完整的类型推导能力。过去几年 C 一直往「值语义、静态多态」方向走const 正确性在模板环境下长期有种别扭感——要么手动堆重载要么用 CRTP 硬扛。现在这一下把整条思路打通了写库的、写框架的、写业务类的人都能在合适的场景下受益。最后再分享一个小技巧你可以给自己的类定义一个私有的工具函数用显式对象参数统一实现读写逻辑再通过两个公共接口一个 const 一个非 const暴露出去这样既享受了 Deducing this 的推导优势又不至于把所有 API 全部模板化对类内部设计的侵入性最小。这个手法我在几个开源项目里实测过兼容性很好。由于篇幅控制这篇「上」先到这里。基础概念、语法、推导规则、适用场景、踩坑记录已经讲透下一篇重点拆解它在真实项目里的进阶用法如何和 CRTP 模式结合、如何重构遗留代码里的 const 重载、以及递归 lambda 和 std::visit 等特性的联动技巧。感兴趣的话可以先把文中的代码示例都抄到本地跑一遍有了手感再往下走。
返回列表