C++推导本2
推导本(P0847)是C++23中提供了指定非静态成员函数的新方法的特征.一般,调用对象成员函数时,尽管不在参数列表中,会隐式传递该对象给成员函数.P0847允许显式化此参数,为其命名并加上const/reference限定符.如:
struct implicit_style {
void do_something(); //对象是隐式的
};
struct explicit_style {
void do_something(this explicit_style& self); //对象是显式的
};
显式对象参数由放在类型说明符前的this关键字区分,并且仅对函数的第一个参数有效.
原因似乎并不明显,但是一堆附加功能几乎是神奇的.其中包括化简代码,递归λ,按值传递this,及不需要在继承类上模板化基类的CRTP版本.
我叫它"显式对象参数",它的功能名比"推导本"更有意义.从VS2022的17.2版本开始,MSVC中支持显式对象参数.参考
引用限定参考
右值引用参考
template <typename T>
class optional {
// 四个函数差不多,是不是太冗余了?
constexpr T& value() & {
if (has_value()) {
return this->m_value;
}
throw bad_optional_access();
}
// 常左值版
constexpr T const& value() const& {
if (has_value()) {
return this->m_value;
}
throw bad_optional_access();
}
//非常右值
constexpr T&& value() && {
if (has_value()) {
return std::move(this->m_value);
}
throw bad_optional_access();
}
// 常右值.
constexpr T const&& value() const&& {
if (has_value()) {
return std::move(this->m_value);
}
throw bad_optional_access();
}
// ...
};
它们只在是否常及移动/复制要存储值上有差别.现在:
template <typename T>
struct optional {
// 1个版本解决所有.
template <class Self>
constexpr auto&& value(this Self&& self) {
if (self.has_value()) {
return std::forward<Self>(self).m_value;
}
throw bad_optional_access();
}
与上述四个重载同样,但在单个函数中.不必再编写const optional&,const optional&&,optional&,和optional&&等了.
而是编写推导调用对象的cvref限定符的函数模板.可大大减少代码.
注意,没有触及重载解析规则或模板推导规则,只是稍微改了点解析名字.
因此,假设:
struct cat {
template <class Self>
void lick_paw(this Self&& self);
};
根据熟悉的所有相同推导模板规则推导Self模板参数.没有神奇.不必用Self和self名,但它们是最明确选项,遵循了其他几种语言的功能.
cat marshmallow;
marshmallow.lick_paw(); //Self = cat&
const cat marshmallow_but_stubborn;
marshmallow_but_stubborn.lick_paw(); //Self = const cat&
std::move(marshmallow).lick_paw(); //Self = cat
std::move(marshmallow_but_stubborn).lick_paw(); //Self = const cat
一个名字解析更改是,在此类成员函数中,禁止显式或隐式引用this.
struct cat {
std::string name;
void print_name(this const cat& self) {
std::cout << name; //无效
std::cout << this->name; //有效
std::cout << self.name; //有效
}
};
使用案例
避免4个重载
注意,这降低了处理右值成员函数的初始实现和维护的负担.很多时候,开发者只会为成员函数编写常和非常重载,不想仅为了处理右值而编写另外两个完整函数.使用推导的this限定符,免费获得右值版本:只需在正确位置编写std::forward即可获得运行时性能提升,同时避免不必要副本:
class cat {
toy held_toy_;
public:
//之前,2个版本
toy& get_held_toy() { return held_toy_; }
const toy& get_held_toy() const { return held_toy_; }
//之后
template <class Self>
auto&& get_held_toy(this Self&& self) {
return self.held_toy_;
}
//之后+转发
template <class Self>
auto&& get_held_toy(this Self&& self) {
return std::forward<Self>(self).held_toy_;
}
};
当然,对更复杂函数,或正在处理想避免复制的大对象时,显式对象参数更方便.
CRTP
(CRTP)是编译时多态性,允许使用常见功能片段扩展类型,而无需支付虚函数成本.有时也叫插件.如,可编写可插件到另一个类型的add_postfix_increment类型,来根据前缀加定义后缀加:
template <typename Derived>
struct add_postfix_increment {
Derived operator++(int) {
auto& self = static_cast<Derived&>(*this);
Derived tmp(self);
++self;
return tmp;
}
};
struct some_type : add_postfix_increment<some_type> {
//前缀加,后缀加是根据它实现的
some_type& operator++();
};
在基类继承转换和函数内部模板化可能有点难懂,有多个级别的CRTP时,问题会更糟.使用显式对象参数,因为没有更改模板推导规则,因此可按继承类型推导显式对象参数类型.即
struct base {
template <class Self>
void f(this Self&& self);
};
struct derived : base {};
int main() {
derived my_derived;
my_derived.f();
}
在调用my_derived.f()中,f内部的Self类型是derived&,而不是base&.
表明可如下定义上面CRTP示例:
struct add_postfix_increment {
template <typename Self>
auto operator++(this Self&& self, int) {
auto tmp = self;
++self;
return tmp;
}
};
struct some_type : add_postfix_increment {
// 同上..
some_type& operator++();
};
注意,现在add_postfix_increment不是模板.相反,已移动自定义到operator++后缀.
转发出λ
从闭包中复制出抓的值很简单:可如常传递对象.从闭包移出抓的值也很简单:调用std::move它.但需要根据闭包是左值还是右值来完美转发抓的值时,就会出现问题.
从P2445取的一个用例是可在"重试"和"试或失败"环境中使用的λ:
auto callback = [m=get_message(), &scheduler]() -> bool {
return scheduler.submit(m);
};
callback(); // 重试(callback)
std::move(callback)(); // 试或失败(rvalue)
问题是:如何根据闭包值的分类转发m?显式对象参数提供了答案.因为λ生成有给定签名带operator()成员函数的类,因此机制也适合λ.
auto closure = [](this auto&& self) {
//可在λ内部使用self
};
表明可根据λ内闭包的值分类完美转发.P2445给了个根据另一个式的值分类转发一些式的std::forward_like助手
auto callback = [m=get_message(), &scheduler](this auto &&self) -> bool {
return scheduler.submit(std::forward_like<decltype(self)>(m));
};
现在原始用例可工作,根据如何使用闭包来复制或移动抓的对象.
递归λ
因为现在可在λ的参数列表上命名闭包对象,这允许递归λ!同上:
auto closure = [](this auto&& self) {
self(); //调用自身直到栈溢出
};
不过,除了溢出栈外,还有更有用用法.如,考虑不必定义其他类型或函数,直接访问递归数据结构?给定二叉树的以下定义:
struct Leaf { };
struct Node;
using Tree = std::variant<Leaf, Node*>;
struct Node {
Tree left;
Tree right;
};
可这样计算叶子数:
int num_leaves(Tree const& tree) {
return std::visit(overload( //见下
[](Leaf const&) { return 1; },
[](this auto const&self,Node*n)->int{
return std::visit(self, n->left) + std::visit(self, n->right);
}
), tree);
}
重载(overload)是从多个λ中创建重载集,一般用于访问variant.
通过递归计算树中的叶子数量.对调用图中的每个函数调用,如果当前为叶子,则返回1.否则,重载闭包会通过self调用自身,并递归加上左右子树的叶子数.
按值传递本
因为现在可定义显式对象参数的限定符,因此可选择按值而不是按引用来取它.对小对象,可提供更好的运行时性能.下面是一例.
假设有此代码,用普通的旧隐式对象参数:
struct just_a_little_guy {
int how_smol;
int uwu();
};
int main() {
just_a_little_guy tiny_tim{42};
return tiny_tim.uwu();
}
MSVC生成以下汇编:
sub rsp, 40
lea rcx, QWORD PTR tiny_tim$[rsp]
mov DWORD PTR tiny_tim$[rsp], 42
call int just_a_little_guy::uwu(void)
add rsp, 40
ret 0
我逐行介绍.
1,sub rsp,40在栈上分配40个字节.4个字节来保存tiny_tim的整成员,32字节为要用的uwu的影子空间,4个字节的填充.
2,lea指令加载,tiny_tim变量地址到(因为使用的调用约定)uwu隐式对象参数期望的位置的rcx寄存器中.
3,mov存储42进tiny_tim的整成员中.
4,然后调用uwu函数.
5,最后,释放在栈上分配的空间并返回.
如果改为指定uwu,按值取其对象参数,如下会怎样?
struct just_a_little_guy {
int how_smol;
int uwu(this just_a_little_guy);
};
此时,生成以下代码:
mov ecx, 42
jmp static int just_a_little_guy::uwu(this just_a_little_guy)
只需移动42进相关寄存器,并跳转(jmp)到uwu函数.因为不通过引用传递,因此不需要在栈上分配东西.因为不在栈上分配,因此不必在函数结束时释放.因此可直接跳到(uwu),而不是跳到那里,然后用call再返回该函数.
SFINAE不友好的可调用对象
给定optional的叫transform的成员函数,它仅在有存储值时在之上,调用指定函数,问题如下:
struct oh_no {
void non_const();
};
tl::optional<oh_no> o;
o.transform([](auto&& x) { x.non_const(); });
//不编译.
MSVC给出如下错误:
错误C2662:"void oh_no::non_const(void)":无法从"const oh_no"转换"this"指针为"oh_no&"
所以按隐式对象参数传递const oh_no给non_const版,这不管用.但const oh_no来自哪?答案就在optional自身实现中.这是精简版本:
template <class T>
struct optional {
T t;
template <class F>
auto transform(F&& f) -> std::invoke_result_t<F&&, T&>;
template <class F>
auto transform(F&& f) const -> std::invoke_result_t<F&&, const T&&>;
};
这些std::invoke_result_t是为了使transform是SFINAE友好的.基本上表明可检查调用transform是否会编译,如果不,则执行其他操作,而不是中止整个编译.但是,这里,语言有漏洞.
重载解析transform时,编译器必须确定给定参数类型,这两个重载中的哪一个是最佳匹配.为此,必须实例化,常和非常重载声明.
如果传递的可调用transform自身对SFINAE不友好,且对常限定的隐式对象无效(示例就是),则实例化,常成员函数的声明,会是个硬编译器错误.哎呀.
显式对象参数允许解决它,因为cvref限定符是从调用成员函数的式上推导出来的:如果从不调用,const optional版函数,则编译器不必试实例化该声明.从P1450中给定std::copy_cvref_t:
template <class T>
struct optional {
T t;
template <class Self, class F>
auto transform(this Self&& self, F&& f)
-> std::invoke_result_t<F&&, std::copy_cvref_t<Self, T>>;
};
允许上例编译,同时仍允许transform对SFINAE友好.
浙公网安备 33010602011771号