“闭包”是什么?

537

我问了一个关于柯里化的问题,提到了闭包。什么是闭包?它与柯里化有什么关系?


33
闭包是什么?有些回答说,闭包是函数;有些说它是堆栈;还有些回答称其为“隐藏”的值。据我理解,它是由函数和封闭变量组成的。 - Roland
5
闭包是一种函数,它可以捕获并存储其被创建时所在环境的状态信息,并在其词法范围之外被调用。 闭包通常用于实现数据隐藏和封装,以及在JavaScript中创建模块或命名空间。 它们是JavaScript中强大且常见的编程概念之一。 关于为什么称其为“闭包”,这可能是因为它可以“封闭”或包含其被创建时的环境。 - dietbuddha
2
请参考软件工程领域 stackexchange 上的 什么是闭包? - Felix K.
这里有很棒的答案。然而,如果你对数学有兴趣,值得查看 https://dev59.com/1nVC5IYBdhLWcg3wqzDV#36878651。 - KGhatak
22个回答

940

变量作用域

当你声明一个局部变量时,该变量就有了作用域。通常情况下,局部变量只在你声明它们的块或函数中存在。

function() {
  var a = 1;
  console.log(a); // works
}    
console.log(a); // fails

如果我尝试访问一个本地变量,大多数语言将在当前范围内查找,然后向上遍历父级范围,直到到达根范围。

var a = 1;
function() {
  console.log(a); // works
}    
console.log(a); // works
当一个代码块或函数执行完毕后,它的局部变量通常会被清除掉,这是我们通常期望的。闭包是一个持久化的局部变量作用域,即使代码执行已经跳出了那个块,它还能保留住局部变量。支持闭包的语言(如JavaScript、Swift和Ruby)可以允许你在函数声明后继续引用它所在的作用域对象及其父级作用域对象中的变量,只要你在某处保留了对该块或函数的引用。作用域对象及其所有局部变量都与函数关联,只要函数存在,它们就会持续存在。这使得函数具有可移植性,我们可以期望在稍后调用函数时,任何在函数首次定义时作用域内的变量仍然在作用域内,即使我们在完全不同的上下文中调用该函数也是如此。 举个例子,在JavaScript中这里有一个非常简单的例子来说明这一点:
outer = function() {
  var a = 1;
  var inner = function() {
    console.log(a);
  }
  return inner; // this returns a function
}

var fnc = outer(); // execute outer to get inner 
fnc();

在这段代码中,我定义了一个函数在另一个函数内部。内部函数可以访问外部函数的所有本地变量,包括变量a。变量a在内部函数的范围内。

通常情况下,当一个函数退出时,它的所有本地变量都会被清除。然而,如果我们返回内部函数并将其赋值给变量fnc,使其在outer退出后仍然存在,那么“内部”函数定义时所在的作用域中的所有变量也会一直存在。 变量a已被封闭 - 它在一个闭包内。

请注意,变量a对于fnc来说是完全私有的。 这是在诸如JavaScript之类的函数式编程语言中创建私有变量的一种方式。

正如您可能猜到的那样,当我调用fnc()时,它会打印变量a的值,该值为“1”。

在没有闭包的语言中,当函数outer退出时,变量a将被垃圾回收并丢弃。调用fnc将引发错误,因为变量a已不存在。

在JavaScript中,变量a会持续存在,因为变量作用域是在函数首次声明时创建的,并且会在函数继续存在的同时一直存在。

a属于outer的作用域。 inner的作用域具有指向outer作用域的父指针。 fnc是指向inner的变量。只要fnc存在,a就会一直存在。变量a位于闭包内。

进一步阅读(观看)

我制作了一个 YouTube 视频,其中包含此代码的一些实际示例。


2
我可以有一个示例,演示这在像 JQuery 这样的库中如何工作,正如倒数第二段所述吗?我没有完全理解那一部分。 - DPM
8
嗨,Jubbat,是的,请打开jquery.js文件并查看第一行。您会看到一个函数被打开。现在跳到末尾,您将看到window.jQuery = window.$ = jQuery。然后该函数被关闭并自我执行。现在您可以访问$函数,而$函数又可以访问在闭包中定义的其他函数。这回答了您的问题吗? - superluminary
4
我已经阅读了这个话题的教材两天,但真的无法理解发生了什么。阅读你的答案只花了4分钟,而且很容易理解。 - Andrew
3
@BlissRage - 主要目的之一是用于事件处理程序。当您设置处理程序时,可以访问一堆本地变量。但稍后,当调用处理程序时,这些变量可能已更改或不再存在。闭包为您提供了可靠的运行时环境。 - superluminary
1
@user1063287 当函数的词法作用域消失后,闭包就被创建了。经典的例子是回调函数。该函数被创建并传递给DOM、setTimeout或Promise。所有函数都有一个闭包作用域。当函数被取消引用时,垃圾收集器将清除该闭包作用域。如果该函数从未被取消引用,则该闭包作用域将永久存在。我制作了一个视频,可能有助于解释它,链接在文章中。 - superluminary
显示剩余7条评论

130

我会举一个例子(用JavaScript语言):

function makeCounter () {
  var count = 0;
  return function () {
    count += 1;
    return count;
  }
}

var x = makeCounter();
x(); returns 1
x(); returns 2
...etc...

makeCounter 函数返回一个函数 x,每次调用它都会递增一。由于我们没有向 x 提供任何参数,所以它必须以某种方式记住计数。它根据词法作用域找到值的位置 - 它必须查找定义它的位置来找到该值。这个“隐藏”的值就是所谓的闭包。

这里是我的柯里化示例:

function add (a) {
  return function (b) {
    return a + b;
  }
}

var add3 = add(3);
    
add3(4); returns 7
你可以看到,当你调用参数为a的add函数(值为3)时,该值包含在我们定义的add3返回的闭包中。这样,当我们调用add3时,它知道在哪里找到a值来执行加法。

4
我不确定你使用的是哪种语言(可能是F#),能否使用伪代码提供上面的示例?我很难理解这个。 - user
1
@crucifiedsoul,这是Scheme。ftp://ftp.cs.indiana.edu/pub/scheme-repository/doc/pubs/intro.txt - Kyle Cronin
4
@KyleCronin 的例子很好,谢谢。问题:说“隐藏的值被称为闭包”更正确,还是说“隐藏值的函数是闭包”?或者说“隐藏值的过程是闭包”?谢谢! - user550738
3
@RobertHume 好问题。语义上来说,“闭包”这个术语有些含糊不清。我的个人定义是,隐藏值和封闭的函数对其使用的组合构成了闭包。 - Kyle Cronin
@Sislam 或许这会有所帮助:add3 赋的值是一个已经计算过的函数调用,也就是说它的值 一个带有一个参数 b 的函数。当你调用 add3(4) 时,你正在调用该函数,该函数“记住”变量 a 及其值 3,即使 a 在其函数块之外并且在某种意义上“不再存在”。这是因为在声明 add3 时,变量 a 在内部函数的词法作用域内是可访问的。在其词法作用域内,内部引用和外部变量之间的连接是持久的。 - user1063287
显示剩余3条评论

104
首先,与大多数人在这里告诉你的相反,闭包不是函数!那么它是什么呢?它是一个在函数的“周围环境”中定义的符号集(称为其环境),使其成为一个封闭表达式(也就是说,在其中每个符号都被定义并具有值,因此可以进行评估)。
例如,当您有一个JavaScript函数时:
function closed(x) {
  return x + 3;
}

它是一个封闭表达式,因为其中出现的所有符号都在其中被定义(它们的含义很清晰),因此你可以评估它。换句话说,它是自包含的

但如果你有这样一个函数:

function open(x) {
  return x*y + 3;
}

这是一个“开放表达式”,因为其中有一些符号没有在其中定义。也就是说,y。当查看此函数时,我们无法确定y是什么以及它代表什么意思,我们不知道它的值,因此我们不能评估此表达式。也就是说,在告诉我们y在其中应该表示什么之前,我们无法调用此函数。这个y被称为“自由变量”。

这个y需要定义,但这个定义不是函数的一部分 - 它在其他地方定义,在其“周围环境”中(也称为“环境”)。至少我们希望是这样:P

例如,它可以全局定义:

var y = 7;

function open(x) {
  return x*y + 3;
}

或者它可以在一个包装它的函数中定义:

var global = 2;

function wrapper(y) {
  var w = "unused";

  return function(x) {
    return x*y + 3;
  }
}

给表达式中的自由变量赋予意义的环境部分被称为闭包。它被称为这个名字,是因为它通过提供所有自由变量的缺失定义来把一个开放表达式变成一个封闭的表达式,以便我们能够对其进行评估。

在上面的例子中,内部函数(我们没有给它命名,因为我们不需要)是一个开放的表达式,因为其中的变量y自由的——它的定义在函数外部,在包装它的函数中。匿名函数的环境是一组变量:

{
  global: 2,
  w: "unused",
  y: [whatever has been passed to that wrapper function as its parameter `y`]
}

现在,闭包是环境的一部分,通过为所有它的自由变量提供定义来关闭内部函数。在我们的例子中,内部函数中唯一的自由变量是y,因此该函数的闭包是其环境的这个子集:

{
  y: [whatever has been passed to that wrapper function as its parameter `y`]
}

环境中定义的另外两个符号不是该函数的闭包的一部分,因为它们不需要运行。它们不是必需的来关闭它。

关于这方面的理论可以在这里找到更多信息: https://dev59.com/1nVC5IYBdhLWcg3wqzDV#36878651

值得注意的是,在上面的例子中,包装函数将其内部函数作为值返回。我们调用此函数的时刻可能与定义(或创建)函数的时刻相距很远。特别是,封装函数不再运行,调用栈上的参数也不再存在 :P 这造成了问题,因为内部函数需要在调用时有y!换句话说,它需要从其闭包中获取变量以某种方式存活于包装函数之外,并在需要时出现。因此,内部函数必须对构成其闭包的这些变量进行快照,并将它们安全地存储在其他地方供以后使用。(在调用栈之外的某个地方。)

这就是为什么人们经常将术语闭包误解为那种特殊类型的函数,它可以拍摄它们使用的外部变量的快照,或用于存储这些变量以便以后使用的数据结构。但是我希望您现在理解它们不是闭包本身——它们只是实现闭包的编程语言中的一种方式,或允许函数闭包中的变量在需要时存在的语言机制。关于闭包存在很多误解,这使得这个主题比它实际上更加混乱和复杂。


7
一个比喻可以帮助初学者理解闭包的概念:闭包“绑定所有的松散末端”,就像一个人在“寻求闭包”时所做的那样(或者说它能够“解决”所有必要的引用)。好吧,这个比喻对我来说很有帮助:o) - Will Crawford
5
这些年来,我读过很多关于“闭包”的定义,但这个我觉得是迄今为止我最喜欢的一个。我猜我们每个人都有自己心中对这类概念的认识方式,而这个定义与我的认知非常吻合。 - Jason S.
3
我看过很多来自谷歌、YouTube、书籍、博客等的解释,它们都很有道理,也很好, 但我认为这是最逻辑清晰的解释。 - starriet
全局对象是闭包吗?因为它封装了所有嵌套的变量和函数?也许不是,因为与其他描述的“外部”封闭结构不同,全局对象永远无法被执行和“完成”? - user1063287

65

凯尔的回答相当不错。我认为唯一需要补充的是,闭包实际上是在创建 lambda 函数时对堆栈的快照。然后,当函数重新执行时,堆栈将恢复到执行该函数之前的状态。因此,正如凯尔所提到的那样,lambda 函数在执行时可以访问隐藏值 (count)。


16
不只是堆栈,不论它们存储在堆栈还是堆上(或两者都有),被保留的还有封闭的词法作用域。 - Matt Fenwick

33

闭包是一个函数,它可以引用另一个函数中的状态。例如,在Python中,这使用了闭包“inner”:

def outer (a):
    b = "variable in outer()"
    def inner (c):
        print a, b, c
    return inner

# Now the return value from outer() can be saved for later
func = outer ("test")
func (1) # prints "test variable in outer() 1

23

为了更好地理解闭包,可以考虑在过程式语言中如何实现闭包。本解释将介绍Scheme中闭包的简单实现。

首先,我必须介绍命名空间的概念。当您向Scheme解释器输入命令时,它必须计算表达式中的各个符号并获取其值。例如:

(define x 3)

(define y 4)

(+ x y) returns 7

define表达式将值3存储在x的位置,将值4存储在y的位置。当我们调用(+ x y)时,解释器查找命名空间中的值,并能够执行操作并返回7。

然而,在Scheme中有一些表达式允许您暂时覆盖符号的值。以下是一个示例:

(define x 3)

(define y 4)

(let ((x 5))
   (+ x y)) returns 9

x returns 3

let 关键字的作用是引入一个新的命名空间,其中 x 的值为 5。你会注意到,它仍然能看到 y 是 4,使得返回的和为 9。你还可以看到,一旦表达式结束,x 又回到了 3。从这个意义上讲,x 被局部值暂时掩盖了。

过程式语言和面向对象语言有类似的概念。每当您在函数中声明与全局变量同名的变量时,您就会获得相同的效果。

我们该如何实现这个?一种简单的方法是使用链表 - 头包含新值,尾部包含旧命名空间。当您需要查找符号时,您从头开始,沿着尾部往下查找。

现在让我们暂时跳过对一等函数(first-class functions)实现的讨论。更或少地说,函数是一组指令,在调用函数时执行并产生返回值。当我们读取一个函数时,我们可以将这些指令存储在幕后,并在调用函数时运行它们。

(define x 3)

(define (plus-x y)
  (+ x y))

(let ((x 5))
  (plus-x 4)) returns ?
我们将x定义为3,plus-x为其参数y加上x的值。最后,在一个屏蔽了x的新值为5的环境中调用plus-x。如果我们仅仅存储操作(+ x y)作为函数plus-x,并且在x的上下文中值为5,则返回的结果将是9。这就是所谓的动态作用域。
然而,Scheme、Common Lisp和许多其他语言有所谓的词法作用域—除了存储操作(+ x y)之外,我们还存储了该特定点的命名空间。这样,当我们查找值时,我们可以看到在这个上下文中x实际上等于3。这就是闭包。
(define x 3)

(define (plus-x y)
  (+ x y))

(let ((x 5))
  (plus-x 4)) returns 7

总之,我们可以使用链表来存储函数定义时名称空间的状态,这样就可以访问封闭作用域中的变量,并且提供了在不影响程序其余部分的情况下局部屏蔽变量的能力。


好的,感谢您的回答,我认为我终于对闭包有了些许理解。但还有一个重要的问题:“我们可以使用链表来存储函数定义时命名空间的状态,从而使我们能够访问在其它情况下已经超出作用域的变量。” “为什么我们要访问超出作用域的变量?当我们声明let x = 5时,我们希望x为5而不是3。发生了什么?” - Lazer
@Laser:抱歉,那个句子不太通顺,所以我更新了它。希望现在更容易理解了。此外,不要将链表视为实现细节(因为它非常低效),而是将其视为一种简单的概念化方式,以便更好地理解如何完成任务。 - Kyle Cronin

13

1
为什么要减去这个?实际上,将自由变量和约束变量以及纯/闭合函数和不纯/开放函数区分开来,比这里大多数其他无知的答案更“正确”(排除将闭包与函数闭合混淆的情况)。 - SasQ
我真的毫无头绪。这就是为什么StackOverflow很糟糕的原因。只需查看我的答案源代码。谁能反驳呢? - soundyogi
SO不差,而且我从未听说过“自由变量”这个术语。 - Kai
谈到闭包时,很难不提到自由变量。可以查阅标准的CS术语了解更多相关信息。 - ComDubh
1
包含一个或多个自由变量的函数被称为闭包并不是正确的定义,因为闭包始终是一等对象。 - ComDubh

9

以下是一个实际案例,说明为什么闭包很牛逼... 这是我 JavaScript 代码中的片段。让我举个例子。

Function.prototype.delay = function(ms /*[, arg...]*/) {
  var fn = this,
      args = Array.prototype.slice.call(arguments, 1);

  return window.setTimeout(function() {
      return fn.apply(fn, args);
  }, ms);
};

这里是如何使用它的:

var startPlayback = function(track) {
  Player.play(track);  
};
startPlayback(someTrack);

现在想象一下,您希望播放延迟启动,例如在此代码片段运行后5秒钟。这很容易通过 delay 和它的闭包实现:

startPlayback.delay(5000, someTrack);
// Keep going, do other things
当您使用5000ms调用delay时,第一个片段将运行,并在其闭包中存储传递的参数。然后,5秒后,当setTimeout回调发生时,闭包仍然保持这些变量,因此它可以使用原始参数调用原始函数。
这是一种柯里化或函数装饰的类型。
如果没有闭包,您必须以某种方式维护函数外部的那些变量状态,从而使逻辑上属于函数内部的东西污染函数外部的代码。使用闭包可以大大提高代码的质量和可读性。

1
需要注意的是,扩展语言或宿主对象通常被认为是一件不好的事情,因为它们是全局命名空间的一部分。 - Jon Cooke

9

简短版

闭包是一个函数及其作用域被分配给(或用作)变量。因此,它的名称为闭包:作用域和函数被封装并像任何其他实体一样使用。

详细的维基百科式解释

根据维基百科的说法,闭包是:

在具有头等函数的语言中实现词法作用域名称绑定的技术。

这是什么意思?让我们看一些定义。

我将使用这个例子来解释闭包和其他相关定义:

function startAt(x) {
    return function (y) {
        return x + y;
    }
}

var closure1 = startAt(1);
var closure2 = startAt(5);

console.log(closure1(3)); // 4 (x == 1, y == 3)
console.log(closure2(3)); // 8 (x == 5, y == 3)

一级函数

基本上,这意味着我们可以像使用其他实体一样使用函数。 我们可以修改它们,将它们作为参数传递,从函数中返回它们或将它们分配给变量。 从技术上讲,它们是一级公民,因此得名:一级函数。

在上面的示例中,startAt 返回一个(匿名)函数,该函数被分配给 closure1closure2。 因此,正如您所看到的,JavaScript 将函数视为任何其他实体(一级公民)。

名称绑定

名称绑定 是关于找出变量(标识符)引用了什么数据。 这里范围非常重要,因为这将确定绑定的解析方式。

在上面的示例中:

  • 在内部匿名函数的作用域中,y绑定到3
  • startAt的作用域中,x绑定到15(取决于闭包)。

在匿名函数的作用域中,x没有绑定到任何值,因此需要在上层(startAt的)作用域中解析。

词法作用域

维基百科所说,作用域:

是计算机程序的区域,在该区域绑定是有效的: 可以使用名称来引用实体

有两种技术:

  • 词法(静态)作用域: 变量的定义通过搜索其包含的块或函数来解析,然后如果失败了,则搜索外部包含块,依此类推。
  • 动态作用域: 搜索调用函数,然后是调用该调用函数的函数,依此类推,沿着调用堆栈向上进展。

为了更好的解释,请查看这个问题参考维基百科

在上面的例子中,我们可以看到JavaScript是按词法作用域的,因为当x被解析时,绑定被搜索在上层(startAt的)作用域中,基于源代码(匿名函数寻找x是在startAt内部定义的),而不是基于调用栈,即函数被调用的作用域。

封装(闭包)

在我们的例子中,当我们调用startAt时,它将返回一个(一等)函数,该函数将被分配给closure1closure2,因此创建了一个闭包,因为传递的变量15将保存在startAt的范围内,并将被封装在返回的匿名函数中。当我们通过closure1closure2使用相同的参数(3)调用此匿名函数时,y的值将立即被找到(因为那是该函数的参数),但是x没有在匿名函数的作用域中绑定,因此解析将在(词法上)更高的函数作用域(保存在闭包中)中继续进行,在那里x被发现绑定到15。现在我们知道所有求和所需的信息,因此可以返回结果,然后打印。

现在您应该理解闭包及其行为,这是JavaScript的基本部分。

柯里化

哦,而且你也学到了什么是柯里化:你使用函数(闭包)来传递每个操作的参数,而不是使用具有多个参数的一个函数。


5

闭包是JavaScript中的一项功能,使函数可以访问其自身范围内的变量、外部函数变量和全局变量。

闭包即使在外部函数返回后仍然可以访问其外部函数作用域。这意味着闭包可以记住并访问其外部函数的变量和参数,即使该函数已经执行完毕。

内部函数可以访问其自身范围、外部函数的范围和全局范围内定义的变量,而外部函数只能够访问自身作用域以及全局作用域中定义的变量。

闭包示例

var globalValue = 5;

function functOuter() {
  var outerFunctionValue = 10;

  //Inner function has access to the outer function value
  //and the global variables
  function functInner() {
    var innerFunctionValue = 5;
    alert(globalValue + outerFunctionValue + innerFunctionValue);
  }
  functInner();
}
functOuter();  

输出结果将为20,即它的内部函数自身变量、外部函数变量和全局变量值的总和。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接