Re:思考·重建·记录 现代C++ C++11篇 (四)C++ Lambda 全解析:编译器是如何为你生成仿函数的?


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️现代C++系列个人专栏: 插曲:现代C++
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

这里是此方,好久不见。 本专栏是【主题曲:C++程序设计 】专栏的补充篇【插曲:现代C++ 】。本系列将优先深度解析C++11标准,力求内容详实,无微不至。C++14~C++20的进阶内容将在后续间隔一段时间后连载。本期将重点讲解:lambda以及常见的运用场景和底层原理等内容.好的,让我们现在开始吧。

一,lambda表达式的概念

1.1概念补充与回顾:仿函数与函数对象

  • 仿函数 :我们之前讲过这个概念,仿函数在这篇文章的6.1.1章节,仿函数就是一个重载了operator()的类。
  • 函数对象 :你可以这么理解,就是仿函数的实例化对象。

1.2lambda表达式的格式

cpp 复制代码
[capture-list] (parameters) -> return type { function body }
  • [capture-list]:捕捉列表 ,该列表总是出现在 lambda 函数的开始位置,编译器根据 [] 来判断接下来的代码是否为 lambda 函数,捕捉列表能够捕捉上下文中的变量供 lambda 函数使用,捕捉列表可以传值和传引用捕捉 ,具体细节1.3中我们再细讲。捕捉列表为空也不能省略。
  • (parameters):参数列表 ,与普通函数的参数列表功能类似,如果不需要参数传递,则可以连同 () 一起省略。
  • ->return type:返回值类型 ,用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。一般返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
  • {function body}:函数体 ,函数体内的实现跟普通函数完全类似,在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量函数体为空也不能省略

1.3lambda表达式的捕捉列表

1.3.1捕捉列表的意义

lambda 表达式中默认只能用 lambda 函数体和参数列表 中的变量,如果想用外层作用域中的变量就需要进行捕捉。

1.3.2捕捉列表的捕捉方式

第一种捕捉方式是在捕捉列表中显示的传值捕捉和传引用捕捉 ,捕捉的多个变量用逗号分割。[x, y, &z] 表示 x 和 y 值捕捉,z 引用捕捉。值捕捉的变量不能修改,引用捕捉的变量可以修改。(引用捕捉可以看作是一个别名,内部修改会影响外部。)

cpp 复制代码
int main(){
    int a = 0, b = 1, c = 2, d = 3;
    auto func1 = [a, &b]
    {
        // 值捕捉的变量不能修改,引用捕捉的变量可以修改
        //a++;
        b++;
        int ret = a + b;
        return ret;
    };
}

第二种捕捉方式是在捕捉列表中隐式捕捉 ,我们在捕捉列表写一个 = 表示隐式值捕捉,在捕捉列表写一个 & 表示隐式引用捕捉, 这样我们 lambda 表达式中用了那些变量,编译器就会自动捕捉那些变量。

cpp 复制代码
int main
{
	int a = 0, b = 1, c = 2, d = 3;
	auto func2 = [=]
    {
      	  int ret = a + b + c;
    	  return ret;
   	 };
    // 隐式引用捕捉
    // 用了哪些变量就捕捉哪些变量
    auto func3 = [&]
    {
        a++;
        c++;
        d++;
    };
}

第三种捕捉方式是在捕捉列表中混合使用隐式捕捉和显示捕捉。[=, &x] 表示其他变量隐式值捕捉,x 引用捕捉;[&, x, y] 表示其他变量引用捕捉,x 和 y 值捕捉。

cpp 复制代码
int main{
	int a = 0, b = 1, c = 2, d = 3;
	// 混合捕捉1
    auto func4 = [&, a, b]{
        //a++;
        //b++;
        c++;
        d++;
        return a + b + c + d;
    };
    // 混合捕捉2
    auto func5 = [=, &a, &b]{
        a++;
        b++;
        //c++;
        //d++;
        return a + b + c + d;
    };
}

混合捕捉注意事项:

  • 第一个元素必须是 & 或 =
  • 使用 & 混合捕捉时,后面的捕捉变量必须是值捕捉,同理 = 混合捕捉时,后面的捕捉变量必须是引用捕捉。

1.3.3lambda捕捉列表的其他注意事项

lambda 表达式如果在函数局部域中:

  • 可以捕捉 lambda 位置之前定义的变量
  • 不能捕捉静态局部变量和全局变量
  • 静态局部变量和全局变量也不需要捕捉,lambda 表达式中可以直接使用。 这也意味着 lambda 表达式如果定义在全局位置,捕捉列表必须为空。
  • 不建议在全局位置定义lambda,平白无故增加全局变量在多线程中是不安全的。
cpp 复制代码
static int m = 0;
int main(){
	int x =0;
	auto func6 = []
	{
    	int ret = x + m;
    	return ret;
	};
	return 0;
}

默认情况下,lambda 捕捉列表是被 const 修饰的,也就是说传值捕捉的过来的对象不能修改mutable 加在参数列表的后面可以取消其常量性,也就是说使用该修饰符后,传值捕捉的对象就可以修改了,但是修改还是形参对象,不会影响实参 。使用该修饰符后,参数列表不可省略(即使参数为空)。

cpp 复制代码
auto func7 = [=]() mutable{
    a++;
    b++;
    c++;
    d++;
    return a + b + c + d;
};

二,lambda表达式的应用与由来

2.1lambda表达式的由来

凌晨一点,你坐在工位前,接手了一个陈年老项目。当你准备优化一段排序逻辑时,屏幕上赫然出现了几行像加密电报一样的代码:

cpp 复制代码
// 价格升序
//sort(v.begin(), v.end(), Compare1());
// 价格降序
//sort(v.begin(), v.end(), Compare2());

你的眉头拧成了麻花。为了搞清楚 Compare1 到底按什么排序,你开始

  • Ctrl + 左键追踪,发现它在一个叫 CommonUtils.h 的文件里。
  • 点开 CommonUtils.h,发现里面套了另一个 SortTraits.h。

在翻过五六个文件、几千行代码后,你终于在一个犄角旮旯里找到了它------原来只是个简单的 return a.price < b.price。

恼火之余你决定用它给这段代码做一次大调整:

cpp 复制代码
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
    return g1._price < g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
    return g1._price > g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
    return g1._evaluate < g2._evaluate;
});

这样就干净很多。可读性也一下子上来了。

于是我想讲的就是:Lambda 表达式的出现,本质上是编程范式的进步。它解决了一个令程序员抓狂的问题:

逻辑不再"远在天边",而是"近在眼前"。这种就近原则极大地提升了代码的可读性。

2.2lambda的应用

lambda 在很多其他地方用起来也很好用。比如线程中定义线程的执行函数逻辑 ,智能指针中定制删除器等,lambda 的应用还是很广泛的,以后我们会不断接触到。

三,lambda表达式的原理

3.1lambda表达式的真相------编译器帮你写了一个仿函数

lambda 表达式 本质是一个匿名函数对象,跟普通函数不同的是他可以定义在函数内部。

cpp 复制代码
auto cmp = [](int a, int b) { return a > b; };

这行代码,编译器在背后生成了一个匿名的仿函数,大致等价于:

cpp 复制代码
class __lambda_1 {
public:
    bool operator()(int a, int b) const {
        return a > b;
    }
};
auto cmp = __lambda_1{};

没错,lambda就是函数对象的语法糖。编译器把lambda体变成 operator() 的函数体 ,捕获的变量变成类的成员变量。有捕获的时候更清楚:

cpp 复制代码
int threshold = 10;
auto gt = [threshold](int x) { return x > threshold; };

编译器生成的东西大致是这样:

cpp 复制代码
class __lambda_2 {
public:
    __lambda_2(int threshold) : threshold_(threshold) {}

    bool operator()(int x) const {
        return x > threshold_;
    }

private:
    int threshold_;
};

auto gt = __lambda_2{threshold};

值捕获就是把变量拷贝进成员变量,引用捕获就是存一个引用(实际实现通常是指针 )。

这也解释了一个经常被问到的问题:为什么lambda默认不能修改捕获的变量?

cpp 复制代码
int count = 0;
auto counter = [count]() {
    count++;  // 编译错误!
};

因为 operator() 默认是 const 的。const 成员函数改不了成员变量。想改?加 mutable:

cpp 复制代码
auto counter = [count]() mutable {
//lambda 表达式语法使用层而言没有类型,所以我们一般是用 auto 或者模板参数定义的对象去接收 lambda 对象。
    count++;  // OK,但改的是Lambda自己的副本,外面的count不变
};

加了 mutable 之后,编译器生成的 operator() 就没有 const 修饰了。

理解了"lambda就是函数对象"这一点,很多事情就说得通了。lambda传给STL算法,跟传函数对象给STL算法,在编译器看来是一回事------模板会为每个lambda生成一个独立的类型(每个lambda的匿名类都不同),所以每个lambda传给 std::sort 都会实例化一个独立的 std::sort 版本。

3.2从汇编层看lambda的原理

lambda 的原理和范围for很像,编译后从汇编指令层的角度看,压根就没有 lambda 和范围for这样的东西

  • 范围for底层是迭代器。
  • 而lambda底层是仿函数对象。

仿函数的类名是编译按一定规则生成的,保证不同的 lambda 生成的类名不同,lambda参数/返回类型/函数体就是仿函数operator()的参数/返回类型/函数体 ,lambda 的捕捉列表本质是生成的仿函数类的成员变量,也就是说捕捉列表的变量都是 lambda 类构造函数的实参,当然隐式捕捉,编译器要看使用哪些就传那些对象。

上面讲一个大概的原理,前文也有所了解,我们可以透过汇编层深入探讨 :

Tips:补充内容:UUID通用唯一识别码

在计算机科学中,UUID(Universally Unique Identifier)是指通用唯一识别码。

在 C++ 中,由于 Lambda 是匿名的,你没给它起名,但编译器在生成机器码时必须给它起个名字(否则链接器找不到代码) 。为了保证这个名字在全世界(整个项目)都是唯一的,编译器会根据一套复杂的算法生成一个类似 UUID 的字符串。

3.2.1lambda的类型

  • 编译期: 生成 闭包类。这是那个带有"UUID"名字的类型模具。
  • 运行期: 生成 闭包对象。这是根据模具压出来的、真实占用内存的实例。
cpp 复制代码
int main(){
	auto r2 = [rate](double money, int year) {
	return money * rate * year;
	};
	return 0;
}

我们将以上的代码转到反汇编:

Bash 复制代码
构造lambda的过程:
auto r2 = [rate](double money, int year) {
return money * rate * year;
};
00FF797C lea eax, [rate]              取得rate的地址并放入寄存器
00FF797F push eax                 寄存器压栈,给构造函数传递参数
00FF7980 lea ecx, [r2]              
00FF7983 call <lambda 866f4ab3de58d52bdf7ca71fcb86bdf4>::<lambda 866f4ab3de...  调用构造函数

看到上面的汇编代码,lambda 866f4ab3de58d52bdf7ca71fcb86bdf4这就是编译器用uuid为你生成的哪个lambda类的类型名称。

3.2.2lambda的底层调用

cpp 复制代码
class Rate{
public:
    Rate(double rate)
        : _rate(rate){}
    double operator()(double money, int year){ return money * _rate * year;}
private:
    double _rate;
};
int main(){
    double rate = 0.49;
    // lambda
    auto r2 = [rate](double money, int year) {
        return money * rate * year;
    };
    // 函数对象
    Rate r1(rate);
    r1(10000, 2);
    r2(10000, 2);
    auto func1 = [] {
        cout << "hello world" << endl;
    };
    func1();
    return 0;
}

以上代码转反汇编:

1.调用仿函数和调用lambda 的构造底层是一致的。

2.调用两个不同的lambda他们的类型是不同的,主要体现在uuid的不同。

3.隐式捕捉不会全部给你捕捉过去,用什么捕捉什么。

所以回归本质,就是编译器生成了一个仿函数的类,调用lambda的本质是调用这个仿函数的operator ()

四,lambda的其他补充

为了不影响整体阅读体验,同时又有很多细节想要讲的,这里统一放在最后:

  1. 不建议将lambda设计的过于复杂,太复杂了建议转成函数,用函数指针调用。
  2. 变量捕捉必须向上查找。这种就不行:
cpp 复制代码
auto func1 = [&a, &b](int x)
{
    // 值捕捉的变量不能修改,引用捕捉的变量
    a++;
    b++;
    int ret = a + b + x;
    return ret;
};
int a = 0, b = 1, c = 2, d = 3;
  1. 同一个变量不能捕捉两次,如下图

  2. lambda不能递归,这是一个先有鸡还是现有蛋的问题:到底是从左往右定义还是从右往左定义是不能确定的。

cpp 复制代码
auto func4 = [&, a, b]   {
    //a++;
    //b++;
    c++;
    d++;
    func4();            // 红框标出,下方有波浪线(表示递归调用错误)
    return a + b + c + d;
};

好了,本期内容就到这里,我是此方,我们下期再见。じゃあね〜

相关推荐
eqwaak02 小时前
4 月技术快讯|Rust 1.90 正式发布,系统级开发再进化
开发语言·后端·rust
Brilliantwxx2 小时前
【C++】初认识模版
开发语言·c++
c++之路2 小时前
C++ 命名空间(Namespace)
开发语言·c++·算法
艾莉丝努力练剑3 小时前
【Linux网络】计算机网络入门:Socket编程预备,从字节序共识到 Socket 地址结构的“伪多态”设计
linux·服务器·网络·c++·学习·计算机网络
2501_933329559 小时前
媒介宣发技术实践:Infoseek舆情系统的AI中台架构与应用解析
开发语言·人工智能·架构·数据库开发
[J] 一坚10 小时前
嵌入式高手C
c语言·开发语言·stm32·单片机·mcu·51单片机·iot
odoo中国10 小时前
Odoo 19技术教程 : 如何在 Odoo 19 中创建 Many2one 组件
开发语言·odoo·odoo19·odoo技术·many2one
借雨醉东风10 小时前
程序分享--常见算法/编程面试题:旋转矩阵
c++·线性代数·算法·面试·职场和发展·矩阵
逻辑驱动的ken10 小时前
Java高频面试考点场景题14
java·开发语言·深度学习·面试·职场和发展·求职招聘·春招