ARTICLE DETAIL

资讯详情

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

泛型 lambda 与 mutable:auto 参数到底生成什么,值捕获的副本为什么改不动

泛型 lambda 与 mutable:auto 参数到底生成什么,值捕获的副本为什么改不动 C14 给 lambda 加了一个看起来很随意的能力参数可以写auto。于是[](auto a, auto b) { return a b; }一个闭包就能同时处理int、double和std::string。但「写auto就等于把operator()变成模板成员函数」这件事决定了它的实例化行为、代码体积和转发写法而另一个关键字mutable存在的原因更简单 ——lambda 的operator()默认是const值捕获的成员根本改不动。本文把这两件事一次讲透。1. 引子一个 add 想服务三种类型先写个普通 lambda 试试加法// 片段普通 lambda 的参数类型必须写死autoadd_int[](inta,intb){returnab;};// 只能收 intautoadd_double[](doublea,doubleb){returnab;};// 只能收 doubleautoadd_str[](std::string a,std::string b){returnab;};// 只能收 string三条几乎一样的定义只有类型不同。C14 的泛型 lambdageneric lambda把这件事压缩成一条#includecstdio#includestringintmain(){constautoadd[](autoa,autob){returnab;};// 泛型 lambdastd::printf(int : %d\n,add(2,3));std::printf(double : %.2f\n,add(1.5,2.25));std::printf(string : %s\n,add(std::string(ab),std::string(cd)).c_str());std::printf(混合 : %.1f\n,add(1,2.5));return0;}int : 5 double : 3.75 string : abcd 混合 : 3.5add是同一个闭包对象却接受了四种类型组合 —— 而且注意最后一行add(1, 2.5)的返回类型是double说明返回类型也是按实参推导的。这不是运行时多态而是每个类型组合在编译期各自实例化了一份operator()。官方文档Lambda expressions — cppreferenceauto参数对应「泛型 lambda」一节2. 编译器生成什么模板化的 operator()普通 lambda 的operator()是一个普通成员函数参数带auto之后它变成一个模板成员函数。两者长得像实例化行为完全不同源码一个泛型 lambda 编译器生成概念模型 ────────────────────────────────── ────────────────────────────────────────────── class __Lambda_5_15 { ← 闭包类型 const auto add public: [](auto a, auto b) { // 模板成员函数不是普通成员函数 return a b; template class T, class U }; auto operator()(T a, U b) const { return a b; } }; 调用点折成一个具体的实例和函数模板一模一样 add(2, 3) → add.operator()int, int(2, 3) → int add(1.5, 2.25) → add.operator()double, double(1.5, 2.25) → double add(std::string{a}, → add.operator()std::string, std::string(...) → std::string std::string{b}); 关键区别 · 普通 lambda → 一个 operator()调用点做的是「实参到形参的隐式转换」 · 泛型 lambda → 每个实参类型组合实例化一份 operator()调用点做的是「模板推导」由此推出三条实用结论泛型 lambda 是模板所以模板的一切规则都适用包括「实例化是按类型组合展开的」。代码体积会按类型组合增长调用了 4 种类型组合就有 4 份operator()的目标代码。这会影响指令缓存icache在泛型 lambda 被大量内联展开的热路径上代码膨胀反而可能拖慢速度 ——不是「泛型 更快」而是「泛型 更容易内联、但可能更大」。闭包对象本身仍然只有一个类型无论实例化出多少份operator()无捕获的泛型 lambda 依旧是 1 字节的空类。3. 泛型 lambda 支持完美转发因为参数是模板推导出来的auto x在泛型 lambda 里就是万能引用forwarding reference配合std::forwarddecltype(x)(x)就能把实参的左值 / 右值属性原样传下去#includecstdio#includestring#includeutilitystd::stringconsume(std::strings){return左值:s;}std::stringconsume(std::strings){return右值:s;}intmain(){// auto 在泛型 lambda 里是万能引用forwarding referenceconstautorelay[](autox){returnconsume(std::forwarddecltype(x)(x));};std::string ahello;std::printf(%s\n,relay(a).c_str());// 左值std::printf(%s\n,relay(std::string(world)).c_str());// 右值return0;}左值:hello 右值:world两个必须记住的点必须写decltype(x)不能写std::forwarddecltype(x)之外的形式。x的静态类型是auto推导出来的引用类型转发时必须把「推导出的具体类型」告诉std::forward写成std::forwardauto(x)或std::forwarddecltype(auto)(x)都是错的。auto用在泛型 lambda 参数上是万能引用但用在普通函数里auto也是万能引用—— 这条规则和模板参数T完全一致。相反如果写成[](const auto x)就丢掉了「右值实参能否被移动」的信息转发自然也就无从谈起。官方文档std::forward — cppreference、C Core Guidelines4. mutableoperator() 默认是 const这是新手最容易被绊一下的地方lambda 的operator()默认带const所以值捕获的成员是只读的改一下就编译报错// 片段故意编译失败不要这么写不加 mutable 就想改值捕获的成员automake_bad_counter(){intcalls0;autobad[calls](){returncalls;};// ✗ 值捕获的成员在 const 函数里改不动returnbad;}真实报错gcc 13.2措辞就是read-only variableprog.cc: In lambda function: prog.cc:4:37: error: increment of read-only variable calls 4 | auto bad [calls]() { return calls; }; | ^~~~~加上mutable就能改 —— 但改的是闭包里的那份副本原变量毫发无损#includecstdiointmain(){intcalls0;// 不加 mutable 时下面这行是编译错误operator() 默认是 const// const auto bad [calls]() { return calls; };autocounter[calls]()mutable{returncalls;};// 改的是副本std::printf(第一次调用 %d\n,counter());std::printf(第二次调用 %d\n,counter());std::printf(原变量 %d\n,calls);// 闭包是对象可以拷贝 —— 副本各自有独立状态autoc2counter;std::printf(原闭包再调用 %d\n,counter());std::printf(拷贝的闭包 %d\n,c2());return0;}第一次调用 1 第二次调用 2 原变量 0 原闭包再调用 3 拷贝的闭包 3三个结论全在输出里原变量 0—— 闭包里的calls是捕获那一刻拷进来的独立成员mutable只解除了它的只读限制没有任何「写回外部」的效果。想让外部看到变化只能用引用捕获[calls]。状态留在闭包对象里—— 第一次调用返回 1、第二次返回 2说明这个int成员是闭包的一部分这也是「用 lambda 当计数器」能成立的原因闭包就是一个带状态的函数对象。拷贝闭包会拷贝状态——auto c2 counter;之后counter内部已经是 2所以两边接着都涨到 3。想共享状态就别拷贝把它放进std::function传引用或者改用shared_ptr捕获。mutable之前 / 之后的差别可以列成一张表写法operator()的 cv 限定能改值捕获成员吗能用const闭包对象调用吗[x] { ... }const不能编译错误能[x] { ... } mutable非const能改的是副本不能const对象调不了非 const 成员函数最后一行是个隐性代价加了mutable之后这个闭包就不能被const引用地调用了传参时要注意形参别写成const auto。所以mutable只该在「确实需要闭包自带可变状态」时使用—— 典型就是计数器、缓存、累积器。官方文档Lambda expressions — cppreferencemutable与「operator()的默认 cv 限定」一节5. C20 的模板 lambda[]class T(T x)auto参数虽然能推导但类型没有名字—— 想在函数体里声明一个同类型的变量、或者调用它的静态成员就只能用decltype(x)绕很别扭。C20 允许给 lambda 显式写模板参数列表位置在捕获列表和参数列表之间。下面这段需要C20// verify: stdc20#includecstdio#includestring#includetype_traitsintmain(){// 模板 lambda类型参数有名字函数体里能直接用它constautodescribe[]classT(T){ifconstexpr(std::is_integral_vT)return整数;elseifconstexpr(std::is_floating_point_vT)return浮点;elsereturn其它;};std::printf(int - %s\n,describe(1));std::printf(double - %s\n,describe(1.0));std::printf(char - %s\n,describe(c));// 有了类型参数就能声明同类型的局部变量 / 返回值constautomake_default[]classT(T){returnT{};};std::printf(默认 int %d\n,make_default(42));std::printf(默认 string 长度 %zu\n,make_default(std::string(x)).size());return0;}int - 整数 double - 浮点 char - 整数 默认 int 0 默认 string 长度 0T{}这一行就是模板 lambda 存在的理由只有拿到了类型名T才能写T{}。用泛型 lambda 的[ ](auto)想做到同样的事得写std::decay_tdecltype(x){}这种又长又容易被引用折叠搞错的表达式。两者怎么选能力泛型 lambdaC14模板 lambdaC20参数写法[](auto x)[]class T(T x)operator()的形态模板成员函数模板成员函数等价在函数体里命名参数类型不行只能decltype绕可以T直接可用声明同类型的局部变量 / 返回值啰嗦T x{}一行搞定支持参数包variadic不行可以[]class... Ts(Ts... xs)接受显式模板实参不行可以fint(3)从而挑选特定重载日常代码里 90% 的场景[](auto x)就够了一旦需要「拿到类型名」或者「处理参数包」就上模板 lambda。6. constexpr lambdaC17C17 起只要函数体满足constexpr的要求 lambda 会自动获得constexpr能力不需要任何关键字。无捕获的constexprlambda 可以在编译期求值直接喂给static_assert#includecstdiointmain(){// C17 起 lambda 可以是 constexpr函数体满足要求时自动获得该能力constexprautosquare[](intx){returnx*x;};static_assert(square(5)25,编译期就要算出来);constexprintksquare(6);std::printf(square(6) %d\n,k);return0;}square(6) 36static_assert(square(5) 25, ...)能编译通过本身就证明了这次调用发生在编译期否则static_assert的参数就不是常量表达式。这条能力在写「编译期查表」「编译期校验配置」这类代码时非常顺手 —— 不必再为一次性的小计算专门开个constexpr函数。官方文档constexpr specifier — cppreference性能视角的一个补充constexprlambda static_assert的组合让「错误在编译期暴露」等价于把一次运行时计算和它的测试一起搬到了编译期 —— 零运行时开销也不需要写单测去覆盖它。7. 完整示例带调用计数的泛型日志器把泛型参数和mutable状态合在一起一个能接任意类型、又能自己在闭包里数调用次数的日志器。#includecstdio#includestring#includevectornamespace{constexprintkIndent2;}intmain(){intcall_count0;// 泛型 lambda mutable既能接任意类型又能在闭包内部维护状态autolog[call_count](constautowhat)mutable{call_count;// 改的是闭包里的副本std::stringline(kIndent, );line[std::to_string(call_count)] what;returnline;};conststd::vectorstd::stringlines{log(std::string(启动)),log(std::string(加载配置)),log(std::string(连接数据库)),};for(constautol:lines)std::printf(%s\n,l.c_str());std::printf(外层 call_count %d\n,call_count);return0;}[1] 启动 [2] 加载配置 [3] 连接数据库 外层 call_count 0编译命令g -stdc17 -O2 -Wall generic_lambda.cpp -o generic_lambda。这段代码里值得拆开看的点mutable是必需的call_count改的是值捕获的成员operator()默认const会直接拒绝编译第 4 节那条报错。因为用了mutablelog必须是非 const 对象所以声明时不能写const auto log ...—— 这也是「mutable与const互斥」在调用点的直接体现。const auto what参数是刻意的这里只读不转移用const比auto 转发更直白也避免了模板实例化出多余的左值 / 右值两份operator()。std::vector的花括号初始化顺序是从左到右有保证的所以三次log调用严格按书写顺序执行计数才可能是 1、2、3。外层call_count始终是 0这正是「值捕获 mutable改副本」的语义不是 bug。要让外面也看到 3得改成引用捕获[call_count]代价是闭包不能再被拷贝出作用域见捕获列表那篇的悬垂引用。性能与体积这个泛型 lambda 只有std::string一种实参类型所以只实例化一份operator()如果让它同时服务 5 种类型就会有 5 份目标代码 —— 泛型的「零抽象成本」是拿代码体积换来的。全程只用标准库、没有裸new/delete、没有std::endl符合知识库的基调。8. 延伸阅读Lambda expressions — cppreference泛型 lambda、mutable、模板 lambda 的规范表述都在这一页std::forward — cppreference泛型 lambda 里做完美转发的正确写法constexpr specifier — cppreferenceconstexprlambda 的约束条件C17 起放宽std::is_integral — cppreference模板 lambda 里做类型分派的常用工具Compiler Explorer想确认泛型 lambda 到底实例化了几份operator()看-O2汇编最直观9. 一句话总结泛型 lambda 的auto参数会把operator()变成模板成员函数每个实参类型组合实例化一份所以能高效内联、也可能造成代码膨胀也因此天然支持autostd::forwarddecltype(x)的完美转发mutable存在的原因是lambda 的operator()默认带const加了它才能修改值捕获的成员但改的始终是闭包里的副本原变量不受影响且加了mutable后闭包就不能再被const对象调用想在函数体里拿到类型名或处理参数包用 C20 的[]class T(T x)想编译期算C17 起 lambda 自动就是constexpr。
返回列表