现代C指南Lambda让我们用另一种方式持有函数仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下Github 一键直达: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP看看超酷的新网站https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/引言笔者在写排序逻辑的时候一直觉得 C 的函数指针和 C98 的仿函数都有点别扭。函数指针要么写在全局作用域里污染命名空间要么得用static成员函数配合void*上下文传来传去仿函数倒是可以把状态封装在类里但为了一个两行的比较逻辑去定义一个完整的类多少有点杀鸡用牛刀(哇, 最OOP的一集)。C11 带来了 lambda 表达式本质上就是一个可以在使用处就地定义的匿名函数对象——不用跳到文件头部去声明不用给编译器生成额外的符号逻辑就写在调用点旁边读代码的人一眼就能看明白。学习目标理解 lambda 表达式的语法要素和编译器背后的闭包类型掌握 lambda 与 STL 算法的配合使用了解值捕获和引用捕获的基本语义知道什么时候该用auto什么时候该用std::functionLambda 的语法拆解Lambda 表达式的完整语法看起来有点唬人但拆开来看每一部分都很直觉[capture](parameters)-return_type{body}capture是捕获列表决定了 lambda 怎么访问外层作用域的变量parameters和普通函数的参数列表完全一致- return_type是尾置返回类型在 C11 中需要满足特定条件才能省略让编译器推导详见下节body就是函数体。我们从一个最简单的 lambda 开始逐步加料// 什么都不做的 lambda,纯摆烂的autodo_nothing[](){};// 简单返回一个值autoforty_two[](){return42;};// 带参数autodouble_it[](intx){returnx*2;};// 实际使用像普通函数一样调用intresultdouble_it(21);// result 42你会发现 lambda 用auto来接收——这是因为每个 lambda 表达式都会生成一个独一无二的、没有名字的类类型所谓的闭包类型closure type你没办法直接写出这个类型的名字。auto在这里就是最自然的选择。返回类型推导C11 的 lambda 返回类型推导规则相对严格只有当 lambda 体满足以下条件时编译器才能自动推导返回类型函数体只有一条return语句或者所有return语句返回的表达式推导出相同类型满足这些条件时可以省略- return_type// 自动推导为 intautosquare[](intx){returnx*x;};// 自动推导为 double因为有 static_castdoubleautodivide[](inta,intb)-double{returnstatic_castdouble(a)/b;};如果函数体比较复杂比如有多个分支各自返回不同的路径编译器可能无法推导或者推导的结果和你预期不一致。这时候显式指定返回类型是最稳妥的做法autoclassify[](intx)-int{if(x0){returnx*2;}elseif(x0){return-x;}return0;// 如果没有这条某些编译器可能报警告};笔者的建议是简单 lambda 省略返回类型复杂 lambda 显式写出来。省略之后代码更紧凑但前提是别让读代码的人猜半天返回值到底是什么类型。作为 STL 算法参数——lambda 的主战场Lambda 表达式最常见的场景是作为 STL 算法的谓词或操作函数。以前你要么传一个全局函数指针要么写一个仿函数类现在直接在调用处写 lambda 就行了逻辑一目了然#includealgorithm#includevector#includeiostreamvoidprocess_data(){std::vectorintreadings{12,45,23,67,34,89,56};// 找出第一个超过阈值的读数autoitstd::find_if(readings.begin(),readings.end(),[](intvalue){returnvalue50;});// 统计有多少个异常值intanomaly_countstd::count_if(readings.begin(),readings.end(),[](intvalue){returnvalue80;});std::coutAnomalies: anomaly_count\n;// 原地翻倍std::transform(readings.begin(),readings.end(),readings.begin(),[](intvalue){returnvalue*2;});// 自定义排序降序std::sort(readings.begin(),readings.end(),[](inta,intb){returnab;});}以前你得把is_above_threshold()定义在别的地方读代码的时候要跳来跳去找定义。现在 lambda 就写在算法调用旁边扫一眼就知道这个谓词在干什么。捕获外部变量——让 lambda 看见外面默认情况下lambda 不能访问外层作用域的任何变量。这是有意为之的设计lambda 要的是一个干净的沙箱不会意外地触碰外部状态。当你确实需要访问外部变量时就通过捕获列表显式声明intthreshold50;// 编译错误threshold 不在 lambda 的作用域内// auto check [](int value) { return value threshold; };// 值捕获复制一份 threshold 到闭包对象中autoby_value[threshold](intvalue){returnvaluethreshold;};// 引用捕获直接引用外部的 thresholdautoby_ref[threshold](intvalue){returnvaluethreshold;};值捕获会在 lambda 创建的那一刻复制变量以后外部的修改不会影响 lambda 内部的副本引用捕获则让 lambda 直接操作原始变量。两种方式各有适用场景也有各自的坑——我们在下一章会专门展开讨论。这里只需要记住一点当你只读不写的时候值捕获是最安全的默认选择。常用的默认捕获写法也有两种[]表示值捕获所有用到的外部变量[]表示引用捕获所有用到的外部变量。用起来很方便但在生产代码中笔者建议尽量显式列出要捕获的变量名避免无意间捕获了不该捕获的东西。inta1,b2,c3;// 全值捕获autosum_all[](){returnabc;};// 6// 全引用捕获——可以修改外部变量autoincrement_all[](){a;b;c;};increment_all();// a2, b3, c4// 混合捕获a 值捕获b 引用捕获automixed[a,b](){returnab;};Lambda 的类型——闭包类型揭秘前面提过每个 lambda 表达式都会产生一个唯一的、匿名的类类型闭包类型。这个类类型有一个operator()成员函数参数和返回值就是你在 lambda 里写的那些。标准只规定了行为具体实现由编译器决定。概念上你可以把 lambda 理解为编译器生成了类似这样的类// 你写的 lambdaautogreet[](conststd::stringname)-std::string{returnHello, name;};// 编译器概念上生成的类简化版struct/* 编译器生成的唯一名字 */{std::stringoperator()(conststd::stringname)const{returnHello, name;}};autogreet/* 上面那个类的实例 */{};实际实现中编译器会根据 lambda 的捕获列表添加相应的数据成员根据mutable关键字决定operator()是否为 const。类型名称的生成方式由各编译器自行决定比如 GCC 用_Z...编码Clang 用$_0...等跨编译器也不保证一致。这就是为什么你没法直接写出 lambda 的类型名——这个名字是编译器内部生成的不同编译器、不同编译单元的名字都不一样。所以存放 lambda 的时候要么用auto编译期类型已知要么用std::function运行时类型擦除有额外开销。用模板参数传递 lambda 是零开销抽象的常见做法——编译器能看到完整的 lambda 类型有机会进行内联优化templatetypenameFuncvoidcall_func(Func f){f();}call_func([](){/* ... */});// 类型对编译器可见可能内联这里的关键是可能是否真的内联取决于编译器的优化策略、lambda 的复杂程度、编译选项等因素。但相比std::function的运行时间接调用模板参数至少给了编译器优化的机会。关于std::function的开销std::function内部使用了类型擦除和小对象优化Small Buffer Optimization, SBO。在 libstdc 中一个std::function对象通常占据 32 字节64位系统即使存储的 lambda 只需要 1 字节。调用时多一层虚函数风格的间接跳转可能阻止内联。如果不需要运行时多态优先用auto或模板参数。我们在第四章《类型擦除与 std::function》会深入展开。实战事件处理系统让我们用 lambda 来搭建一个简单的事件处理系统这在实际项目中是很常见的需求——注册回调、触发回调回调可能来自不同的模块各有各的上下文#includecstdint#includefunctional#includearray#includeiostreamclassEventDispatcher{public:usingHandlerstd::functionvoid(uint32_t);voidon_event(intid,Handler handler){if(id0idstatic_castint(handlers_.size())){handlers_[id]std::move(handler);}}voidtrigger(intid,uint32_ttimestamp){if(id0idstatic_castint(handlers_.size())handlers_[id]){handlers_[id](timestamp);}}private:std::arrayHandler,8handlers_;};// 使用示例voidsetup_system(){EventDispatcher dispatcher;intpress_count0;uint32_tlast_press_time0;// 注册按键回调引用捕获 press_count 和 last_press_timedispatcher.on_event(0,[](uint32_ttimestamp){if(timestamp-last_press_time50){// 50ms 防抖press_count;last_press_timetimestamp;std::coutPress #press_count at timestampms\n;}});// 注册超时回调值捕获 thresholduint32_tthreshold1000;dispatcher.on_event(1,[threshold](uint32_ttimestamp){if(timestampthreshold){std::coutTimeout at timestampms\n;}});// 模拟事件触发dispatcher.trigger(0,100);dispatcher.trigger(0,160);// 距上次 60ms通过防抖dispatcher.trigger(0,180);// 距上次 20ms被防抖过滤dispatcher.trigger(1,1200);}运行结果Press #1 at 100ms Press #2 at 160ms Timeout at 1200ms可以看到 lambda 作为回调非常自然——捕获列表把需要的上下文变量引进来函数体写业务逻辑注册的时候传进去就行了。比起 C 风格的void (*callback)(void* user_data)配合void*强转类型安全和可读性都好太多了。C14 的泛型 lambdaC14 给 lambda 带来了一个很实用的增强参数类型可以用auto。这让 lambda 变成了一个模板函数对象——编译器会为不同的参数类型各自生成一份operator()的实例// 泛型 lambda可以接受任何支持 operator 的类型autoadd[](autoa,autob){returnab;};intxiadd(3,4);// int operator(int, int)doublexdadd(3.5,2.5);// double operator(double, double)std::string xsadd(std::string(hello),std::string( world));编译器在背后生成的闭包类型大致长这样structGenericClosure{templatetypenameT1,typenameT2autooperator()(T1 a,T2 b)const{returnab;}};泛型 lambda 在写通用算法和工具函数的时候特别好用不需要在 lambda 外面再套一层模板函数了。这块我们会在第三章《泛型 Lambda 与模板 Lambda》里深入探讨。注意事项与踩坑预警Lambda 体不要太长Lambda 的优势在于就地定义、逻辑紧凑。如果一个 lambda 超过 5-7 行就该考虑把它提取成一个命名函数或者仿函数了。超过这个长度的 lambda 反而会降低可读性——读代码的人要在算法调用的参数列表里滚动好几屏这就违背了逻辑在使用处的初衷。引用捕获的生命周期陷阱这是 lambda 最常见的 bug 来源之一引用捕获的变量在 lambda 执行时已经销毁了。典型的场景是在函数内创建 lambda 并返回它// 危险返回的 lambda 引用了局部变量 localautomake_bad_lambda(){intlocal42;return[local](){returnlocal;};// local 在函数返回后销毁}// 安全值捕获automake_safe_lambda(){intlocal42;return[local](){returnlocal;};// lambda 持有副本}引用捕获本身没有错但你必须保证被引用的对象活得比 lambda 久。在事件系统、异步回调这类场景中这个约束特别容易被忽视。优先用auto而非std::function存储 lambda除非你需要运行时多态比如把不同类型的回调放进同一个容器否则不要用std::function来存储 lambda。auto直接持有闭包类型类型大小等于捕获的数据成员大小无捕获的 lambda 通常只有 1 字节给编译器提供了内联优化的机会std::function做了类型擦除固定开销32-64 字节调用时多一层间接跳转。// 编译期类型已知大小1字节无捕获可能内联autof[](intx){returnx*2;};// 类型擦除大小32字节libstdc运行时间接调用std::functionint(int)g[](intx){returnx*2;};这个差异在性能关键路径上可能很重要但也要避免过早优化如果代码不是热点路径std::function的便利性可能更重要。小结Lambda 表达式是现代 C 中最实用的特性之一。它把在使用处定义函数这件事件的成本降到了最低——不需要额外的命名、不需要类定义、不需要分离声明和实现。核心要点回顾Lambda 的语法是[capture](params) - ret { body }大部分时候可以省略返回类型Lambda 的类型是编译器生成的唯一闭包类型用auto存储最自然Lambda 最大的用武之地是作为 STL 算法的谓词和操作参数值捕获复制变量、引用捕获引用变量各有各的安全边界C14 的auto参数让 lambda 变成了模板函数对象下一章我们深入讨论 lambda 的捕获机制——值捕获和引用捕获在底层到底发生了什么C14 的初始化捕获解决了什么问题以及那些让你凌晨两点还在 debug 的捕获陷阱。参考资源Lambda expressions (C11) - cppreferenceC14 generic lambdas - cppreference相关阅读通用GUI编程技术——图形渲染实战五十——命中测试与鼠标事件路由精确交互 - 相似度 82%Linux 工作队列把中断里做不了的事推迟到进程上下文 - 相似度 82%2.2 坐标系与 QTransform 变换基础 - 相似度 71%