CPU眼里的:匿名函数 | Lambda | 捕获

CPU眼里的:匿名函数 | Lambda | 捕获

编码文章call10242025-05-15 16:51:375A+A-

匿名函数(也称Lambda函数)是C++中十分常用的编码方式,但你知道它背后的实现机制和工作原理吗?


01

提出问题

Lambda,也就是匿名函数,是C++和很多高级编程语言的宠儿。它们灵活多变的形态,让多少程序员感叹编程之美;又让多少程序员感叹C++的博大精深,不可琢磨。

特别是lambda逆天的捕获(capture)能力,更是让人叹为观止。除了多背语法规则,多多练习以外,还有什么更好的办法,来驯服这条让人爱恨交织的语法规则呢?


02

代码分析

这里让我们用CPU的视角,解读一下lambda,看看是否有既简单又清晰的分析思路?话不多说,打开Compiler Explorer,让我们编写一个世界上最简单的lambda测试函数test。

在里面定义一个lambda函数:lmd,它有一个参数a,做一个简单的赋值:a = 1;随后做一下函数lmd的调用。

void test()
{
    auto lmd = [](long a){ a = 1; };
    lmd(2);
}

看到了吗?有趣的事情发生了,如图所示。

虽然我们只定义了一个函数test,但编译器实际上为我们生成了2个函数。一个是我们编写的函数test,用来作lambda函数的调用;另一个是函数调用操作符,也就是lambda函数lmd的函数体。

从外表上看,函数调用操作符跟普通函数的路数,十分相似。所以,所谓的匿名函数,不过是编译器偷偷帮我们定义了一个函数而已。如果不出意外的话,这个lambda函数应该跟普通函数,并无区别。

让我们眼见为实,写一个类似的普通函数func,它也只有一个参数a,也只做最简单的赋值操作。

void func(long a)
{
    a = 1;
}

void test()
{
    auto lmd = [](long a){ a = 1; };
    lmd(2);
}

好了,让我们比较一下普通函数func和lambda函数lmd的CPU指令,如图所示。

结果还是不出意外的出现了意外,它们的指令居然不同!到底哪里出了问题?

让我们一起分析一下,如同文章“CPU眼里的:this”中关于this指针的实现一样,根据lambda函数lmd对应的对应CPU指令,我们可以看出:编译器给函数lmd偷偷夹带了一个隐藏参数!

让我们也为普通函数func,添加一个参数v。此时,如下图所示,此时普通函数func跟函数lmd对应的CPU指令,完全相同!两种函数的底层实现,是完全一致的。



当然,此时这个隐藏参数v,并没有实际意义,完全可以省略。但当代码再复杂一点的时候,它就不是可有可无的了。


03

值捕获

好了,让我们提高难度,作一下“捕获”,看看捕获是如何跨作用域,读、写变量的。定义一个函数test1,里面定了一个变量a;随后,定义一个lambda函数lmd1,并对变量a进行“值捕获”,也就是在中括号中加上a;最后,作一下函数lmd1的调用。如图所示。

跟刚才的情况类似,编译器还是给我们生成了两个函数。一个是我们编写的函数test1;一个是lambda函数对应的函数调用操作符。

与其用人类的语言,解释lambda函数是如何实现对变量a的“值捕获”,不如,用代码来解释代码。

让我们再写一个与lmd1等价的普通函数:func1。如图所示。

如你所见,两个函数对应的CPU指令完全相同。原来所谓的“值捕获”,跟普通的参数传递,并没有本质区别!

那为什么要传递指针呢?这个指针到底是谁的内存地址呢?答案要从函数func1的调用阶段说起。让我们一起完成函数func1的调用部分。

写一个调用函数call_func1,里面定义一个变量a;然后,再定义一个变量v,并把变量a的值,拷贝给变量v;随后,调用函数func1,会把变量v的地址和数值2,一起传递给函数func1。如图所示。

如你所见,函数call_func1和函数test1,所对应的CPU指令,完全相同!原来所谓的“值捕获”,会克隆一个变量a。

这里的克隆体,就是变量v,它的地址,将会被传递给lambda函数,因为二者的数值相同,就间接实现了:lambda函数,可以跨作用域的读取(不能写)外部变量a。

好了,让我们再从CPU的视角,执行一下这个调用过程。在没有lambda函数的情况,CPU和堆栈的状态如图的左侧所示。

堆栈中只存储着变量a的值:1,变量a所在的内存地址是0x8000。随着lambda函数的出现,栈顶向下移动,为隐藏变量v开辟了8字节的空间,并用来存储变量a的值:1。变量v所在的内存地址是0x7FF8。所以,可以把变量v看作是变量a的克隆体。

随后,调用lambda函数lmd1。变量v的内存地址:0x7FF8和数值2,就会被加载到CPU寄存器rdi和esi里面,以备函数lmd1使用。关于参数传递的细节,还可以参看文章“CPU眼里的参数传递”。

如上图的右侧所示,由于函数lmd1只能得到克隆体变量v的内存地址0x7FF8,并不是本体变量a的内存地址0x8000。所以,它只能读、写克隆体v,而不会影响到外部变量a。


04

引用捕获

那“引用捕获”又是如何实现的呢?答案是:换汤不换药。不同的是:此时隐藏变量v保存的不再是变量a的值,而是变量a的内存地址:0x8000,从堆栈内存上看,它们是这样的,如图所示(左侧是值捕获,右侧是引用捕获)

所以,为了在右侧的lambda函数lmd2中,读、写变量a,就需要对隐藏变量v,作两次指针的*操作。

第一次*操作,是从0x7FF8中读取变量a的内存地址:0x8000;第二次*操作,是从0x80000中读取变量a的值:1

这时,如果我们在函数lmd2中改变a的值,就会影响外部变量a的值,因为它和外部变量a一样,最终都会操作同一块内存地址:0x8000


05

总结

  • 对于不需要复用的简单函数,lambda函数是一个非常不错的选择。当然,lambda函数的实现,跟普通函数基本相同,只是,它是由编译器偷偷定义的,所以对程序员不可见。
  • “捕获”这个词,非常玄幻,甚至有点声东击西。如果不考虑字面意思,其本质上还是在作lambda函数的参数传递。但由于它让lambda函数获得了:跨作用域操作变量的能力,这让lambda函数显得比普通函数更加强大。
  • “值捕获”,会克隆一个被捕获的变量,并将克隆体的内存地址传递给lambda函数使用;而“引用捕获”会间接传递本体变量的内存地址,这使得lambda函数也可以读、写本体变量。
  • 所谓成也萧何,败也萧何;使用“引用捕获”的时候,需要对被引用变量的生命周期,有清晰的认识,否则一旦出现错误,就很难调试或查找问题的根源。


06

热点问题

Q1:Lambda的捕获能力,让程序员可以用更简洁的代码,实现更加复杂的功能。所以,它算是C++的语法糖吗?

A1:很好的总结。从实现的角度而言,用C语言也可以实现Lambda的捕获功能,但代码会比较啰嗦。说Lambda是语法糖,阿布觉得也没有什么不妥。但凡事都有两面性,简洁的代码固然好,但如果需要配合过于复杂的语法解释的话,这个语法糖可能就没有那么好吃了。

从另一个角度上看,Lambda可以减少代码的行数,但包含的Lambda的代码,往往涉及的元素(例如:函数参数)比较多,其可读性往往不如普通代码,需要开发者有一个适应过程。


Q2:之前有看到过一篇文章中介绍,编译器会使用类来实现Lambda表达式,例如:函数调用操作(运算)符,这种说法正确吗?

A2:这种说法是有道理的,甚至我也可以从本文的内容中,找到依据。例如编译器为Lambda函数生成的代码中,通常用类的函数操作(运算)符来标识,例如:test1()::{lambda(long)#1}::operator()(long) const。不过这更像是对同一件事情的不同描述。你可以认为这是在用类实现Lambda,也可以认为是在用普通C函数实现Lambda。

例如文章“CPU眼里的:class vs struct”,对于class的某些特性,用C++的语法可以解释清楚;用C语言的语法也可以解释清楚,甚至可以用对方的语言,实现自己的功能。

适当的抽象和使用概念,弄帮我们构建复杂的上层系统,帮助程序员之间的沟通、交流。但当我们站在底层的实现层面时,具体的CPU指令和行为对我们来说更加重要。如果在底层的实现层面参杂过多的抽象,可能把问题变得更加复杂,让编程走向玄学。


07

更多知识

如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》

<script type="text/javascript" src="//mp.toutiao.com/mp/agw/mass_profit/pc_product_promotions_js?item_id=7431929235222135337"></script>
点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4