You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

33函数式编程第2关实现闭包特性

你好,我是宫文学。

上节课,我们实现了函数式编程的一个重要特性:高阶函数。今天这节课,我们继续来实现函数式编程的另一个重要特性,也就是闭包。

闭包机制能实现信息的封装、缓存内部状态数据等等,所以被资深程序员所喜欢,并被用于实现各种框架、类库等等。相信很多程序员都了解过闭包的概念,但往往对它的内在机制不是十分清楚。

今天这节课,我会带你了解闭包的原理和实现机制。在这个过程中,你会了解到闭包数据的形成机制、词法作用域的概念、闭包和面向对象特性的相似性,以及如何访问位于其他栈桢中的数据。

首先,让我们了解一下闭包的技术实质,从而确定如何让我们的语言支持闭包特性。

理解闭包的实质

我们先通过一个例子来了解闭包的特点。在下面的示例程序中有一个ID的生成器。这个生成器是一个函数但它把一个内部函数作为返回值来返回。这个返回值被赋给了函数类型的变量id1然后调用这个函数。

function idGenerator():number{//()=>number{
    let nextId = 0;

    function getId(){
        return nextId++;  //访问了外部作用域的一个变量
    }

    return getId;
}

println("\nid1:");
let id1 = idGenerator();
println(id1());  //0
println(id1());  //1

//新创建一个闭包,重新开始编号
println("\nid2:");
let id2 = idGenerator();
println(id2());  //0
println(id2());  //1

//闭包可以通过赋值和参数传递,在没有任何变量引用它的时候,生命周期才会结束。
println("\nid3:");
let id3 = id1;
println(id3());  //2

然后神奇的事情就发生了。每次你调用id1()它都会返回一个不同的值依次是0、1、2……

为什么每次返回的值会不一样呢?

你看这个代码内部函数getId()访问了外部函数的一个本地变量nextId。当外部函数退出以后内部函数仍然可以使用这个本地变量并且每次调用内部函数时都让nextId的值加1。这个现象就体现了闭包的特点。

总结起来,闭包是这么一种现象:一个函数可以返回一个内部函数,但这个内部函数使用了它的作用域之外的数据。这些作用域之外的数据会一直伴随着该内部函数的生命周期,内部函数一直可以访问它。

说得更直白一点,就是当内部函数被返回时,它把外部作用域中的一些数据打包带走了,随身携带,便于访问

这样分析之后你就明白了。为了支持闭包,你需要让某些函数有一个私有的数据区,用于保存一些私有数据,供这个函数访问。在我们这门课里,我们可以把这个函数专有的数据,叫做闭包数据,或者叫做闭包对象。

然后我们再运行这个示例程序,并分析它的输出结果:

图片

你会发现这样一个事实id1和id2分别通过调用idGenerator()函数获得了一个内部函数,而它们各自拥有自己的闭包数据,是互不干扰的。

从这种角度看,**闭包有点像面向对象特性。每次new一个对象的时候都会生成不同的对象实例。**实际上,在函数式语言里,我们确实可以用闭包来模拟某些面向对象编程特性。不过这里你要注意,并不是函数所引用的外部数据,都需要放到私有的数据区中的。我们可以再通过一个例子来看一下。

我把前面的示例程序做了一点修改。这一次,我们的内部函数可以访问两个变量了。

//编号的组成部分
let segment:number = 1000;

function idGenerator():()=>number{
    let nextId = 0;

    function getId(){
        return segment + nextId++;  //访问了2个外部变量
    }

    //在与getId相同的作用域中调用它
    println("getId:" + getId());
    println("getId:" + getId());

    //恢复nextId的值
    nextId = 0;

    return getId;
}

println("\nid1:");
let id1 = idGenerator();
println(id1());  //1000
println(id1());  //1001

//修改segment的值会影响到id1和id2两个闭包。
segment = 2000;

//新创建一个闭包,重新开始编号
println("\nid2:");
let id2 = idGenerator();
println(id2());  //2000
println(id2());  //2001

//闭包可以通过赋值和参数传递,在没有任何变量引用它的时候,生命周期才会结束。
println("\nid3:");
let id3 = id1;
println(id3());  //2002

你看我们增加了一个全局变量segment而内部函数最后生成的id等于segment + nextId。比如当segment=1000nextId=8的情况下生成的id就是1008。

并且我们在两个不同的地方调用了这个内部函数。一个地方是在idGenerator内部也就是声明getId()的函数作用域,而另一个调用则在全局程序中。在这两个情况下,函数访问变量数据的方式是不同的。

你能看到在idGenerator内部调用函数getId()时我们不需要为它设置私有数据区。为什么呢因为getId()运行时所处的作用域和声明它的作用域是一样的所以可以直接访问nextId和segment这两个变量。并且nextId和segmentid的生存期都超过了调用getId()的时间区间所以也不会出现像在第一个示例程序中idGenerator中的nextId变量会随着idGenerator运行结束而消失的情形。

但在外部来调用id1()、id2()的时候我们就需要把nextId变量放到私有数据区了。这是因为调用id1()和id2()所在的作用域与声明getId()的时候是不同的。在运行时的作用域中已经“看不到”nextId变量了所以必须把它放到私有数据区。不过在这个作用域仍然是能够“看到”segment变量的所以segment变量不需要放到私有数据区。

这样看起来,闭包现象跟作用域密切相关。所以这里我再引申一下,介绍一个术语,叫做词法作用域Lexical Scope,它的意思是声明一个符号时所在的作用域。对于函数来说,它总是在函数声明的作用域中绑定它所引用的各种变量。这样,我们以后不管在哪里执行这个函数,都会“记住”它们,也就是访问这个作用域中的变量的值。现代大部分语言使用的都是词法作用域。

词法作用域又叫做静态作用域。与静态作用域相对的呢是动态作用域。动态作用域中函数中使用的变量不是在语义分析阶段绑定好的而是可以在运行期动态地绑定。比如如果TypeScript使用的是动态作用域而我们在调用id1()、id2()之前声明了一个叫做nextId的变量那么id1()和id2()执行的时候其函数内部的那个nextId变量就会跟刚声明的这个nextId变量绑定。那这个时候也就不需要闭包了。

通过对比词法作用域和动态作用域,我希望你能加深对闭包的理解。对于学习了这门课程的你来说,理解它们的差别应该更容易。因为你已经清晰地知道,变量引用的消解都是发生在语义分析阶段,不会在运行期来改变这种引用关系。

最后在示例程序中你还会看到id3这个函数类型的变量并且我们把id1赋给了id3。id1和id3引用的是同一个闭包。所以虽然我们不再使用变量id1了但闭包所占用的内存并不会释放。并且你还可以把这个闭包以参数的方式传给其他函数甚至再被包含在其他闭包里。只要还有变量在使用这个闭包那它的内存就不会被释放。这一点跟面向对象特性中的对象也是完全一致的。

好了,我们花了不少的篇幅来分析闭包的实质。但这些分析不是白费的。基于这些分析,你就可以迅速拿出实现方案来。

现在,就让我们直接上手试试。按照惯例,我们仍然要先修改编译器的前端。

修改编译器前端

在编译器的前端方面,我们主要是做一些语义分析工作。

根据前面的分析,首先我们要分析出一个函数里引用的变量中,哪些是在函数之外声明的。这项工作比较简单,因为我们在引用消解的时候,已经能够把变量的引用和变量的声明建立起关联关系,并且能够知道每个变量是在哪个作用域里声明的。

你可以用“node play example_closure.ts -v”把示例程序的符号表打印出来看看每个变量所属的作用域。这样你就可以很容易地分析出内部函数所引用的外部变量。

图片

在完成第一项分析以后,我们还要做第二个分析。也就是说,我们要识别出哪些变量仍然能够被访问到,而哪些是不能的

这次分析是在调用外部函数并返回闭包的时候进行的。对于那些能够访问到的变量我们可以继续访问比如示例程序中的segment变量。而对于哪些已经不能够访问到的变量我们则要创建一个私有的数据区来保管它们比如示例程序中的nextld变量。

语义分析的参考实现,你可以参见semantic.ts

在语义分析之后我们继续来修改AST解释器让它支持闭包特性。

修改AST解释器

在AST解释器里为了让闭包正常运行我们需要让函数能够正确访问外部的变量。根据我们前面的分析这又分成两种情况。第一种情况是要支持函数的私有数据区用来保存nextId这样的变量。第二种情况是能够访问其他函数栈桢中的变量比如segment变量。

首先我们看看第一种情况,就是为闭包设计私有的数据区。

这里我设计了一个ClosureObject数据结构这个数据结构和PlayObject、StackFrame的设计相差不大都用了一个Map来保存变量的数据闭包函数可以从这里访问私有数据。

//闭包对象
class ClosureObject{
    functionSym:FunctionSymbol;
    data:Map<Symbol,any> = new Map();
    constructor(functionSym:FunctionSymbol){
        this.functionSym = functionSym;
    }
}

在示例程序的栈桢中id1和id3指向一个相同的ClosureObject而id2指向另一个ClosureObject。当给函数变量赋值时我们就把这个闭包对象赋给新的变量。闭包对象中包含了一个FunctionSym引用这样解释器就知道要运行哪个函数的代码了。

你能看出来使用ClosureObject跟使用PlayObject是很相似的。对于函数中的数据我们要区分出来哪些是位于函数作用域中的哪些是位于ClosueObject中的。而在运行对象方法时我们也是要区分方法的本地变量和存放在PlayObject中的对象属性。

好了现在我们设计完了闭包的数据对象。接下来我们看看如何让函数访问其他函数的栈桢中的变量比如访问segment变量。

这个时候函数的调用栈如下图所示。segment位于全局函数的栈桢中。而id1()所调用的函数逻辑需要先找到segment所在的函数的栈桢然后才能访问segment变量的值。可是如何能找到segment变量所在的栈桢呢

你可能会说,这还不简单,不就是当前栈桢的上一级栈桢吗?图中就是这么显示的呀。

事情没那么简单。你看我在原来的示例函数的基础上再增加了一点代码。我们增加了一个函数foo()在foo里调用idGenerator()来生成闭包。

function foo():()=>number{
    println("\nid4:");
    let id4 = idGenerator();
    println(id4());
    println(id4());
    return id4;
}

let id5 = foo();
println("\nid5:");
println(id5());
println(id5());

这个时候调用栈就变成了下面这样一共三级。你还可以再设计出更复杂的调用场景产生更多级的栈桢。所以id1()的上一级栈桢并不是segment变量所在的栈桢。

那如何找到segment变量所在的栈桢呢这就需要我们的栈桢能够跟函数关联起来让算法知道哪个栈桢是由哪个函数产生的然后就可以找到正确的栈桢了。

class StackFrame{
    //存储变量的值
    values:Map<Symbol, any> = new Map();
    
    //返回值,当调用函数的时候,返回值放在这里
    retVal:any = undefined;

    //产生当前栈桢的函数
    functionSym:FunctionSymbol;

    constructor(functionSym:FunctionSymbol){
        this.functionSym = functionSym;
    }
}

好了现在我们已经知道如何在AST解释器中支持闭包特性了。你可以查看play.ts中参考实现,研究一下其中的技术细节。

接下来我们再来分析一下,如何把闭包特性编译进可执行程序。

编译成可执行程序

我们先来实现一下闭包的私有数据区。

在这个技术特性上,我们完全可以借鉴面向对象特性的实现技术。我们可以把闭包函数,比作一个类的方法。至于闭包数据,我们就可以把它当做是对象的属性。

而当程序调用一个闭包函数的时候,它必须把闭包数据通过第一个参数传递进去,这个过程跟调用对象方法也是一样的。当调用对象方法的时候,对象引用也是第一个参数。

所以说,在有了实现面向对象特性的底子以后,我们再实现对闭包数据的管理,就会容易很多,可以充分借鉴原来的技术思路。

不过,要实现另一个特性,也就是从别的函数的栈桢里找到所引用的外部变量的数据,就没那么简单了。

按照AST解释器的实现逻辑我们需要知道哪个栈桢是由哪个函数生成的。可是我们马上就遇到了两个需要解决的技术点。

第一个技术点,是我们并没有在可执行文件里保存函数符号的信息。在编译后的可执行文件里,每个函数都只是一些机器码而已。在汇编代码中用来标识每个函数入口的标签,也消失不见了,因为它的作用已经完成了。它的作用,就是用于计算函数的入口地址而已。我们甚至可以说,在我们现在生成的机器码里,根本就没有任何关于函数的概念。

这个问题是可以解决的。我们可以在可执行程序的数据区保存程序的符号信息。在研究C++程序生成的汇编代码中我们已经看到C++会在可执行文件里保存一些与类型有关的信息以及vtable。我们可以采用相同的技术手段保存我们自己想保存的信息。

实际上如果我们要调试程序那就要往可执行文件里保存很多符号信息和调试信息否则没办法知道栈桢和寄存器里的哪个数据对应的是哪个变量。所以当我们用debug模式生成可执行文件时要比release模式的可执行文件大很多。

**第二个技术点,是我们并没有在原来的栈桢里保存对函数的引用信息。**所以,我们就难以搞清楚哪个栈桢是哪个函数生成的,也很难从中找到相应的变量。这个技术点也是可以解决的,这需要我们往栈桢里添加额外的信息,来建立栈桢和函数之间的对应关系。

不过要实现这两个技术点涉及的代码工作量有点大我们在这节课的参考实现里就不包含这个特性了你可以先自己动手试试。不用担心这点我会在课程完结前补上。同时在我们后面再迭代的、作为开源项目的PlayScript代码库中也会添加这个特性届时你再可以对照着看看。

课程小结

今天的内容就是这些。为了掌握闭包的特性,你需要记住以下几个知识点:

首先,闭包的产生,是由于声明时所引用的外部作用域中的变量,在运行时的作用域中并不存在,所以我们需要一个专门的数据区来保存这些数据。

第二TypeScript采用的是词法作用域也就是在语义分析阶段就把变量的使用和声明做了绑定并且不再改变。这是需要闭包机制的根本原因。

第三,闭包特性跟面向对象特性有很多相似之处,闭包数据就类似于对象数据。我们调用闭包函数的时候,也要把闭包对象的引用传递给函数,类似于调用对象的方法。

第四,如果要访问其他函数的栈桢内的数据,我们需要记录每个栈桢是由哪个函数生成的。

思考题

今天的思考题,我想让你分享一下你使用闭包特性的场景,以及为什么要使用闭包特性。欢迎你在留言区留言。

欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。

资源链接

这节课的代码目录在这里!