闭包是怎么来的

本文是我学习闭包以来的总结和体会, 错误或者不当之处还请读者指出, 以免误导后学。
如果转载请在正文开头注明文章来源, 以便读者可以看到及时的更新。

学习东西首先要抓住重点, 用最少的精力获取最大成果, 其余的就是搂草打兔子, 顺便学了。
JavaScript 的重点和难点之一就是闭包, 学好闭包机制 Javascript level up 50%;
学习闭包机制时, 个人建议参照 ECMAScript 规范 和 V8 引擎 学习。理论实践相结合。

下面是一些关于闭包的描述, 摘录权威性的文献或资料:

<<JavaScript权威指南>>(第六版) p183

从技术角度来讲, 所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。

<<JavaScript权威指南>>(第六版) p183

函数变量可以被隐藏于作用域链中, 因此看起来是函数将变量“包裹”了起来.

<<JavaScript高级程序设计(第三版)>> p178

闭包是指有权访问另一个函数作用域中的变量的函数。

You Don't Know JS: Scope & Closures

Closure is when a function is able to remember and access its lexical scope
even when that function is executing outside its lexical scope. 

MDN

A closure is the combination of a function and the lexical environment within which that function was declared.

某百科

In programming languages, a closure (also lexical closure or function closure) is a technique
for implementing lexically scoped name binding in a language with first-class functions. 
Operationally, a closure is a record storing a function together with an environment.
The environment is a mapping associating each free variable of the function 
(variables that are used locally, but defined in an enclosing scope) with the value or reference 
to which the name was bound when the closure was created.
A closure—unlike a plain function—allows the function to access those captured variables 
through the closure's copies of their values or references, even when the function is invoked outside their scope.

初学者看到这么多定义, 很容易迷糊, 如盲人摸象, 管中窥豹。

到底哪种定义更加全面, 更加接近闭包的本质?

要明白某个名词, 寻纠它的起源是搞明白其含义的妙招。

闭包到底是怎么来的, 这种概念是怎么出现的, 闭包到底解决了什么问题?

首先明确一点, 闭包并不是 JavaScript 所独有的概念, 其他语言也有其实现

在 JavaScript 中, 函数是一等公民: 函数可以作为参数传递, 可以从函数返回, 可以修改, 可以赋值给变量。

在支持 函数是一等公民的编程语言中, 要面临一个问题—— funarg problem, 如何处理自由变量 (变量既不是函数的参数, 也不是局部变量)

funarg problemupwards funarg problemdownwards funarg problem 两种。

upwards funarg problem 发生在函数将其嵌套函数作为返回值返回时(嵌套函数使用了自由变量)。
downwards funarg problem 发生在将函数作为参数传入函数时。

举例来说, 下面是 downwards funarg problem

let x = 10;
 
function foo() {
  console.log(x);
}
 
function bar(funArg) {
  let x = 20;
  funArg(); // 10, 不是 20
}
 
// 将 `foo` 作为参数传给 `bar`.
bar(foo);

对于函数 foo 来说, x 就是其自由变量。 函数 foo 内的 变量 x 应该解析到全局环境中值为 10x(即采用静态作用域)还是 bar 函数中值为 20x (即采用动态作用域)?

JavaScript 解决这个问题, 通过采用 静态作用域(或者说词法作用域 —— 函数作用域是在定义函数时就确定的, 而不是运行时。)。

还有个 upwards funarg problem

function foo() {
  var a = { x: 1, y: 2 }; // 对象
  var b = 10; // 基本数据类型
  function bar(param) {
    return param+ b;
  }
  return bar;
}
var b = 20;
var func = foo();
console.log(func(1));

这个例子是我们在 JS 中经常见到的例子,虽然看起来很简单, 其中却大有学问。

我们知道, 通常来说, 在基于栈的函数内存分配范式中, 调用函数时, 会将其参数和局部变量保存在栈的栈帧(或者活动记录)上, 函数调用结束后, 保存其参数和局部变量的栈帧就会从调用栈中弹出。

上述例子中, bar 函数执行时, foo 函数的栈帧已经从调用栈中弹出,如果没有某种机制, 其局部变量 ab 就都不存在了, bar 根本不可能获取到变量 b 的值。

怎么办?

一个办法就是, 有外部引用引用 变量 b 时, 禁止函数 foo 的栈帧从栈中弹出,但是这打破了函数基于栈的内存分配范式(函数调用完毕后, 应该将其栈帧从栈中弹出)

怎么能够既可以使函数遵循基于栈的内存分配, 还可以使 barfoo 返回后仍然可以获取到 b 的值呢?

方案一:

在堆而不是栈上分配所有栈帧,当栈帧不再需要时, 依赖某种形式的 垃圾回收 或者 引用计数 来释放栈帧。由于在堆上管理栈帧远远没在栈上管理来的高效, 这种策略可能严重降低性能。而且, 因为在通常的程序中大多数函数并不会创建 upwards funargs(当函数作为参数时, 该函数就叫 funarg), 很多这种损耗是不必要的。

方案二:

一些考虑性能的编译器会采用混合方式: 如果编译器通过 静态程序分析 推断出函数没有创建 upwards funargs, 那么 函数的栈帧就会在栈上分配, 否则的话, 在堆上分配栈帧。

方案三:

利用闭包。 闭包创建时, 将变量的值拷贝进闭包。在可变变量(mutable variables) 的情况下,这将导致不同的行为,因为状态不能够在闭包之间共享。
在实际的引擎实现中,出于性能的考虑,可能会对闭包存储的变量进行优化, 比如只保存自由变量(v8 即是采用了这种优化方式)。

JavaScript 正是采用了第三种方案, 利用闭包机制。

那么闭包到底是怎么实现的呢?

某百科有段描述, 摘录翻译如下:

闭包通常是由一种特殊的数据结构实现的, 该数据结构包括指向函数代码的指针, 以及闭包创建时函数词法环境(换句话说, 可获取到的变量集)的表示。 引用的环境在闭包创建时将非局部名称(也就是自由变量)绑定到对应的变量, 此外, 将它们的生存期扩展到至少和闭包的生存期一样长。 稍后进入闭包时可能是在不同的词法环境中, 当函数执行时使用的非本局部变量将会引用闭包捕获的变量,而不是当前环境的变量。

在 ES3 规范下, 闭包为函数代码 + 该函数的所有父作用域(也就是函数内部属性[[Scopes]]), 可以用伪码表示:

Closure = {
 functionCode: <pointerToFunctionCode>,
 [[Scopes]]: []
}

JavaScript 中 所有的函数(函数声明、函数表达式、命名函数、匿名函数)均有内部属性 [[Scopes]] 。从图中(NodeJS 8.11.3 vscode 调试结果)看的话是这样子的:

1.png

从上图可以看出, 所有的函数(函数声明、函数表达式、命名函数、匿名函数)均有 [[FunctionLocation]][[Scopes]]
所以从技术角度来看, 所有的函数都是闭包。

闭包中保存了 所有捕捉到的自由变量, 眼见为实


closure.png

这是 谷歌浏览器中调试到的结果, 可以看出:
(一) bar 执行时, Closure(foo) 中只包含了 bar 使用的自由变量 b, 没有包含 foo 的局部变量 a。(明显这是经过了优化, 只会保留自由变量。如果未优化的话, Closure(foo) 中会有变量 abbararguments)
(二) 调用栈中, 只有 (anonymous)bar, 没有 foobar 执行时 foo 函数已经从调用栈中弹出(网上仍然有很多博客错误的认为闭包函数执行时, 定义该函数的上下文并没有出栈)

call stack 这个概念比较通用, 在 ECMAScript中, execution context stack 就是 call stack。 类似于钱这个概念, 各国都有钱, 在我们中国, 钱就是人民币, 在美国就是美元。

验证: 只有自由变量存在的情况下,才会有真正意义上的闭包。

not-closure.png

函数 bar 处于 foo 中 且 foo 已把 bar 返回, 但是 bar 执行的时候并没有产生闭包。 由此可以看来, 没有自由变量存在的话, 不会有闭包, 而这同样是 v8 进行优化过后的结果。( 如果涉及到 v8 的话, <<JavaScript 高级程序设计>> 中所讲的 “闭包是指有权访问另一个函数作用域中的变量的函数” 是不是不是很确切,个人认为, 有权且访问了 更合适。)

another-closure.png

这里 虽然 foo 没有把 bar 返回在词法作用域之外执行, V8 依旧认为生成了闭包。(这个例子, 在<<You don't know JS>> 中并不认为是闭包)

还有个问题就是: 闭包存放在哪里?

closure.png

可以看到当 foo(2) 执行到图示位置, bar 函数的内部属性 [[Scopes]] 已经保存了对 foo 的闭包, 此闭包中保存了自由变量 a 和其值 2。然后, foo(2) 将返回值(也就是函数对象 bar)赋值给了变量 baz
此时, baz 持有的就是这个 bar 函数对象:

func-bar.png

foo(2) 返回后其内部的所有变量销毁。

当执行 baz() 时, bar 函数内部的 a 的值就从 [[Scopes]][0]中获取到了, 如下图:

closure2.png

这些东西都搞明白后,再看文章开头的几个定义是不是清晰很多了。

总结:

由于作用域链机制的存在, 从技术上来讲,JavaScript 中 所有的函数(函数声明、函数表达式、命名函数、匿名函数)都是闭包。

闭包是保存函数代码和定义函数的环境的记录, 此环境存储着闭包创建时函数的每个自由变量和其值或引用的映射。

在实际的闭包应用(比如内存性能优化分析)中, 我们真正要关注的闭包应当是:

  • 函数作为参数传递 或者 从另一个函数中返回
  • 函数内部使用了自由变量

小测验

对于方案三中提到的 状态不能够在闭包之间共享 如何理解呢?
拿《JavaScript 权威指南》(第六版) 中的一个例子来说(p185):

function counter() {
 var n = 0;
 return {
    count: function() { return n++; },
    reset: function() { n = 0;}
 };
}

var c = counter(), d = counter(); // 创建两个计数器
c.count(); // => 0
d.count(); // => 0
c.reset(); 
c.count(); // => 0;
d.count(); // => 1

counter() 调用了两次,得到了两个计数器对象, 调用其中一个计数器对象的 count() 或者 reset() 不会影响到另一个对象。counter() 中的状态 n 在两个计数器对象之间不是共享的。
思考一下, 这是为什么?

参考资料:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容

  • 一、闭包的定义 闭包是指有权访问另一个函数作用域中的变量的函数 --《JavaScript高级程序设计》 函数对象...
    沐向阅读 330评论 0 0
  • 一、理解 JavaScript 的作用域、作用域链和内部原理 作用域 javascript 拥有一套设计良好的规则...
    旭哥_阅读 351评论 0 1
  • 作用域链与闭包 了解作用域链之前需要先了解下作用域是什么。 作用域 几乎所有的语言都有作用域的概念。这是因为它们都...
    周二可阅读 511评论 1 2
  • 一切皆对象 js中的一个常见运算符 typeof 以上代码列出了 typeof 输出的集中类型标识, 其中上面的四...
    无迹落花阅读 1,976评论 0 5
  • 1、变量作用域 要理解闭包,首先要理解javascript的特殊的变量作用域。 变量的作用域无非就两种:全局变量和...
    yuanjiex阅读 313评论 0 0