try ai
科普
编辑
分享
反馈
  • 闭包

闭包

SciencePedia玻尔百科
核心要点
  • 闭包是一个函数及其周围状态(词法环境)引用的捆绑包,这使得它即使在其他地方执行,也能够访问其创建作用域中的变量。
  • 闭包通常捕获变量的内存位置,而非其瞬时值,这在循环等场景下可能导致与直觉相悖的结果。
  • 为了支持生命周期超过其定义函数的闭包,编译器会使用逃逸分析,将被捕获的变量分配在堆上,而不是临时的调用栈上。
  • 现代编译器采用环境切片和存活分析等复杂的优化技术,以确保闭包的内存效率和高性能。

引言

在编程中,函数通常是一个自包含的代码块。但如果一个函数能够记住它被创建时的环境呢?这就是闭包背后的核心思想:一个打包了其词法作用域记忆的函数。这个看似简单的概念是现代编程语言中最强大、最优雅的特性之一,但它挑战了我们关于内存、时间和作用域如何工作的基本直觉。本文将通过探索赋予闭包生命的精妙机制,来揭开其神秘面纱。

为此,我们将首先深入探讨闭包的核心“原则与机制”。该部分将解释闭包如何通过词法作用域“记住”变量,为何它们捕获的是变量的位置而非值,以及系统如何通过允许被捕获的变量从调用栈“逃逸”到堆上来管理内存。随后,“应用与跨学科联系”部分将探讨实际的工程挑战与解决方案。我们将看到编译器如何为提高效率而优化闭包,如何使闭包能与其他语言系统交互,以及如何处理与即时编译和协程等特性的复杂互动,从而揭示编程理论与实践之间深刻的相互作用。

原则与机制

从本质上讲,计算机程序是一系列指令。在这种视角下,函数是一个可重用的子序列,一个我们可以随意调用的命名“配方”。但如果一个配方能够记住它被写下的厨房呢?如果它携带着台面上香料的气味以及它诞生那天烤箱的余温呢?这就是​​闭包​​的精髓:它不仅仅是一个函数,而是一个与其创建时所处环境绑定的函数。它是一个包含待执行代码及其诞生环境记忆的包。

这个看似简单的想法——一个带有记忆的函数——是现代编程中最强大的概念之一。但它的实现揭示了一系列精妙而微妙的机制,挑战了我们关于程序如何运行,尤其是在时间、内存和身份方面最简单的直觉。

记忆的本质:位置 vs. 值

让我们从一个思想实验开始,探究“记住”的真正含义。假设我们有一个变量,称之为 xxx,并将其值设为 333。现在,我们定义一个函数 inc,其功能是返回 x+1x + 1x+1 的值。这个函数 inc 是一个闭包,因为它的函数体引用了 xxx,而 xxx 并非其自身的参数,而是存在于其周围的​​词法​​环境中。现在转折来了:在我们定义了 inc 之后,但在调用它之前,我们将 xxx 的值改为 777。最后,我们调用 inc()。它会返回什么?是返回 444(因为创建它时 xxx 是 333)?还是返回 888(因为现在执行时 xxx 是 777)?

在大多数现代语言中,答案是 888。这可能看起来令人惊讶,但它揭示了关于闭包工作原理的一个深刻真理。闭包通常不会在创建时捕获其周围变量值的快照。相反,它捕获的是变量本身——或者更准确地说,是它们在内存中的​​位置​​。

为了具体说明,我们可以将计算机的内存看作一个由两部分组成的系统。一部分是​​环境​​,它就像一个地址簿,将变量名(如 $x$)映射到内存位置(如 location_123)。另一部分是​​存储​​,即内存本身,它将这些位置映射到它们当前的值(例如,location_123 持有值 777)。

当我们的闭包 inc 被创建时,它捕获了那一刻的环境。在那个环境中,名称 $x$ 被映射到 location_123。该闭包实质上持有一个指向该特定内存位置的引用(即指针)。它并不关心最初那里存的值是 333。后来,当我们把 $x$ 重新赋值为 777 时,我们没有改变地址簿,而是改变了 location_123 处的内容。当我们最终调用 inc() 时,它使用其捕获的环境来查找 $x$,找到了 location_123,并读取存储在那里的当前值,即 777。然后它计算 7+17 + 17+1 并返回 888。

这个原则被称为​​词法作用域​​(或静态作用域)。“词法”部分意味着变量的含义由函数在源代码中书写的位置决定,而不是由它被调用的位置决定。inc 函数永远与其诞生地的 $x$ 绑定。即使我们在另一个拥有自己值为 100100100 的局部变量 $x$ 的函数内部调用 inc,我们的闭包 inc 也会忽略它。它忠于其原始环境,查找自己捕获的 $x$,并仍然返回 888。这种可预测的行为是现代语言设计的基石。

意外的循环陷阱

这种按位置捕获的机制功能强大,但它也会导致一个著名且富有启发性的陷阱。考虑一个循环三次的程序,循环计数器变量 iii 从 000 变为 222。在每次迭代中,我们创建一个函数,该函数应该打印出该次特定迭代中 iii 的值。我们将这三个函数存储在一个数组中,并且只有在循环完全结束之后,我们才逐一执行它们。

我们期望得到什么?我们希望第一个函数打印 000,第二个打印 111,第三个打印 222。

实际发生了什么?它们全都打印出 222。为什么?

这背后是同样的原理。循环使用单个变量 iii,它占据单个内存位置。在第一次迭代(i=0i=0i=0)中,我们创建了一个捕获 iii 位置的闭包。在第二次迭代(i=1i=1i=1)中,同一位置的值被更新为 111,我们创建了另一个捕获完全相同位置的闭包。i=2i=2i=2 时也是如此。循环结束后,iii 位置上的值是 222。当我们最终执行存储的三个闭包时,每一个都忠实地沿着它的引用回到那个单一、共享的位置,并读取其最终值:222。

这是一个经典的例子,展示了开发者的意图(为每次迭代捕获 iii 的值)与默认机制(捕获变量 iii 的位置)之间的差异。那么,语言是如何修正这一点以符合我们的直觉呢?它们在编译期间采用了一些巧妙的策略:

  1. ​​隐式复制​​:现代语言中最常见的解决方案是让编译器检测到这种特定情况。当它看到在循环内部创建闭包并捕获循环变量时,它会隐式地改变程序的语义。在幕后,对于循环的每次迭代,它都会创建变量 iii 的一个全新的、私有的副本。在该次迭代中创建的闭包随后会捕获这个新的、私有副本的位置。由于这个新位置再也不会被修改,它实际上为该闭包冻结了 iii 的值。

  2. ​​按值捕获​​:一些语言提供语法来显式请求“按值捕获”。这会指示闭包在创建时获取变量值的快照并将其存储在内部,而不是捕获其内存位置。

两种策略都达到了相同的目标:它们确保每个闭包都获得自己独特的变量版本,从而保留了其创建时刻的值,并满足了我们的直觉期望。

逃离调用栈

我们已经看到闭包可以持有对变量的引用。但这引出了一个关于内存本身的更深层次问题。在一个简单的程序中,函数调用由一种称为​​调用栈​​的结构管理。可以把它想象成一叠盘子。当一个函数被调用时,一个新的盘子(一个​​激活记录​​)被放在最上面。这个盘子存放了该函数所有的局部变量。当函数返回时,它的盘子被移走,其所有局部变量也随之销毁。这是一个简单、高效的后进先出(LIFO)过程。

但是,如果一个函数创建了一个闭包,捕获了它的一个局部变量,然后返回了这个闭包,会发生什么呢?

loading

根据简单的栈模型,当 make_counter 返回时,它的盘子——包含变量 count——应该被销毁。但返回的 counter 闭包仍然需要 count 来完成它的工作!如果 count 变量被销毁,闭包将持有一个指向无效内存的“悬垂引用”,调用 counter() 将导致程序崩溃。这被称为​​向上 funarg 问题​​。

解决方案是深刻的:被可能比当前函数调用活得更久的闭包所捕获的变量,不能存储在栈上。它们必须​​逃逸​​。编译器会执行所谓的​​逃逸分析​​来检测这种情况。如果编译器确定一个变量的生命周期必须超出其函数的激活记录,它就会将该变量分配在​​堆​​上,而不是栈上。

堆是另一种不同的内存——一个巨大的动态区域,数据可以在其中拥有更长的生命周期。堆上的对象不会在函数返回时被销毁。只要程序中至少还有一个对它的引用,它就会一直存在。一个称为​​垃圾回收器 (GC)​​ 的系统会定期扫描堆,找到不再可达的对象,并回收它们的内存。

因此,在我们的 make_counter 例子中,编译器看到变量 count 被一个从函数返回的闭包捕获了。它“逃逸”了。因此,count 被分配在堆上。当 make_counter 返回时,它的栈帧被弹出,但 count 变量在堆中继续存在,被 counter 闭包安全地引用着。作用域框架的生命周期与调用栈的后进先出规则脱钩,转而由堆的可达性来决定。

相反,如果一个闭包仅在其定义函数内部创建和使用,并且从不“逃逸”,一个智能的编译器可以证明这一点。它会将捕获的变量保留在栈上以实现最高效率,从而避免堆分配和垃圾回收的开销。

词法作用域、内存位置、调用栈和堆之间的这种美妙互动,正是闭包力量的源泉。它们似乎神奇地扭曲了时间和内存的规则,但它们遵循的是一套一致而优雅的底层原则。它们证明了这样一个事实:在计算机科学中,如同在物理学中一样,一些最强大和最具表现力的现象源于少数简单、基本规则之间出人意料的相互作用。

应用与跨学科联系

掌握了闭包的原理——一个能记住其诞生环境的函数——我们可能会以为大功告成。但这恰恰是真正旅程的开始。抽象概念是一回事,在真实计算机这个具体而混乱的世界中将其实现则是另一回事。闭包的真正美妙之处,就像物理学中任何深刻的思想一样,不仅体现在其定义中,更在于它如何与周围世界互动、挑战并塑造之。我们将看到,这一个概念如何迫使我们成为聪明的工程师、细致的会计师,甚至是对于“何事可知、何时可知”持谨慎态度的哲学家。

体现的艺术:让闭包成为现实

第一个挑战是纯粹实践性的:如何在一台为运行更简单任务而构建的机器上表示一个闭包?计算机的处理器知道如何跳转到函数的代码,但这段代码拥有“记忆”或“环境”的想法对它来说是完全陌生的。从本质上讲,闭包是一个由两部分组成的实体:一个指向待执行代码的指针 pcodep_{\mathrm{code}}pcode​,以及一个指向其捕获变量环境的指针 penvp_{\mathrm{env}}penv​。

这在与庞大的现有代码生态系统(其中大部分使用 C 语言)交互时立即产生了一个问题。应用程序二进制接口(ABI)是一套严格的规则,用于规定函数之间如何相互调用——如何传递参数,参数放在哪里(寄存器或栈上),以及如何返回结果。这些规则没有为传递一个“额外”的环境指针做出规定。我们不能简单地改变规则,否则我们的语言将无法与任何其他语言对话。

解决方案是一个精妙的工程巧计。当调用一个闭包时,调用者完全按照 ABI 的规定传递函数的可见参数。但它同时通过一个“秘密通道”——一个为此目的预留的专用 CPU 寄存器——来传递环境指针 penvp_{\mathrm{env}}penv​。由我们的语言编译的函数知道在这个特殊寄存器中寻找其环境。而一个标准的 C 函数,对这个约定一无所知,会简单地忽略该寄存器或将其视为一个可被覆盖的临时值,根据 ABI 对这类寄存器的规定,这样做是完全没问题的。这使得闭包能够与 C 世界无缝互操作;它们遵守公共契约,同时利用私有约定来实现其更强大的语义。这是一个优雅的技巧,证明了美丽的抽象是如何通过巧妙、务实的工程得以实现的。

追求完美:精简与高效的闭包

让闭包能工作只是第一步,接下来是让它们变得高效。一个简陋的实现可能极其浪费,导致程序运行缓慢且耗费大量内存。编译器设计的艺术在很大程度上就是优化的艺术,而闭包为此艺术提供了丰富的画布。

极简主义者的环境

当一个函数创建闭包时,这个闭包究竟应该记住什么?一个简单的方法是让它捕获其父函数的整个“世界”——即每一个局部变量,无论它是否需要。这就像为了周末旅行而把整个房子都装上卡车一样。虽然简单,但效率极低。

一个智能的编译器则像一个有眼光的打包者。通过一个称为静态分析的过程,它会检查闭包的函数体,并确定它实际使用的自由变量的精确集合。然后,它创建一个仅包含那些变量的环境。这项技术被称为“环境切片”,可以显著减少每个闭包的内存占用,特别是当创建函数有许多局部变量而闭包只需要一两个时。

我们还可以进一步完善这一点。编译器可以执行“存活分析”来追踪一个变量的值在某个点之后是否还需要。如果一个变量是“死的”——意味着它的当前值将永远不会再被读取——就没有理由将其包含在闭包的环境中,即使闭包的文本引用了它。通过只捕获存活的变量,编译器确保闭包的环境不仅小,而且只包含真正有用的信息。这将闭包从一个内存囤积者转变为效率的典范。

栈上的家园还是堆上的人生?

捕获什么和存储在哪里同样重要。程序的内存通常分为两个主要区域:栈和堆。栈是一个高效、有序的区域,用于存放生命周期短且可预测的数据——即在函数调用时创建、在函数返回时销毁的数据。堆则是一个更灵活但速度较慢的区域,用于存放需要存活未知或较长时间的数据。

闭包的环境提出了一个关键问题:它应该分配在快速的栈上,还是持久的堆上?答案完全取决于闭包自身的生命周期。如果编译器能够证明一个闭包只会在其父函数的执行期间被使用——即它永远不会被返回、传递给另一个线程或存储在长生命周期的数据结构中——那么它就没有“逃逸”出其词法作用域。对于这样一个非逃逸的闭包,其环境可以安全且高效地分配在栈上。

然而,如果闭包可能比其父函数活得更久——如果它是其作用域的一个“逃犯”——那么它的环境就必须分配在堆上。否则,父函数返回后,其栈帧将被清空,闭包将留下一个指向垃圾数据的环境指针。编译器通过一种称为“逃逸分析”的技术来区分这两种情况的能力,是函数式语言最重要的优化之一。它使得短生命周期的闭包几乎没有成本,同时确保了其长生命周期同类的正确性 [@problem_-id:3627870]。

宏大的交响乐:协同工作的闭包

闭包并非存在于真空中。当它们与现代编程语言的其他高级特性相互作用时,其真正的力量和复杂性才会显现出来,而且往往是以令人惊讶和深刻的方式。

暂停时间世界中的闭包

考虑一个有协程的世界——协程是一种可以在执行中途暂停并在稍后恢复的函数。一个协程可以创建一个闭包,然后暂停,将该闭包交给程序的另一部分。当协程被挂起时,它的栈——即其整个局部状态——在时间上被冻结了。此时,闭包持有一个指向这个被挂起现实的引用。

这带来了一系列有趣的挑战。只要协程仅仅是挂起状态,这个引用就是安全的。但如果协程最终被终止了呢?它的栈将被释放,闭包将持有一个指向虚空的“悬垂指针”。此外,如果被捕获的变量是可变的呢?闭包可能会修改它,而当协程恢复时,它必须能看到那个被修改过的值。

这迫使编译器为每个被捕获的变量做出一个复杂的、依赖于上下文的选择。如果变量是不可变的且闭包会逃逸,它的值可以直接被复制。如果变量是可变的但闭包被证明不会比其创建者活得更久,那么直接引用父函数的栈是安全且高效的。但如果变量是可变的且闭包可能会逃逸,那么只有一个安全选项:该变量必须被“提升”到堆上的一个共享位置,供闭包及其父协程共同访问。这种在复制、引用和提升之间的谨慎舞蹈是现代并发系统中内存安全的核心,也是闭包迫使我们必须明确解决的问题。

再现魔法:闭包与JIT

在对性能的不懈追求中,即时(JIT)编译器执行着令人难以置信的动态优化。当程序运行时,JIT 可能会识别出一个“热点”闭包,并将其重新编译成超优化的机器码。在此过程中,闭包的结构本身可能会被分解。它的环境可能被拆散,被捕获的变量直接存放在 CPU 寄存器中,其值为了追求原始速度而从安全的对象容器中“拆箱”。

这种优化后的状态速度快但很脆弱。如果 JIT 的假设被证明是错误的(例如,一个它假设为整数的变量突然收到了一个字符串),它必须触发一次“去优化”,立即回退到更安全的、未经优化的代码版本。在这一刻,它必须上演一个魔术:从其分散的、优化过的部分中完美地重构出原始的闭包。

为此,JIT 依赖于元数据——一份它在去优化点保存的“配方”。这份配方详细记录了原始环境的每个部分现在位于何处(例如,“变量 xxx 当前作为未拆箱的整数位于 EAX 寄存器中”),以及如何将其重新组合起来(例如,“分配一个新的装箱对象,将 EAX 中的值放入其中,并将指向该装箱对象的指针存储在新环境向量的第一个槽位中”)。这种从正在运行的程序的底层、优化后的大杂烩中具象化出像闭包这样的高级抽象的能力,是现代高性能语言运行时的基石。

知识的边缘:闭包与不可知之物

最后,我们来到了编译的哲学边界。编译器的威力来自于它通过分析源代码能证明关于程序的哪些事情。但对于像 eval 这样执行在运行时才可知的字符串代码的特性,情况又如何呢?

eval 函数为编译器制造了一片“战争迷雾”。考虑一个在 eval 调用之前创建的闭包。由于它的环境是从一个编译器可以看见并理解的世界中捕获的,其行为是可预测的。编译器可以充满信心地对其进行分析、优化和推理。

但对于任何出现在 eval 调用之后的代码,一切都变得不确定了。eval 字符串可能引入了新的变量来“遮蔽”现有变量,从而从根本上改变了像 x 这样的标识符的含义。一个静态的、提前编译的编译器,由于无法访问运行时的字符串,必须变得极其保守。它不能再假设 x 指向它之前所知的那个绑定;它必须将 x 视为一个未知量,这严重限制了像常量传播这样的优化。

闭包,作为一个其定义根植于代码的静态、词法结构中的实体,与 eval 可能释放的动态混乱形成了鲜明对比。这种张力凸显了语言设计中的一个根本权衡:静态分析的可预测性和可优化性与动态执行的灵活性之间的权衡。

从机器的寄存器到程序分析的前沿,一个函数能记住其诞生地的简单想法,被证明是一个强大的透镜。它迫使我们直面工程、效率和认识论上的根本问题,揭示了统一计算理论与实践的深刻而美妙的联系。

function make_counter() { let count = 0; return function() { // This is a closure that captures 'count' count = count + 1; return count; }; } let counter = make_counter(); // 'make_counter' is called, then returns. let val1 = counter(); // returns 1 let val2 = counter(); // returns 2