ARTICLE DETAIL

资讯详情

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

C++23 Deducing this:用显式对象参数告别重复代码与CRTP样板

C++23 Deducing this:用显式对象参数告别重复代码与CRTP样板 写C写了十年我大概被两件事烦过无数遍同一个成员函数为了const重载要复制一份几乎相同的实现以及写CRTP时满屏的static_cast。C23的Deducing this特性P0847终于给出了系统的解法——它让成员函数里那个永远藏起来的隐式this变成可以在参数列表里显式声明甚至直接参与模板推导的第一个参数。这篇文章是基础篇我会把显式对象参数explicit object parameter的语法形态、推导规则和几个最常用的基础场景一次讲清楚适合已经掌握普通成员函数和模板推导、但还不熟悉这个新特性的C开发者。1. 从隐式this的多年痛点说起1.1 两个成员函数重复代码的现实先看最典型的场景。假设你写了一个Buffer类里面用std::vectorint存数据对外提供带边界检查的访问接口class Buffer { std::vectorint data_; public: int at(size_t index) { check(index); return data_[index]; } const int at(size_t index) const { check(index); return data_[index]; } };这段代码本身没问题但你已经能闻到重复的气味check逻辑写了两遍函数体几乎一样唯一的区别只是data_[index]的返回类型。如果后面要给at加参数校验、加日志、加锁你得同步维护两处。再往后如果这个类还需要区分右值调用和左值调用或者加上volatile限定重载数量会以组合方式增长普通版本、const版本、限定的、限定的、const的、const的。每个版本的函数体都长一样这就很折磨人了。问题的根源在于C的普通成员函数把this指针藏在了函数签名背后你无法在参数列表里直接声明对象的类型和值类别。所以一旦需要针对不同的cv/ref限定做不同处理就只能靠重载复制粘贴。Deducing this的核心价值之一就是把这个藏起来的对象参数重新放回到参数列表里让模板推导替你做组合。1.2 CRTP样板能静态多态但难读第二个痛点是CRTPCuriously Recurring Template Pattern。传统写法是这样的templatetypename Derived struct ShapeBase { const char* name() const { return static_castconst Derived(*this).nameImpl(); } }; struct Circle : ShapeBaseCircle { const char* nameImpl() const { return circle; } };这里你必须通过模板参数把Derived传给基类然后在每个成员函数里手动做一次static_cast把基类的this转成派生类引用。这个static_cast是必须的但它完全是样板代码读代码的人需要额外理解哦这里是CRTP要把this转成派生类写代码的人也多了一处可能出错的地方。更糟的是如果你是Circle的使用者看到ShapeBaseCircle这种形式需要把模板参数和类名对起来。如果继承链更深这种模板参数的粘连会让代码越来越难读。Deducing this对这个问题的解法非常直接不再需要把Derived作为模板参数传给基类因为基类的成员函数可以把自己的对象参数声明成一个通用的模板参数编译器会直接推导出实际调用的派生类类型。1.3 lambda递归标准库wrapper的昂贵代价第三个痛点平时遇到得少但遇到就很烦想在lambda里递归调用自己。C14时代的标准做法是用std::function把lambda包一层std::functionint(int) factorial [](int n) - int { return n 1 ? 1 : n * factorial(n - 1); };这样能跑但代价是std::function的类型擦除开销虚函数式的分派、可能的内存分配、以及无法内联。更微妙的是lambda捕获了factorial这个std::function对象本身一旦你把这个lambda复制出去捕获的那份std::function和外面的是同一个对象但代码意图已经绕了几个弯。Deducing this允许lambda声明一个显式对象参数这个参数就是lambda闭包对象自身于是你可以写auto factorial [](this auto self, int n) - int { return n 1 ? 1 : n * self(n - 1); };不需要std::function不需要捕获调用factorial(5)即可。编译器能确定self就是当前这个闭包对象的精确类型内联和优化都变得容易。这三个痛点的共性是对象本身在成员函数或闭包中一直是个半隐式的存在。Deducing this解决的问题本质上就是把这个半隐式的东西变成一个普通的、可推导的、可参与重载决议的显式参数。2. 显式对象参数的语法形态2.1 最小示例与调用方式Deducing this的语法并不复杂核心规则只有一条如果成员函数的第一个参数以this关键字引入那么这个参数就被称为显式对象参数explicit object parameter。struct Widget { void foo(this Widget self, int x) { self.value_ x; } int value_ 0; };这里self的类型是Widget它绑定到调用该成员函数的对象。注意this不是参数名它是一个引入符参数名是self。你当然可以把参数名改成任意合法的标识符但社区惯例是叫self你会在各种标准库实现和示例代码里看到这个命名建议保持这个习惯。调用方式完全不变Widget w{}; w.foo(42); Widget{}.foo(1); // 错误右值不能绑定到 Widget也就是说对外暴露的接口和普通成员函数一模一样变化的只是成员函数的内部声明方式。这个调用方无感知的特性很重要它意味着你可以渐近式地改造现有类不需要改任何调用代码。显式对象参数必须是成员函数的第一个参数。写成void foo(int x, this Widget self)会直接编译错误。2.2 五种写法的限定符对照显式对象参数的类型可以自由指定cv限定和引用限定这五种写法对应了你在普通成员函数里见过的各种对象状态显式对象参数等价的隐式成员函数形式可绑定的对象this Widget selfvoid foo() 非const左值this const Widget selfvoid foo() const 左值或右值右值能绑定到constthis Widget selfvoid foo() 仅右值this Widget self无直接等价左值或右值按值复制一份this const Widget self无直接等价左值或右值按值复制一份const对象第一行是基础左值对象可以调用右值对象不行。第三行反过来只有右值可以调用因为非const左值引用不能绑定到右值这个约束由编译器强制执行。这些行为其实和你写普通重载时的预期完全一致只不过现在完整地反映在参数类型上。第四行和第五行值得特别说一句显式对象参数可以按值传递。这意味着调用时会把对象复制一份到函数参数里函数内部操作的是副本不会影响原对象。这在普通成员函数里做不到普通成员函数你只能拿到*this的引用想操作副本得自己再拷贝一次。后面我会在应用场景里给一个例子。2.3 调用形式没变变化在参数列表有人会问既然调用方式没变那编译器怎么知道w.foo(42)里的w应该当作Widget呢答案是成员函数调用w.foo(42)会被编译器翻译成一次普通的函数调用对象w作为第一个实参传给显式对象参数数值42作为第二个实参。也就是说w.foo(42)在概念上等价于Widget::foo(w, 42)——只是这个等价是编译器内部完成的你不需要也不能用普通函数的语法去调用它至少目前是这样的。这个设计最大的好处是重载决议变得非常直观。普通成员函数的const重载和重载依赖一套特殊的规则而显式对象参数让你可以直接用参数类型匹配的思路去理解左值匹配Widget还是const Widget右值匹配Widget还是const Widget完全就是普通引用绑定的规则不再有隐性规则需要背诵。2.4 与隐式对象参数的区别用表格简单对比一下隐式对象参数和显式对象参数对比项隐式对象参数传统成员函数显式对象参数Deducing this声明方式编译器默默加不可见作为第一个参数以this引入类型控制由cv/ref限定符间接控制直接写在参数类型里模板推导不支持支持可写this Self self函数体访问成员直接用成员名或this-通过参数名访问如self.member_与虚函数配合正常不允许与static配合不冲突static没有this不允许有一点需要明确即使使用了显式对象参数函数体里的this指针仍然存在你仍然可以写this-value_。参数self和this指向同一个对象。但新代码里推荐把所有成员访问都改成通过self进行这样代码风格统一也更能体现对象是一个普通参数的思路。this关键字本身并没有被废弃只是在这个新语法里更多是作为引入符存在。3. 编译器如何推导这个this3.1 转发引用推导规则完整梳理真正让Deducing this威力大增的是模板推导。最常见的模式是把显式对象参数声明成一个转发引用模板参数struct Widget { int value_ 0; templatetypename Self void set(this Self self, int v) { self.value_ v; } };这里Self是转发引用推导规则和函数模板完全一致。假设有下面几种调用场景编译器推导出的Self如下调用代码self 的具体类型Widget w; w.set(1);Widgetconst Widget cw; cw.set(1);const WidgetWidget{}.set(1);Widget因此self是Widgetconst Widget{}.set(1);const Widget因此self是const Widget注意推导结果是精确的类型自动保留了const和值类别信息。这相当于你用一份代码自动生成了普通成员函数中void set(int)、void set(int) const、void set(int)、void set(int) const四个重载的行为。但这里有一个细节需要强调如果你用了templatetypename Self那么set本身成了函数模板。模板实例化会在第一次调用时发生每种self类型都会生成一份独立的代码。也就是说上面四类调用会生成四份set的实现。如果你的函数体很大代码膨胀是真实的成本。后面我会专门谈这个问题以及应对方式。3.2 decltype(auto)与返回类型语义模板推导的便捷也会带来新的坑最大的坑是返回类型。看这个例子class Buffer { std::vectorint data_; public: templatetypename Self auto at(this Self self, size_t index) { return self.data_[index]; } };看起来没问题但auto推导会丢掉引用和顶层const。self.data_[index]的原始类型是int或const int经过auto推导后会变成int或const int——也就是说返回值变成了一个拷贝。对at这种访问器来说是灾难性的改动。正确写法是用decltype(auto)templatetypename Self decltype(auto) at(this Self self, size_t index) { return self.data_[index]; }这样decltype(auto)会完整保留表达式的类型和值类别非const左值对象调用返回intconst对象调用返回const int。我建议把只要显式对象参数用了模板推导成员访问类函数就用decltype(auto)当成一条默认规则除非你有意识地想返回一个副本。顺带提一个更进阶的写法。如果想让右值对象调用时连嵌套成员也一起按右值返回比如std::optionalT::value()函数的语义需要主动配合std::forwardtemplatetypename Self constexpr decltype(auto) value(this Self self) { if (!self.has_value()) throw std::bad_optional_access(); return std::forwardSelf(self).m_value; }这里self.m_value本身是左值不加forward的话右值对象调用也返回T。加上std::forwardSelf(self)之后右值调用会返回T允许调用方把数据从临时optional里移动出来。这是Deducing this和引用折叠配合的经典写法建议理解清楚。3.3 重载决议从对象参数角度重新理解显式对象参数让重载决议回归朴素。假设你有两个重载struct Widget { void print(this const Widget self) { std::cout const\n; } void print(this Widget self) { std::cout \n; } };对Widget w; w.print();来说w是左值能绑定const Widget不能绑定Widget所以调用第一个。对Widget{}.print();来说临时对象是右值两个重载都能绑定吗const Widget能绑定右值Widget也能绑定右值编译器会选择更匹配的Widget版本。这和你写普通自由函数重载void f(const Widget)与void f(Widget)的匹配规则一模一样。这就是为什么说这个特性消除了成员函数重载的特殊感。以前你写void print() const和void print() 需要记住引用限定符的规则现在你只需要掌握左值、右值、const引用、非const引用之间的绑定优先级这套你已经用了很多年的规则。重载决议还有一个收益如果要删除某个组合可以直接显式删除对应版本。比如你希望成员函数只允许左值调用可以写void dangerous(this Widget self); void dangerous(this const Widget) delete; void dangerous(this Widget) delete;一旦有右值或const对象调用编译期就会得到一个明确的错误。这个能力在工具类接口设计里很实用。3.4 模板与非模板显式对象参数的差异现在把两种写法放在一起看struct A { void f(this A self); // 非模板固定类型 }; struct B { templatetypename Self void f(this Self self); // 模板每种Self实例化一份 }; struct C { void g(this const C self); // 非模板const void g(this C self); // 非模板 };非模板写法的优点是没有代码膨胀函数就是普通成员函数只是用显式参数声明取地址、重载、ABI都更简单。但它同时也失去了泛化能力它只能处理你写死的这个类的类型。模板写法则把对象参数变成了可以继续推导的模板参数。它的好处不仅仅是对这个类本身生效对派生类也同样成立——这就是CRTP痛点的解法。但它也有代价函数成为模板后会影响虚函数本来也不能用、会为不同Self类型生成多份实例、部分编译期行为会改变。我的建议是如果只需要处理当前类自身优先用非模板写法如this Widget self或this const Widget self如果需要支持派生类、需要同时覆盖左值/右值/const几种组合才用模板写法。不要一上来就templatetypename Self那会让本来简单的接口变成一个模板徒增编译负担。4. 基础应用场景逐一落地4.1 用一份at()覆盖const与非const刚开头那个Buffer的例子用Deducing this改写后是这样class Buffer { std::vectorint data_; public: templatetypename Self decltype(auto) at(this Self self, size_t index) { self.check(index); return self.data_[index]; } };现在检查逻辑只写了一遍返回类型由调用对象的const属性自动决定普通Buffer调用返回intconst Buffer调用返回const int临时Buffer调用也返回int因为右值对象的非static数据成员本身是左值你不需要为右值做任何额外处理。这个模式可以推广到几乎所有既要const版本又要非const版本的getter/访问器。在实际项目里operator[]、front()、back()、data()这类函数的改造收益最明显因为它们的函数体往往只有一行但重复的声明占了很多行。有一个老生常谈但值得重复的注意事项如果函数体内访问的是数据成员内部的子对象并且这个子对象的operator[]或getter本身有重载那么self.data_[index]的返回类型会由子对象的类型决定而不是由self决定。这时候要仔细分析decltype(auto)推导出的类型必要时写一个static_assert来锁定行为static_assert(std::is_same_vdecltype(std::declvalBuffer().at(0)), int); static_assert(std::is_same_vdecltype(std::declvalconst Buffer().at(0)), const int);这种编译期断言在改造大代码库时特别有用可以防止模板推导悄悄改变了接口语义。4.2 只想让右值调用的成员函数有时候某个成员函数设计出来就是给临时对象用的。比如一个日志采集器你写完一条log后希望立刻把数据序列化并清空但不想让外部持有一个可以反复触发序列化的左值对象class LogCollector { std::string buffer_; public: std::string flush() { // 传统写法 std::string ret buffer_; buffer_.clear(); return ret; } };传统写法用限定符但遇到const右值就失效而且如果你想再限制const左值还得补几个重载。用Deducing this可以更直接class LogCollector { std::string buffer_; public: std::string flush(this LogCollector self) { std::string ret std::move(self.buffer_); self.buffer_.clear(); return ret; } };现在只有右值对象能调用flush。左值调用会在编译期直接报错。如果你希望const右值也不能调用非模板写法天然满足LogCollector不能绑定到const右值。同理你也可以按值传显式对象参数来实现对副本操作。比如一个配置对象你想提供一个方法生成一个修改过的新副本但又不改变原对象class Config { int timeout_ 30; public: Config withTimeout(this Config self, int t) { self.timeout_ t; return self; } };withTimeout按值接收一个Config副本修改副本后返回。调用方式非常自然Config base; Config changed base.withTimeout(60); // base 不变这个模式在配置类和不可变值对象里会很常用传统写法需要手写拷贝、修改、返回三步现在一步到位。4.3 CRTP的现代写法回到CRTP那个例子。用Deducing this后struct ShapeBase { templatetypename Self const char* name(this Self self) { return self.nameImpl(); } }; struct Circle : ShapeBase { const char* nameImpl() const { return circle; } };最大的变化是ShapeBase不再需要模板参数Derived。编译器在Circle调用name()时会把self推导为Circle于是self.nameImpl()直接调用Circle的成员。没有static_cast没有模板基类代码读起来就是一个普通基类接口。需要注意一个使用前提这个模式依赖CRTP的语义——基类函数不会实例化直到某个派生类真的调用它。如果你不小心创建了一个裸的ShapeBase base;并调用base.name()编译器会尝试实例化ShapeBase::nameShapeBase然后在self.nameImpl()处报没有这个成员的错误。这个报错位置还算清晰但不如显式requires那么友好。如果希望错误信息更明确可以用C20的requirestemplatetypename Self const char* name(this Self self) requires requires (Self s) { { s.nameImpl() } - std::convertible_toconst char*; } { return self.nameImpl(); }当Self没有nameImpl时重载会被替换掉而不是硬报错。对于库代码这个约束是值得的。还要补充一点Deducing this的CRTP变体依然依赖静态绑定它不会让多态从静转动它只是把传统CRTP的实现细节藏得更干净、更好读。如果你需要真正的运行时多态仍然应该用virtual。4.4 无捕获递归lambda递归lambda的问题在语法上被Deducing this一举解决auto factorial [](this auto self, int n) - int { return n 1 ? 1 : n * self(n - 1); };这个lambda的闭包类型里operator()的第一个参数是self类型推导为当前闭包类型本身。self(n-1)就是用闭包对象自己再去调用operator()本质上和普通函数递归没有区别编译器可以正常内联和优化也不存在std::function的类型擦除开销。一个限制是带显式对象参数的lambda不能有任何lambda捕获。包括按值捕获、按引用捕获都不允许。这是标准明确禁止的。所以上面的递归函数必须把外部依赖通过函数参数传进去而不能直接捕获。如果你确实需要闭包访问外部变量可以把它放到结构体里再定义operator()或者用传统std::function方案。这个限制单独拿出来讲是因为很多初学者会在写递归lambda时顺手捕获this指向的外部对象然后发现编译失败找不到原因。如果你有多个重载的递归lambda呢比如想写一个同时处理int和double的递归访问者也可以把显式对象参数和普通模板参数组合起来auto visit [](this auto self, auto v) - void { using T std::decay_tdecltype(v); if constexpr (std::is_integral_vT) { // ... } else { self(std::forwarddecltype(v)(v)); } };这里的self始终指向闭包自身即使闭包类型本身不变但普通参数可以继续做模板推导。这种自引用模板lambda在实现树形遍历、状态机等场景时相当顺手。4.5 让基类非虚接口直接使用派生类成员这一节可以看作是CRTP的推广。假设你有一个工具类提供某种通用的算法骨架但部分细节让派生类实现。传统CRTP里基类函数通过static_castconst Derived(*this)去调派生类成员。Deducing this的写法更直接class Interface { public: templatetypename Self int process(this Self self, int input) { int v self.preprocess(input); // 中间逻辑 return self.postprocess(v); } }; class MyImpl : public Interface { public: int preprocess(int v) const { return v * 2; } int postprocess(int v) const { return v 1; } };调用MyImpl impl; int result impl.process(41); // (41*2)1 83和普通CRTP相比代码的意图更清晰process是这个接口的操作它需要preprocess/postprocess提供具体步骤。你在接口继承层次里只需要声明一次process子类不需要再跟模板参数纠缠。这里有一个设计层面的提醒将成员函数模板化之后它就不再是虚函数了本来显式对象参数与virtual互斥所以这种写法实现的是一种编译期接口约束。如果需要在容器里存放Interface*并通过基类指针调用process这条路走不通你仍然需要virtual。两种技术定位不同按场景选择。5. 使用前需要记住的边界与坑5.1 和虚函数、static、构造/析构的硬冲突显式对象参数虽然强大但有以下几种情况是编译器直接拒绝的不能用于虚函数。virtual void foo(this Widget self)不合法。根本原因在于虚函数的多态分派依赖this指针的静态类型签名如果this可以被模板推导成不同的Self类型虚表无法表达这种动态推导。不能用于static成员函数。static函数没有对象显式对象参数需要一个对象逻辑上就矛盾。不能用于构造函数和析构函数。构造函数/析构函数的语义是在不完整对象上操作而显式对象参数要求对象已经完整可用。这个限制在直觉上很容易理解你不能把正在构造的对象当作一个完整对象传给别人。不能和尾缀引用限定符同时使用。比如void foo(this Widget self) 是错误的。因为Widget已经表达了只在右值对象上调用这个语义再写后缀是多余的。这些限制都是硬性编译错误不是运行时坑遇到报错直接改设计就行。5.2 引用限定后缀与默认实参的禁止除了上面那些结构冲突还有两个容易踩的语法细节。第一个是默认实参。显式对象参数不能有默认实参void foo(this Widget self /* 不允许 */);理由很简单显式对象参数的值由成员函数调用语法自动提供的你说不清这个默认值应该是什么。就算你写了一个静态对象当默认值也会引发生命周期和初始化顺序的混乱。所以标准直接禁止。第二个是参数顺序。显式对象参数必须是第一个参数。如果需要让调用方传入额外参数写在它后面。这个要求很自然但一开始写的时候容易把顺序搞反尤其是习惯传统成员函数写法的人可能会下意识把普通参数放在前面。编译器的报错信息一般会提示expected explicit object parameter as first parameter看到这个提示就知道是顺序问题。5.3 lambda捕获互斥与规避方法前面已经提过带显式对象参数的lambda不能有任何lambda捕获。标准这么限制是为了避免捕获的成员变量和self这个闭包对象的状态出现两套来源lambda捕获会把外部变量拷贝或引用进闭包对象而self又代表闭包对象本身二者叠加会让闭包成员的状态变得难以推理。如果确实需要递归lambda访问外部状态有几种替代方案把外部状态作为普通参数传给lambda。把lambda放进一个结构体里定义自己的operator()成员函数这样成员访问走的是显式参数self没有lambda捕获的参与。用传统std::function方案虽然性能和可读性差一些。我自己的习惯是优先考虑第一种。递归函数需要的外部状态其实往往可以显式传参写出来反而更清晰。5.4 按值传递的拷贝代价与适用场景在2.2节的表格里this Widget self按值传递会复制整个对象。对大型对象来说这个代价可能不小。所以按值形式适合小对象、值语义对象以及配置类这种本来就打算复制的场景。如果你只是想让函数不修改原对象可以用this const Widget self避免拷贝然后在需要副本的地方手动拷贝。如果对象是不可拷贝的按值形式甚至不能编译。因为调用时会尝试拷贝构造。这时候要么改用引用形式要么用移动构造——Widget{}.foo()可以匹配this Widget self吗可以它会移动构造self参数。但Widget w; w.foo()就不行了因为左值不能隐式移动。这种用拷贝/移动决策参与重载匹配的特征让按值显式对象参数成为一个需要谨慎使用、但偶尔很有用的工具。还要注意的是按值形式访问数据成员时你访问的是self这个副本的成员即使它是const对象也不影响你在函数内部调用非const成员函数因为self在函数作用域内是个非const局部变量除非你声明成this const Widget self。这给了你一个在副本上自由操作的窗口。5.5 编译器支持现状与最小测试环境Deducing this是C23正式标准的一部分目前在主流编译器的支持情况如下编译器最低版本可用编译选项GCC13-stdc23或更保守用-stdc2bClang17-stdc23MSVCVS 2022 17.719.37/std:clatest如果要在老版本下提前体验可以看看有没有标准库厂商提供backport但更现实的做法是锁一个新版编译器在工程里先试点。我的建议是不要在线上大规模使用除非你的项目已经切换到GCC 13或Clang 17以上的工具链并且有完善的CI覆盖。做一个最小测试环境很便宜// main.cpp #include iostream struct Widget { int value_ 0; templatetypename Self void add(this Self self, int x) { self.value_ x; } }; int main() { Widget w; w.add(10); std::cout w.value_ \n; const Widget cw; // cw.add(10); // 错误不能修改const对象 }编译g -stdc23 -Wall -Wextra main.cpp -o test ./test如果编译器报expected constructor, destructor, or type conversion before this之类的错误优先检查编译器版本是不是足够新其次检查参数顺序和ref-qualifier后缀是不是混用了。我个人的使用体会是Deducing this最容易入手、收益最稳的使用场景是把数据类的重复const重载改写成decltype(auto)加模板Self的版本。因为改造成本低、风险小、测试直观还能立刻消除代码重复。CRTP改造和递归lambda则适合在新代码里慢慢引入不要急于把现有大量CRTP一次性推倒重来——毕竟函数模板化之后实例化行为、ABI、编译时间都会发生变化。先用小范围试验把编译期断言和调用约束写好再逐步铺开。这次基础篇先讲到这里下篇可以继续聊显示对象参数和成员函数指针的交互、更复杂的重载设计以及在标准库实现里能看到的具体应用。
返回列表