如果你写过一些稍微讲究点的 C++ 类,一定体会过这类痛苦:同一个成员函数,为了同时支持 const、非 const、左值、右值,你得写上三四份几乎一模一样的代码。C++23 的 Deducing this(显式对象参数)就是冲这个来的。这篇我先把基础概念和语法彻底讲透,配合可直接编译的代码实例,分析这套新机制到底怎么工作、能简化到什么程度、以及我个人实测中踩过的几个坑。
关于 Deducing this,网上已经有很多零散讨论,但大多要么是标准提案的浓缩翻译,要么是几行代码一带而过。这导致很多同学看完后依然不知道它和传统的引用限定符重载本质区别在哪,也不知道什么时候该用它、什么时候不该用。这篇文章基于我最近在真实项目里的使用经验,从设计动机一路拆到推导规则,争取让你读完就能动手用起来。
1. 为什么 C++23 非要搞出个 Deducing this
1.1 老 C++ 里成员函数的「重载灾难」
熟悉 C++ 的朋友都知道,*this在成员函数里一直是个「隐式参数」。你写void func(),编译器其实偷偷帮你把对象指针传了进来。早年这没什么问题,但后来我们想让成员函数知道自己被调用时对象是左值还是右值、是 const 还是非 const,就只能靠引用限定符和 const 限定符的组合,硬生生堆重载。
我举个例子,假设你写一个日志缓冲类,内部有个data()方法用来拿底层缓冲区,逻辑完全一样,只是分类型:
class Buffer { public: std::span<char> data() & { return span_; } // 非const左值 std::span<char> data() && { return span_; } // 非const右值 std::span<const char> data() const & { return span_; } // const左值 std::span<const char> data() const && { return span_; } // const右值 private: std::array<char, 1024> span_; };四份声明,四个函数体,里面除了返回值类型,几乎一模一样。如果哪天逻辑要改,你得记得同步改四个地方,漏一个就是隐蔽 bug。这还只是最简单的情况。一旦函数体稍微复杂一点,比如加日志、加引用计数操作,这段重复代码的维护成本立刻变得肉眼可见。
1.2 引用限定符的「补丁」属性
C++11 引入引用限定符(&、&&)那会儿,大家以为终于能用一套函数处理左值和右值了,结果发现只是把重载从两个变成了四个:
- 处理
const版本:void f() const & - 处理非
const版本:void f() & - 处理右值版本:
void f() && - 处理
const右值:void f() const &&
这是典型的「补丁式」演进。每发现一个新的调用场景,就往限定符列表里加一个组合,本质还是在堆代码。而且这种写法没法做泛型抽象——你没法写一个「某些限定符下通用」的函数模板来自动适配所有情况。
1.3 Deducing this 的核心思想
C++23 的 Deducing this(正式名称是 explicit object parameter,显式对象参数)换了个思路:既然this本质上就是个参数,那不如把它从「隐式」变成「显式」,让类型推导机制直接接管。
也就是说,你可以在成员函数参数列表的第一个位置,显式写出一个参数来表示调用对象本身。编译器会根据调用时对象的实际类型(T、const T、T&、const T&、T&&等)去推导这个参数,一套模板实现,天然覆盖所有限定符组合。
这个思路最早出自 Barry Revzin 等人的提案 P0847 ,经过多次修订后在 C++23 正式落地。它解决的问题可以用一句话概括:把本来由语言隐式处理的 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& self | const 左值引用 | 只读操作 |
this Widget&& self | 右值引用 | 移动语义场景 |
this auto&& self或模板形式 | 完整保留调用对象类别 | 通用代码,最推荐 |
这里的this Widget&& self比较特殊。如果你直接写Widget&&,它和普通函数的右值引用一样,只会绑定右值。但如果配合模板参数写成this auto&& self或者template<typename T> void f(this T&& self),它就成了转发引用(也叫万能引用),不管是左值、右值、const 还不是 const,都能原样接收并保留 cv 限定符。
2.3 与旧式引用限定符的关系
有人可能会问:我原来用void f() &这种旧式写法,是不是要被淘汰了?其实不是。显式对象参数提供的是另一条路,两者在 C++23 里共存,各有用武之地:
- 旧式 ref-qualifier 写法简洁,适合函数实现完全固定的场景。
- 显式对象参数灵活,能泛化,适合函数逻辑对调用对象类别敏感、想统一维护的场景。
举个直观对照,同一个功能,旧式四重载代码在前面已经难看过了。换成显式对象参数后这样写:
template<typename 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&&的 Self | self的推导类型 |
|---|---|---|
| 非 const 左值对象 | Widget& | Widget& |
| const 左值对象 | const Widget& | const Widget& |
| 非 const 右值 | Widget | Widget&& |
| const 右值 | const Widget | const 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到底是什么类型,从而在不同调用类别下走不同逻辑。比如:
template<typename Self> void destroy(this Self&& self) { if constexpr (std::is_lvalue_reference_v<Self>) { // 左值场景 self.ref_count_--; } else { // 右值场景,不用减引用计数 } }这在旧写法里是想都不敢想的——原来你得写两个不同行为的重载,现在一份代码 + 编译期分支就搞定了。理解这个,你才算真正理解 Deducing this 的「Deducing」到底 Deduce 了什么。
3.3 和普通函数模板推导的类比
其实显式对象参数的推导规则,跟普通函数模板没有任何区别。你完全可以把this Self&& self看成template<typename 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: template<typename Self> auto&& value(this Self&& self) { return std::forward<Self>(self).data_; } private: std::string data_; };就这么简单。auto&&在这里做返回类型的自动推导,它会根据self的类别推导出对应的返回类型。我们来验证一下推导链路:
- 非 const 左值调用
tc.value():Self推导为TextContainer&,auto&&推导为std::string&。 - const 左值调用
const_tc.value():Self推导为const TextContainer&,auto&&推导为const std::string&。 - 临时对象调用
TextContainer{}.value():Self推导为TextContainer,self为TextContainer&&,std::forward<Self>(self).data_返回std::string&&。
看到没,返回类型也自动跟着调用类别走。用value()的时候,左值拿左值引用,右值拿右值引用,const 对象拿 const 引用,编译器全部自动处理。
提示:
std::forward<Self>(self)这一步不能写错。直接把self.data_返回,在 const 对象场景下只能返回 const 引用,在右值场景下拿不到右值引用语义,功能会退化。务必用转发保留原始类别。
4.3 效果验证与额外收益
我用 GCC 13 编译并跑了上面这段代码,验证了约束条件:
TextContainer tc; static_assert(std::is_same_v<decltype(tc.value()), std::string&>); const TextContainer ctc; static_assert(std::is_same_v<decltype(ctc.value()), const std::string&>); static_assert(std::is_same_v<decltype(std::move(tc).value()), std::string&&>);全部通过。如果旧式四重载写法漏写任何一个,这些静态断言至少有一个失败。这就是模板自动生成重载带来的最大好处:你永远不会漏掉某个组合,因为编译器根据调用点现场推导,天然覆盖全部情况。
还有一个额外收益:代码评审的时候,别人看你的类一眼就知道所有成员函数统一处理了哪些限定符组合,可读性提升不是一点半点。
4.4 什么时候不该用
讲完优点,我也得泼点冷水。显式对象参数不是万能药,有些场景反而更啰嗦:
- 函数只有唯一实现、不需要区分限定时,直接写普通成员函数最省事。
- 对虚拟接口的类,不能用这个特性,因为虚函数禁止声明显式对象参数。
- 静态函数完全不受影响。
核心判断标准就一条:有没有跨多种调用类别的统一逻辑。有,就上显式对象参数;没有,别硬凑,普通写法挺好。
5. 编译环境、踩坑记录与注意事项
5.1 编译器支持现状
截至 2024 年底,主流编译器对 Deducing this 的支持已经比较成熟,我用过这几个组合没问题:
| 编译器 | 最低版本 | 编译选项 |
|---|---|---|
| GCC | 13 | -std=c++23或-std=c++2b |
| Clang | 16 | -std=c++23 |
| MSVC | VS 2022 17.5+ | /std:c++latest |
建议我实际使用下来比较稳的还是 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 { template<typename Self> void check(this Self&& self) { static_assert(std::is_lvalue_reference_v<Self>); } }; 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 A& |
static 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 和 C++20 的 Concepts 搭配起来,杀伤力比单独使用大得多。常见的需求是:我希望这个函数只对满足某个编译期特性的类型生效。配合显式对象参数,可以直接加约束,写成这样:
template<typename Self> requires std::same_as<std::remove_cvref_t<Self>, Widget> void update(this Self&& self) { // 只有 Widget 能调用 }或者用更简洁的写法:
void update(this auto&& self) requires(std::same_as<std::remove_cvref_t<decltype(self)>, Widget>) { // ... }这种约束方式在旧 C++ 里完全做不到。旧式写法中 const 和引用限定符是语言内嵌的开关,你只能按维度开关,没法说「排除所有非 Widget 类型的继承类」。
实际项目里,我常拿这个特性写 mixin/混入类的方法。比如一个Measurable概念,希望所有派生类都有统一的size():
template<typename T> concept HasSize = requires(T t) { t.size(); }; struct MeasurableBase { protected: template<typename 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 我只简单提一句,因为它是显式对象参数能直接解决的老大难问题。C++17 时代想写出一个递归 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" 章节,里面有作者原始设计动机,解释了很多语法之外的考量。
- 去看 C++23 标准 [dcl.fct] 部分的 explicit object parameter 小节,对标准措辞有个感觉。
- 最后把 Deducing this 和 concepts、折叠表达式、结构化绑定等现代 C++ 特性结合,构造几个综合示例练手。
关于中文参考资料,比较推荐《C++23 高级编程(第 6 版)》,网上有 PDF 流传,里面专门有一章讲 C++23 核心新特性,Deducing this 讲得比较清楚,示例也对得起初学者。但我要提醒一点:书里示例为了展示特性,往往倾向于炫技,你在真实项目落地时一定要按文末「什么时候不该用」那节的标准重新审视,别啥都往上套。
我个人在实际把玩这个特性的过程中,最大的体会是:C++23 的 Deducing this 不是一个「新语法点」,而是一把钥匙,它让成员函数第一次拥有了和普通函数一样完整的类型推导能力。过去几年 C++ 一直往「值语义、静态多态」方向走,const 正确性在模板环境下长期有种别扭感——要么手动堆重载,要么用 CRTP 硬扛。现在这一下把整条思路打通了,写库的、写框架的、写业务类的人都能在合适的场景下受益。
最后再分享一个小技巧:你可以给自己的类定义一个私有的工具函数,用显式对象参数统一实现读写逻辑,再通过两个公共接口(一个 const 一个非 const)暴露出去,这样既享受了 Deducing this 的推导优势,又不至于把所有 API 全部模板化,对类内部设计的侵入性最小。这个手法我在几个开源项目里实测过,兼容性很好。
由于篇幅控制,这篇「上」先到这里。基础概念、语法、推导规则、适用场景、踩坑记录已经讲透,下一篇重点拆解它在真实项目里的进阶用法:如何和 CRTP 模式结合、如何重构遗留代码里的 const 重载、以及递归 lambda 和 std::visit 等特性的联动技巧。感兴趣的话可以先把文中的代码示例都抄到本地跑一遍,有了手感再往下走。