云栈社区的技术圈子里流传着一种说法:C++23 deducing this 改变了成员函数隐式对象参数的 ABI 传递方式。这句话其实只对了一半。
this 指针在 ABI 上历来就是第一个参数。P0847 提案真正触及的,并非“引用形态怎么传”,而是三个更底层的细节:值传递引发的复制语义、符号表里的名字编码,以及 const 在参数推导中扮演的角色。本文从 Itanium C++ ABI 的调用约定层层拆解,看透了哪些变了,哪些三十年没变。
“ABI 冲击论”错在哪里
“ABI”这个词经常把三个层面搅在一起:调用约定(寄存器如何传参)、符号(mangled name)、语义(参数如何初始化)。P0847 真正触动的是后两层,第一层纹丝未动。
证据就在编译器的实现里:在同一个 ABI 下,Clang 16 和 GCC 14 对显式对象参数的代码生成,与隐式 this 版本相比,寄存器分配逐比特相同——引用形态 this T& 和 this Self&&,本质上只是把 this 从“魔法”改写成了普通的第一参数。微软 C++ 团队博客说得很直白:这属于“explicit”(显式化)特性,而不是“redefine”(重定义)特性。一个把隐式对象参数从指针变成引用的提案,在 Itanium ABI 上不会产生任何寄存器层面的差异。
真正被改变的,是下面三件事。
this 在 Itanium ABI 上从来都是第一个参数
Itanium C++ ABI(GCC/Clang 在 Linux 上的共同基准)明确规定:非静态成员函数的调用,等价于将 this 作为第一个参数传入。在 x86-64 System V 下,它落入 RDI 寄存器。
struct Tensor {
float* data_;
float& at(size_t i) { return data_[i]; } // 非 const 版本
float at(size_t i) const { return data_[i]; } // const 版本
};
两个重载的调用约定完全一致——同一个寄存器,同一条传参路径。差异只在符号层:
$ nm -C libtensor.o | grep at
0000000000000000 T Tensor::at(unsigned long)
0000000000000010 T Tensor::at(unsigned long) const
在 Itanium mangling 规则里,const 成员函数比非 const 多一个 K 限定:_ZN6Tensor3atEm 对应 _ZNK6Tensor3atEm。这就是 const 在 ABI 上的全部代价——多一个符号字符,寄存器分配零影响。我习惯把 const 看作“类型系统的修辞”:编译器为它额外写个 K,仅此而已。
再深挖一层,还有两个多数人忽视的细节。其一是返回值:当返回类型拥有非平凡拷贝构造或析构时,调用方必须分配临时空间,并将指向它的指针作为隐式的第一个参数传入——它排在 this 和所有用户参数之前(Itanium C++ ABI 的 Return Value 规则)。也就是说,在最坏情况下,成员函数的实际参数顺序是“RVO 临时指针 → this → 用户参数”,this 甚至不是排头兵。其二是虚函数:this 必须指向定义该函数的那个类的实例;调用次要基类的虚函数时,编译器要在 vtable 里插入 thunk 做 this 调整(§3.2.1)——这是 this 在 ABI 上最昂贵的一处,但与 const 仍然无关。
mutable 是 const 的结构性漏洞
const 传播链上有一个官方后门:mutable。它允许 const 成员函数修改指定成员,代价是瓦解掉 const 的全部推理。
struct ProfiledLayer {
mutable size_t call_count_ = 0; // 统计用,const 路径也要写
float weight_[4096];
float infer() const {
++call_count_; // const 成员函数里写 mutable 成员,合法
return weight_[0];
}
};
这个后门在存储层面留下真切的痕迹。带 mutable 成员的 const 对象不能被整体放入只读段——如果编译器把它布局到 .rodata,++call_count_ 写入时会导致段错误。因此标准规定 const 对象的 mutable 成员可修改,实现上就只能把这类对象留在可写段。这是 const 在存储上唯一的“代价”,但不体现在 ABI:mutable 不参与 mangling,_ZNK14ProfiledLayer5inferEv 里根本没有它的位置。
对编译器优化来说,mutable 是一堵墙。非 mutable 成员在 const 成员函数内部的读可以放心缓存、外提、向量化;而 mutable 成员的每次读取都必须重读,因为它可能在任意 const 路径上被意外修改。我见过不止一个推理框架把 call_count_ 写进 hot path,性能剖析时才发现,一个本可向量化的循环被它拆得稀碎——const 的优势在 mutable 面前退化为“除了 mutable 都不可变”。
deducing this:把 this 从指针变成可推导的参数
P0847R7(C++23 正式采纳)给出了三种显式对象参数形态:
struct Vec {
float* d;
size_t n;
// 形态一:引用,this 从指针变成引用
float get(this Vec& self, size_t i) { return self.d[i]; }
// 形态二:模板推导,cv/ref/值类别全由调用点决定
template <class Self>
auto&& at(this Self&& self, size_t i) {
return std::forward<Self>(self).d[i];
}
// 形态三:按值,对象本身作为参数拷贝进来
Vec copy(this Vec self) { return self; }
};
形态一和形态二在调用约定上与隐式 this 逐比特相同——引用即指针,this Vec& 仍然落在与隐式 this 完全一致的位置(正常情况下 RDI)。真正改变“传递方式”的只有形态三:对象按值传参,小对象进寄存器,大对象走栈并伴随一次复制/移动。这是全文唯一一处真正由 ABI 传递方式引起变化的地方,代价也最直接:每次调用都复制整个对象。
符号层的变化是 2023–2024 年才敲定的新规则。在没有 H 标记之前,显式对象参数版本在 mangling 里被当作“跳过对象参数”来处理——libcxxabi 的 demangler 最初就这么实现,于是 void f(this Vec)(按值)与 void f() 的对象参数类型明明不同(Vec 对 Vec&),符号却撞成同一个,链接期根本分不清。Itanium ABI 的 cxx-abi#148 为此引入 H 标记,将显式对象参数的类型也编码进符号:
_ZN3Vec1fEv // Vec::f() (隐式对象参数)
_ZNH3Vec1fE1F // Vec::f(this Vec) (显式对象参数,按值)
Clang 16 率先实现,GCC 从 r14-7222(2024-01,GCC 14)跟进。这个 H 带来的后果是:任何迁移到 deducing this 的类,符号表都会经历一次重写——第二节里“const 只多一个 K”的结论就此失效,它多出的一个前缀 H 外加一整个对象参数类型。
const 传播在此发生了质变。隐式 this 时代,const 是写死在函数声明上的静态类型;deducing this 则将 const 变成了模板推导的输出:x.at(0) 推导 Self = Vec&,std::move(x).at(0) 推导 Self = Vec,const Vec cx; cx.at(0) 推导 Self = const Vec&——一个函数模板就覆盖了原来 & / const& / && / const&& 四种重载,这正是 P0847 要消灭 optional::value() 那四份重复代码的核心动机。
但我也要给这个特性泼点冷水。形态三的复制开销只是第一层代价;第二层是符号兼容性——把现有类迁移到 deducing this,所有成员的 mangled name 都会长出 H 前缀,这对动态库边界就是一次静默的 ABI 破坏,新旧 .so 混装直接触发 undefined symbol。第三层是虚函数:C++23 明确禁止带显式对象参数的成员函数声明为 virtual(虚机制仍依赖隐式 this 的动态分派),2024 年的 P3469R0 提案试图放开这个限制,但尚未进入标准。所以说,deducing this 现阶段还覆盖不了多态代码,这是一个真实的适用边界。
符号、值传递与 const 推导三个落点
对 C++ 架构师而言,第一个落点是二进制边界。凡是导出类、插件接口、pybind11 绑定、跨 .so 的成员函数,迁移前务必先跑一遍符号对比:H 前缀意味着 ABI 不兼容,这不是换一个语法糖的事,而是发布计划的事。我去年把一个推理框架的 Layer 基类改成 Self&& 形态,测试全绿,但旧的预编译 kernel 模块一加载就报告 undefined symbol——符号层的变化不会在单个翻译单元里暴露出来。
对 AI 大模型算法专家来说,第二个落点是 const 传播与优化的关系。推理引擎中的张量访问器,const 版本能让编译器在只读路径上消除别名、放心向量化,这和 CUDA kernel 里写 const __restrict__ 是同一套思维:告诉编译器“这路数据没人写”,它才敢把加载提到循环外。deducing this 的形态二把这份保证从手写四份重载变成了模板的一次推导。
第三个落点是值语义。形态三对小对象是友好的:一个 16 字节的 shape 结构按值传参进寄存器,省去别名分析,配合 RVO 往往实现零拷贝。但对大对象则是陷阱——copy() 每次真的复制,编译器不会给你豁免。
this 在 ABI 上从来就没变过——它始终是一个待在寄存器里的指针。C++23 改变的是符号表里多出的那个 H、形态三引入的复制语义,以及 const 从“写死的静态类型”变为“可推导的参数策略”。下次再听到“deducing this 改了 ABI”,不妨反问一句:改的究竟是调用约定,还是符号与推导?这两者的代价,差距可不止十万八千里。
声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。