[Theory] A closure is nothing but ...

以前写的一篇文章

1. 找出定义

在解释新概念时,很多人喜欢类比比喻,似乎只有这样,才能让抽象的概念更容易理解一些,
然而非常不幸,这些不恰当的比喻,却经常会造成误解
让我们自以为如此,实则还是对力量一无所知

在讨论 closure(闭包)这个概念时,可能一百个前端会有一百二十种认识。
那么到底 closure 是个什么东西呢?
我们还是要回到定义,用最原始的笨方法去理解它。

先看维基百科上是怎么说明的吧,

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.

粗略的翻译一下,
在编程语言中, closure(闭包)或者称词法闭包或函数闭包,
是一种与编程语言实现相关的技术,它在支持first-class function的语言中可以实现词法作用域

除此之外,在《EOPL》第80页,我们还看到,

These data structures are often called closures, because they are self-contained: they contain everything the procedure needs in order to be applied. We sometimes say the procedure is closed over or closed in its creation environment.

这样就比较清晰了,closure(闭包)原来是一种数据结构,它包含了函数调用所需要的一切信息。

2. 理解实现

那么 closure 到底是一种怎样的数据结构呢?
刚好我在这篇文章中有过一些介绍:词法作用域和闭包

; closure in the lexical-scope

(struct closure 
  (param body env))

这是一段 Racket 代码,使用 struct 定义了一个数据结构。
包含了3个字段,parambodyenv
这就是 closure(闭包)了。

它是怎么使用的呢?

(define (eval-exp exp env)
  (handle-decision-tree 
   `((,is-symbol? ,eval-symbol)     
     (,is-self-eval-exp? ,eval-self-eval-exp)     
     (,is-list?      
      ((,is-lambda? ,eval-lambda)       
       (,is-function-call-list? ,eval-function-call-list))))   
   exp env))

; ......

(define (eval-lambda exp env)  
  (display "eval-lambda\n")  
  (let ((param (caadr exp))        
        (body (caddr exp)))    
    (closure param body env)))

; ......

(define (eval-function-call-list exp env)  
  (display "eval-function-call-list\n")  
  (let* ((clos (eval-exp (car exp) env))         
         (arg (eval-exp (cadr exp) env))

         (body (closure-body clos))
         (lexical-env (closure-env clos))
         (param (closure-param clos))

         (frame (create-frame)))

    (extend-frame frame param arg)

    (let ((executing-env (extend-env lexical-env frame)))
      (eval-exp body executing-env))))

以上代码 eval-exp 可用来求值任意表达式,我们会进行判断,
如果这个表达式是 函数定义 is-lambda?,我们就调用 eval-lambda
如果这个表达式是 函数调用 is-function-call-list?,我们就调用 eval-function-call-list

函数定义的逻辑非常简单,只是把 parambody 和当前 env 存到 closure 中返回就行了,
之后函数调用的时候,再从 closure 中把这个 env 拿出来,用实参 extend 一下,再在这个环境中求值函数体。

所以,closure(闭包)的原理一下子就清晰了,
closure 只是一种技术手段,让函数在调用时,能拿到它被定义时的环境(env)。

3. 回到现实

我们用JavaScript来理解一下,找找感觉。
先来看看,如果函数调用时拿不到定义时的环境会怎样?

3.1 环境(env)和栈帧(frame)

首先,我们知道,函数的调用链路是先调用后返回的,即,
如果 foo 中调用了 bar,则 bar 返回后, foo 才会返回。

foobar 两者很可能有相同的实参,

function foo(x) {
    bar(2);
}
function bar(x) { }

foo(1);

所以需要花点心思考虑 env 该怎么表示。

经过一番考虑之后,我们决定用一个数组表示 env
env 中的元素为一个一个对象(称为栈帧 frame),对象中存着变量名和值之间的绑定关系。

env = [frame1, frame2, ...]
frame = {key1: value1, key2: value2, ...}

具体说来就是,(序号1,2,表示时间顺序)

const a = 1;   // 1.[{a: 1}]
const b = 2;   // 2.[{a: 1, b: 2}]

函数调用时会创建一个新的栈帧,并将实参与值的绑定关系写入这个栈帧中。

const a = 1;              // 1. [{a: 1}]
const b = 2;              // 2. [{a: 1, b: 2}]

const f = function (x) {  // 3. [{a: 1, b: 2, f: <function>}]
                          // 5. 创建一个空的frame,加入env尾部,[{a: 1, b: 2, f: <function>}, { }]
                          // 6. 将实参与值的绑定关系写入frame中,[{a: 1, b: 2, f: <function>}, {x: 3}]
}

f(3);                     // 4. 调用前:[{a: 1, b: 2, f: <function>}]
                          // 7. 调用后:弹栈把栈帧销毁,又回到了[{a: 1, b: 2, f: <function>}]

3.2 动态作用域

这样处理是最容易想到的办法,我们似乎并不需要使用 closure。然而

const f = function () {      // 1. [{f: <function>}]
                             // 3. 创建新栈帧,[{f: <function>}, { }]

    const a = 1;             // 4. 将局部变量写入当前栈帧,[{f: <function>}, {a: 1}]
    const g = function () {  // 5. [{f: <function>}, {a: 1, g: <function>}]
                             // 11. 创建新栈帧,[{f: <function>, g: <function>, a: 2}, { }]
        return a;            // 12. 在env中从右到左(栈顶)查找a的值,a = 2,函数返回 2
    }

    return g;                // 6. 函数返回
}

const g = f();               // 2. 调用前:[{f: <function>}]
                             // 7. 调用后:弹栈,又回到了[{f: <function>}]
                             // 8. 将返回值赋值给g,[{f: <function>, g: <function>}]
const a = 2;                 // 9. 变量写入当前栈帧,[{f: <function>, g: <function>, a: 2}]
const r = g();               // 10. 调用前:[{f: <function>, g: <function>, a: 2}]
                             // 13. 调用后:弹栈,又回到了[{f: <function>, g: <function>, a: 2}]
                             // 14. 将返回值赋值给r,[{f: <function>, g: <function>, a: 2, r: 2}]

我们看到,最终 r 的值为 2,而不是离 return a; 最近的 const a = 1;
这就很容易令人困惑,当我们想知道 return a;a 是什么值的时候,还要关心 g 是怎么被调用的。
g 在调用之前 const a = 2; 那么 g 就会返回 2,在调用之前 const a = 3;,则会返回 3

同样的写法 g(),居然会有不同的结果,这样很容易出事。
因此,这种简易的 env 实现,逐渐被人们所抛弃了。

取而代之,人们通过 closure 来保存函数在定义时的环境,并在那个环境中求值函数体。

3.3 静态(词法)作用域

下面我们再看看使用 closure,我们可以做到什么,

const f = function () {      // 1. [{f: <closure>}]
                             // 3*. (从closure中获取f定义时的环境:第1步的)创建新栈帧,[{f: <closure>}, { }]

    const a = 1;             // 4. 将局部变量写入当前栈帧,[{f: <closure>}, {a: 1}]
    const g = function () {  // 5. [{f: <closure>}, {a: 1, g: <closure>}]
                             // 11*. (从closure中获取f定义时的环境:第5步的)创建新栈帧,[{f: <closure>}, {a: 1, g: <closure>}, { }]
        return a;            // 12*. 在env中从右到左(栈顶)查找a的值,a = 1,函数返回 1
    }

    return g;                // 6. 函数返回
}

const g = f();               // 2. 调用前:[{f: <closure>}]
                             // 7*. 调用后(恢复成调用前的环境):[{f: <closure>}]
                             // 8. 将返回值赋值给g,[{f: <closure>, g: <closure>}]
const a = 2;                 // 9. 变量写入当前栈帧,[{f: <closure>, g: <closure>, a: 2}]
const r = g();               // 10. 调用前:[{f: <closure>, g: <closure>, a: 2}]
                             // 13*. 调用后(恢复成调用前的环境):[{f: <closure>, g: <closure>, a: 2}]
                             // 14*. 将返回值赋值给r,[{f: <closure>, g: <closure>, a: 2, r: 1}]

不妨重点关注带 * 号的步骤,
在第 3 步和第 11 步中,我们并没有像之前的例子那样,直接操作函数调用时的环境,
而是从 closure 中取出了函数在定义时的环境。

11 步最为明显,它取出了第 5 步时的环境
因此,再从 env 中找 a 值的时候,我们找到了 const a = 1; 的值。
这个 const a = 1; 是离 return a; 最近的,在代码(词法)范围内距离最近的。

因此,我们就说变量 a 是具有静态(词法)作用域的。

With lexical scope, a name always refers to its (more or less) local lexical environment.

4. 小结

本文介绍了什么是 closure,以及它在编程语言实现中扮演着的角色,
接着,我们看到了动态作用域和静态(词法)作用域的区别,
相信这样理解的话,我们应对 closure 的认识更加深刻了吧。


参考

Wikipedia: Closure (computer programming)
Wikipedia: lexical scope
Essentials of Programming Languages
词法作用域和闭包

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