JavaScript函数式编程思想

978-7-115-49993-6
作者: 潘俊
译者:
编辑: 张爽

图书目录:

详情

本书由浅入深地介绍了函数式编程的思想、模式和特点,例如运算符的函数化、方法的函数化、语句的函数化,面向对象编程与函数式编程的对比,等等。分析如何在JavaScript中有效运用函数式编程,注重从本质和内在逻辑的角度解释各个主题,并辅以相关的代码演示。

图书摘要

版权信息

书名:JavaScript函数式编程思想

ISBN:978-7-115-49993-6

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    潘 俊

责任编辑 张 爽

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书主要介绍了函数式编程的基础理论、核心技术、典型特征和应用领域,以及它与面向对象编程的比较。本书既广泛介绍函数式编程的思想,也结合JavaScript的特点分析其应用和局限,注重从本质和内在逻辑的角度解释各个主题,并辅以相关的代码演示。对于函数式编程涉及的JavaScript语言本身的特性,以及与面向对象编程的比较,在书中也给予了重点讨论。

本书适合希望学习函数式编程的JavaScript程序员阅读,对一般的函数式编程理念感兴趣的读者也可以将本书作为参考。


伴随着Web技术的普及,JavaScript已成为应用最广泛的编程语言之一。由于其在Web前端编程中的统治地位、语言本身的表现力、灵活性、开源的本质和ECMAScript标准近年来的快速发展,JavaScript向各个领域渗透的势头仍然强劲。函数式编程的思想和语言原来仅仅在计算机学术圈中流行,近年来它的魅力越来越多地被主流软件开发行业所认识到。Scala、Closure等语言的出现,C#、Java等语言中引入函数式编程的功能都是这一趋势的体现。

传统的JavaScript开发主要使用命令式和面向对象的编程范式,并零星地结合了一些函数式编程的技巧。通过系统地介绍函数式编程的思想和技术,展现它在提高代码的表现力、可读性和可维护性等方面的益处,本书希望能让更多的JavaScript程序员了解并喜欢上这种优美而高效的编程范式。

本书内容共分为9章。

第1、2章介绍了与JavaScript函数式编程所用技术紧密关联的名称和类型系统的理论。

第3章简要介绍了函数式编程的理论基础:lambda演算和JavaScript中函数的相关知识。

第4、5章介绍了函数式编程的基础和核心技术:一等值的函数、部分应用和复合。

第6章介绍了函数式编程的典型特征:没有副作用的纯函数和不可变的数据。

第7章介绍了函数式编程中进行重复计算的递归模式。

第8章介绍了函数式编程的重要领域:列表处理。

第9章系统地比较了面向对象编程和函数式编程。

野人献曝,未免贻笑大方;愚者千虑,或有一得可鉴。书中的不足之处,敬请各位读者批评指正。

潘 俊  

2018年10月


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


一般对函数式编程的介绍都会从一等值和纯函数等概念开始,本书却准备在那之前先用一些篇幅讨论两个通常未得到足够重视的主题:名称和类型系统。前者包括名称绑定、作用域和闭包等内容;后者包括类型的含义和划分、强类型和弱类型、静态类型和动态类型,以及多态性的内容。理解这些概念对编程很有意义,无论是使用哪种语言,采用什么范式。具体到本书的核心,使用JavaScript进行函数式编程,在理解以上普适概念的基础上,掌握它们在JavaScript中的特定表现和行为,又具有格外的重要性。这一方面是因为JavaScript长期以来被认为是一种简单的脚本语言,缺少在通用知识背景下对其特性和行为的分析,以致对其行为的认识往往是零碎但实用的。另一方面是因为名称和类型系统与JavaScript的函数式编程有着紧密的关联。嵌套函数和闭包是JavaScript的函数式编程离不开的技术,鸭子类型是JavaScript借以实现函数式编程通常具备的参数多态性特征的机制。这些内容都将在下面两章中得到充分的讨论。

编程语言中有许多实体:常量、变量、函数、对象、类型、模块。从计算机的角度来看,所有这些都是用它们在存储器中的地址来代表的。要人们记住这些地址,并用它们来思考,当然是不可能的。就像在生活中和处理其他领域的问题一样,人们给编程语言中的实体以名称。所谓名称绑定(Name binding),是指将名称和它所要代表的实体联系在一起。编程语言中的名称通常又称为标识符(Identifier),它是字符序列,在许多语言中,能使用的字符种类会受到限制,例如JavaScript中的标识符只能由字母、数字、$和_组成,并且不能以数字开头。广义来说,编程语言中所有可用的名称都经过了绑定,包括在语言设计阶段绑定的关键字(如ifwhile)和操作符(如+.),我们这里关心的仅仅是程序员在代码中使用的标识符和它们所代表的实体之间的绑定。

名称绑定有3个要素:名称、实体和绑定。创建名称绑定因而也就包含3个动作:创建名称、创建实体和绑定。创建名称通过声明(Declaration)完成:声明变量、声明函数、声明类型等。实体的创建方式随其类型而变化,数字、字符等原始数据类型的值只需写出其字面值(Literal),更复杂的数据类型值根据所用语言的语法创建。绑定则通过给名称赋值完成。在有些场景中,创建实体和绑定会在创建名称后自动完成。例如在许多静态类型的语言里,声明的数字变量若不赋值,会初始化为0。在另一些场景中,创建名称、创建实体和绑定这3个动作是一并完成的。Java中声明类型和JavaScript中声明函数都属于这种情况。

名称和实体是相互独立的存在,它们之间的绑定也不是一一对应的。名称可以不绑定任何实体,如JavaScript中已声明但未赋值的变量;也可以同时绑定多个实体,如操作符和方法重载(JavaScript是弱类型的,所以没有方法重载的概念,目前也不支持自定义操作符重载,不过也用到了重载,+操作符就是如此)。反过来,实体可以不绑定任何名称,如表达式中未被赋值的对象;也可以同时绑定多个名称(这种现象称为别名Aliases),如通过赋值多个变量都指向同一对象。

在程序运行过程中,名称和实体都有自己的生存期(Lifetime),从创建开始,到销毁结束,两者并不一定重合。实体可以延续比某个名称更长的时间。例如调用函数时传入某个对象参数,在该函数内参数名和对象之间构成绑定,到函数返回时名称失效,但在调用该函数的代码里对象仍然生存。在特殊情况下,名称的生存期也可能比其中的实体更长。例如在C++中可以手工销毁对象,在以引用方式传递对象参数时,若在被调用者内销毁该对象,调用者内关于该对象的名称还依然存在,此时的名称就成为悬空指针(Dangling reference)。

通常名称所绑定的实体是可以被更换的,这样的名称被称为变量,其绑定的实体称为变量值,通过赋值来更换变量值。程序中经常会用到一些固定的值,如引力常数、颜色代码、给用户提示的字符串,将它们绑定到专门的名称有诸多好处:有意义的名称可以充当所绑定值的注释;多次使用某个值时,用名称不容易输入错误,即使发生错误,编程语言的编译或运行环境也能发现和报告;一旦需要更换,用名称时只需修改一处,否则需要找出代码中所有使用该值的地方并修改。对于绑定这些值的名称,在程序运行过程中更换它们的值是不需要也不应该的。所以许多编程语言引入了称为常量的名称,它们的值只能在声明时绑定,之后不允许更换。

需要注意的是,常量的值虽然不能被更换,但并不能保证它不会发生改变。假如常量值是简单数据类型的,如布尔值、数字,那么确实不可能改变;假如常量值是复合类型的,如JavaScript中的对象,那么虽然不能通过赋值更换为其他对象,但是可以修改它的属性值。只有字符串等不可变的复合数据类型,才能够确保常量值不发生改变。关于简单和复合数据类型,将在第2章中介绍。关于数据的不变性,将在第6章中讨论。在ECMAScript 2015之前,JavaScript只能通过var语句声明变量。ECMAScript 2015新增了const语句以声明常量。

变量依据其与所绑定的值的关系,可以分为值模型(Value model)和引用模型(Reference model)。采用值模型的变量,可以看作值的容器,赋给变量的值就保存在容器中。变量值被更改,就在变量读写的位置就地完成。采用引用模型的变量,则是指向它所绑定的值的指针或者引用。变量值被更改,既有可能是变量被赋予新的指针,也有可能是指针不变,而它指向的数据发生变化。

变量采取的模型对其被赋值时的行为有很大影响。一个变量的值被赋予另一个变量,采取值类型时,值会被复制,副本保存在被赋值的变量中,两者彻底无关,不会相互影响;采用引用类型时,只是指针被复制,赋予第二个名称,数据仍只有一份,若是一方修改了指针指向的数据,另一方也能看到同样的变化。前者更安全,后者对于体量巨大的数据则节省了复制的时间和空间。

有些编程语言(如Java、C#)针对不同的数据类型采取不同的模型:布尔值、数字等简单类型,占用的空间很小,采用值模型;动态数组和映射等复合类型,占用的空间可能很大,采用引用模型。在这些语言中,又可以把采用值模型的数据类型称为值类型(Value type),把采用引用模型的数据类型称为引用类型(Reference type)。有些编程语言则采取统一的变量模型,C语言中所有的变量都是值模型的,不过可以通过指针来实现引用类型的行为。而动态类型的语言因为变量可以被赋予任何类型的值,大多采用单一的引用模型,如Lisp、Smalltalk、JavaScript。

注意数据的值类型、引用类型和传递参数时的按值和按引用方式是不相干的概念,将在3.3.3节中阐释。

一个程序,可以从静态和动态两个角度来观察。前者是用空间的[1]维度,分析程序的代码;后者是以时间的维度,研究程序的运行。上一节提到名称的生存期,就是采用时间的维度考察名称有效的问题。程序运行的每一时刻都对应着代码中的某一语句,因而名称在时间上的生存,也就对应着它在代码中空间上的有效。我们把名称在代码中有效的区域称为其作用域(Scope,该词在日常英语中的含义为范围,在这里也可用作动词,表示确定作用域的行为)。

一个自然的疑问就是,名称为什么会有有效性的问题?名称一旦声明,为什么不是在整个代码中都有效?这确实是一个选项,有些编程语言(如早期的BASIC)中的名称就是如此。但是可以想象,这种方案很快会给名称的创建和使用带来很大困扰。在整个程序中,任何名称的含义都是唯一的,在某处使用了numi,在另一处就要使用numberj,在下一处就要使用number2k。短小的名称很快就会用尽,代码里充斥的名称就会变得冗长难记。这不仅带来体力上的输入麻烦,更严重的是它要求程序员在脑力上维持对整个程序用到的所有名称的关注,从而大大限制了一个程序所能达到的规模。此外,在代码的不同区域重新使用名称又是完全可行的。不同函数中绑定的变量,即使名称相同,含义也是不同的。所以对以上方案初步的改进是,将名称分为全局的(Global)和函数局部的两类。前者在整个程序中都有效,后者仅仅在声明它们的函数内有效。这就是ECMAScript 2015标准之前,JavaScript中名称面临的状况:在某个函数内用varfunction关键字声明的变量和函数的作用域是包围它们的函数,不在任何函数内声明的变量和函数的作用域是全局。

JavaScript在语法的外观上沿袭了很多C语言家族的惯例,最明显的就是用大括号包围的代码包块(Block)。ifwhilefor等控制流的结构,在形式上都与函数声明一样,有自己的包块。按照C语言家族的惯例,包块也是作用域的级别。也就是说,在包块中创建的名称只在该包块中有效,不同包块中可以使用相互独立的同样的名称,例如先后有两个for语句都声明i作循环变量而不会彼此干扰。ECMAScript 2015引入的分别用于声明变量、常量和类的letconstclass关键字,使得JavaScript也拥有了包块作用域。包块作用域背后的理念是将名称的有效范围局限在比函数更小的区域内,使得程序员在思考任何一段代码时,当前需要关注的名称所组成的集合尽可能的小。为了不影响历史代码,用var声明的变量作用域保持不变,而用function关键字声明的函数,作用域则发生变化。在ECMAScript 2015之前,在严格模式下,不允许在包块内声明函数;在非严格模式下,标准未规定包括内声明函数的意义,实际行为取决于具体的JavaScript引擎。在ECMAScript 2015之后,在严格模式下,包块内声明的函数的作用域是该包块;在非严格模式下,包块内声明的函数的作用域仍然是该包块所在的函数。ECMAScript 2015还为JavaScript增加了模块的语法,一个模块中声明的实体只在该模块中可见,需要通过导出导入语句才能为其他模块使用,因此在函数与全局之间有了模块层级的作用域。

有些编程风格提倡将一个函数内所有的变量声明都集中于函数顶部,显然这种做法享受不到包块作用域的好处。即使在没有包块作用域的语言中,就近声明变量也是更好的习惯,它使得变量的使用更靠近声明,从而令代码更易于理解。而且在顶部集中声明变量,要求程序员在编写函数的一开始就在脑海中列出所有用到的变量的清单,实行起来的难度也更高。

采用就近声明的风格时,用let取代var来定义变量,有可能会遇到一些小问题,见如下代码。

var condition = true;
if (condition) {
    var foo = 1;
    // ...
} else {
    // ...
}
foo = 2;

进入if语句后,发觉要声明一个变量foo,在if语句之后还会用到这个变量。这一段代码在语法上没有错误,现在改用let来声明变量。

if (condition) {
    let bar = 1;
    // ...
} else {
    // ...
}
bar = 2;

if语句之后使用的bar是未声明的变量,因为它已经超出了if语句中所声明bar的包块作用域,要令它可以使用,须将bar的声明上移到if语句之前。

let bar;
if (condition) {
    bar = 1;
    // ...
} else {
    // ...
}
bar = 2;

对于只有一个层次的if语句,预想到其中可能用到的变量提前声明,或许不是什么难事。但当编写嵌套的多个层次的if语句时,就近声明似乎显得更方便。比较下面两段代码。

var condition1, condition2, condition3;
if (condition1) {
    var foo1 = 1;
    // ...
    if (condition2) {
        var foo2 = 2;
        // ...
        if (condition3) {
            var foo3 = 3;
            // ...
        }
    } else {
        // ...
    }
} else {
    // ...
}
foo1 = 2, foo2 = 3, foo3 = 4;

let bar1, bar2, bar3;
if (condition1) {
    bar1 = 1;
    // ...
    if (condition2) {
        bar2 = 2;
        // ...
        if (condition3) {
            bar3 = 3;
            // ...
        }
    } else {
        // ...
    }
} else {
    // ...
}
bar1 = 2, bar2 = 3, bar3 = 4;

第二段代码的变量声明集中于最外层的if语句之前,看上去与在函数顶部声明变量的风格接近。如此比较,在有些场合,似乎沿用没有包块作用域的var变量声明更方便。细究其实不然。在第二段代码中,因为变量的有效范围限定在声明它的包块内,理解其中的任何一部分时,需要关心的变量要么声明在当前包块内,要么在包围它的上一级包块内,依次类推,不在这个上溯链条中的其他包块就可以被快速忽略。而第一段代码,在任何地方声明的变量都在整个函数内有效,要查找某个变量的声明和经历的变化,就要遍历所有的角落。习惯运用包块作用域,对于编写和理解代码都有益处。

前面讨论的确定作用域的方式称为静态作用域(Static scoping),静态指的是在程序运行前通过分析名称在代码中的相对位置,就能确定它的作用域。一般应用的规则为,名称在代码中有效的区域,就是它在其中被声明的包块。这个包块内可能有内嵌的包块,它们也都属于该名称的作用域。包块是一种通用的代码层次,模块、类型、对象、函数、控制结构等都能使用,甚至还可以创建不依附于这些实体的、仅仅用于分隔代码或获得独立作用域的包块。内嵌包块中如果声明了一个在外套包块中已声明的名称,则在该内嵌包块以及它可能包围的更深层次的包块中,该名称使用的是内嵌包块中的声明。这种现象称为隐藏(Hide)或遮盖(Shadow,内嵌包块中的声明遮盖了外套包块中的声明)。

从静态作用域简明的规则,可以反向推导出对于代码中任何地方出现的某个名称,如何确定其含义(即它在何处被声明)的规则:首先在该名称所处的包块内查找其声明,若未找到,则在上一级外套包块中继续,直到最外层包块之外的全局代码区域;假如还未找到,就发生了引用未声明名称的错误。简而言之,名称使用的是最靠近它的包块中的声明。几乎所有编程语言都有一些内置的(Built-in)实体,例如用于输入输出的例程、数学计算的函数以及基本的数据类型。这些实体同样被绑定到对应的名称上,只不过给它们命名的不是使用这些语言的程序员,而是发明这些语言的程序员。我们可以想象全局代码区域和其中内嵌的包块一样,外面还套有一个虚拟的包块,在这个包块中声明了编程语言内置的实体。这样静态作用域的规则就不仅适用于代码中自定义的名称,还扩展到能覆盖编程语言内置的名称。并且在有些语言中,程序员还能重新声明这些内置的名称,将它们在虚拟包块中的声明覆盖。

与静态作用域相对的是动态作用域(Dynamic scoping)。采用这种方式时,名称的作用域取决于程序的控制流。原则上来说,某个名称的作用域依然是它在其中被声明的区域,如包块、函数,不过当这个区域中有函数调用时,作用域将扩展到该函数的代码,对任何进一步的函数调用也是如此。假如被调用的函数中声明了同一个名称,在该声明的区域以及其中进一步调用的函数内,将使用这一较新的声明。对照静态作用域的规则,动态作用域将包块嵌套换成了函数调用,视角从空间的代码结构变成了时间的程序运行。

同样,从动态作用域的定义,可以反向推导出在代码中任何地方出现的某个名称,如何确定其含义(即它在何处被声明)的规则。名称的含义就是程序运行中最近一次对它做出的并且此时尚未失效(超出包块范围或从函数返回)的声明。由于在阅读代码时无法预知函数的调用顺序,所以对于那些不在当前函数内声明的名称,不能确定其含义。这不仅给理解代码带来困难,而且容易产生错误——非局部声明的名称在程序运行时读取或写入的值出乎程序员的预料,而这类错误是很难纠正的。正因为如此,很少有编程语言会采用动态作用域。

JavaScript使用的是静态作用域,下面的代码既可以验证这一点,也可以展现两种作用域方式的区别。

let x = 1;

function f() {
    let x = 3;
    g();
}

function g() {
    //读取变量x。
    console.log(x);
    //写入变量x。
    x = 2;
}

f();
//=> 1
console.log(x);
//=> 2

变量x分别在全局和函数f内声明,f调用函数g,其中分别读取和写入x。编写这段逻辑的语言若是采用静态作用域,则函数g内读写的x指向的都是最靠近它的包块(此处为全局)中的声明,输出如代码所示。假如语言用的是动态作用域,函数g内读写的x指向的就是最近调用它的函数f中的声明,最后的输出将分别是3和1。

值得一提的是,传统JavaScript编程中常用的this关键字的含义取决于代码被调用的方式,在不同的情况下可能指全局对象、新创建的对象、当前对象和事件的发布对象。假如以作用域的理论来解释它,显然这个名称的作用域不是静态的,因为通过分析代码无法确定某处this的含义。它和一般的遵循动态作用域的名称也不完全相同,因为根据动态作用域的规则,一个名称假如在某个函数中被声明,它的含义就由该声明决定,否则它的含义继承自该函数的调用者。而this绑定的含义由其所在代码的调用方式决定,但又与调用者中它的含义无关,对函数而言,相当于在每次调用时在函数顶部隐式地声明该名称。也就是说,动态作用域的名称毕竟可以找到人为的声明,这是它含义的源头,this的含义则不只是动态的,还是由语言指定的,在JavaScript中类似的关键字还有对应于函数接收到参数的arguments

在介绍静态作用域时,我们把包块当成一个整体,似乎在其中声明的名称自动就在整个包块内有效。而实际上包块内的语句出现是有先后顺序的,出现在某个名称的声明之后的语句自然可以引用该名称,可出现在它之前的会如何呢?不同的编程语言有不同的做法,而且对变量和函数声明的做法还可能不一样。例如C语言要求变量和函数的定义都在使用之前,Java语言在声明类的字段时和对方法内的局部变量要求在使用前声明,对使用对象的成员(字段和方法)则不要求声明在前。声明在前看上去是一个自然的要求,但有些场合也会有在一个名称声明前就使用它的需要,这种现象称为前向引用(Forward reference)。例如两个名称相互引用,最典型的就是两个函数相互递归调用;又或者按照抽象层次从高到低的顺序排列函数,函数的声明就总是出现在调用它之后。为了解决这些需求,不同的语言有不同的做法。我们此前一直用的声明(Declaration)一词是可以和定义(Definition)互换的,C语言对两者做出了区分,定义给出实体(如函数、结构体)的细节,声明确定名称的作用域,这样就可以将某个实体的声明置于使用它的代码之前。Module-3采用的就是本节开头所述的理念,在一个包块中声明的名称在整个包块内有效。Python干脆省略了变量声明,可以直接使用。

总的看来,在还需要声明的前提下,变量先声明后使用更易于理解,函数和类型则是前向引用更方便。因为变量属于代码的细节,对它们的使用往往是集中的,跟随代码是有先后顺序的;而函数是独立抽象的实体,它们被声明的顺序和实际被调用的先后没有关系。

JavaScript中的各种实体在能否前向引用方面的情况各异。ECMAScript 2015之前,用var声明和function语句声明的变量和函数都能被前向引用,在JavaScript中有一个专门术语——提升(Hoist),即对变量和函数的声明就像分别被提升到所在作用域的顶部。ECMAScript 2015之后,var语句仍然能提升变量,用function语句声明的函数提升情况比较复杂:在严格模式下,函数的作用域是包块,在作用域内会被提升;在非严格模式下,内嵌函数的作用域仍然是包围它的函数,但是它只会被提升到最靠近它的包块顶部。

//非严格模式下foo没有被提升到它所在的函数顶部。
foo();
//=> ReferenceError: foo is not defined
{
    //foo被提升到包围它的包块顶部。
    foo();
    //=> foo
    //在内嵌包块中声明的函数。
    function foo() {
        console.log('foo');
    }
}
//foo的作用域仍然是包围它的函数。
foo();
//=> foo

其他分别用新增的letconstclass语句声明的变量、常量和类不会被提升,必须先声明再使用。

名称绑定和作用域这两个概念看上去有些普通,远没有闭包(Closure)引起的兴趣和疑问多。没有函数式编程经验的人,在初次接触到JavaScript的闭包概念时,大多会觉得这是一个很新奇的东西,一时无法理解它的效果,也体会不了有经验的程序员所说的它带来的好处。而实际上如果掌握了名称绑定和作用域,就会发现闭包的出现是水到渠成的。

在程序运行中的某一刻或代码中的某一处,所有当前有效的名称组成的集合被称为此刻或此处的引用环境(Referencing environment)。当不针对某个名称时,我们把代码中引用环境保持不变的区域也称为作用域。这个意义上的作用域与前面讨论的名称的作用域是息息相关的,假如用前者来解释后者,它就是代码中所有作用域相同的名称所在的区域。根据名称作用域的规则,在全局代码中的某一处,引用环境就是全部全局名称组成的集合。在一个全局函数内,引用环境包括所有的局部名称、参数和全局名称。

JavaScript的函数与C语言的一个巨大区别就是前者可以嵌套,也就是说一个函数可以声明在另一个函数内。一个内嵌函数的引用环境包括它自身所有的局部名称和参数、外套函数的局部名称和参数,以及所有的全局名称。我们在本书后面会看到,嵌套函数是JavaScript编程中必不可少的写法,许多模式和技巧都是依赖它才得以成立的。

内嵌函数需要能访问外套函数的引用环境,当内嵌函数在它的作用域内被直接调用时,满足这个要求是很平常的。但是JavaScript中的函数还可以作为参数和返回值,这时从内嵌函数的声明到调用它的代码,引用环境发生了改变。若还要访问原来的引用环境,就必须以某种方式将内嵌函数的引用环境和它捆绑在一起,这个整体就称为函数的闭包。很多有关JavaScript的文章在介绍闭包时,都把它定义为从某个函数返回的函数所记住的上下文信息。一个函数可能成为返回值,确实是建立闭包的有力理由。因为函数的局部名称都存在于调用堆栈中,若没有闭包,外套函数返回内嵌函数后,外套函数的堆栈帧被删除,返回的内嵌函数所能引用的外套函数中的局部名称也将消失。

function createClosure() {
    let i = 1;
    return function () {
        console.log(i);
    }
}

const fn = createClosure();
//若没有闭包,fn将无法引用createClosure的局部变量i。
fn();
//=> 1

但实际上闭包并不是只在函数被返回时才创建的,任何JavaScript的函数都是同它的闭包一同创建的。下面的代码不涉及返回函数,却显示了闭包的效果。

function f(fn, x) {
    if (x < 1) {
        f(g, 1);
    } else {
        fn();
    }

    function g() {
        console.log(x);
    }
}

function h() {
}

//假如没有闭包,此处的结果将会是1。
f(h, 0);
//=> 0

当函数g最终被调用时,参数x的值为1,但是g输出的x值为0。这是因为函数g使用的是它闭包中的x,而它的闭包是在声明函数时创建的,在第一次调用函数f时获得值0。等到f调用自身后再次进入其代码时,g的引用环境已经与声明它时的不同,参数x虽然名称相同,但与g闭包中的x是身份不同的值。

以上行为也可以用另一对概念来解释。上一节指出,在代码中遇到某个名称时,静态作用域使用的是空间上最近的声明,动态作用域使用的是时间上最近的声明。略加推敲会发现,在没有调用函数的情况下,代码的文本顺序和程序的执行顺序是一致的,空间上最近的声明就是时间上最近的声明,两种作用域方式的效果是相同的。假如所有的函数都在调用处声明,或者说在调用前内联化(Inline),也会导致同样的结果。两种作用域差别的关键就在于,函数的引用环境建立的时间,静态作用域是在函数声明时建立的,称为深绑定(Deep binding)[2];动态作用域是在函数执行时建立的,称为浅绑定(Shallow binding)。回看上面的代码,假如JavaScript采用的是浅绑定,函数g使用的就将是它执行时包围它的函数f的参数x,输出的结果将会是1。

在一个函数中,引用环境包括它的局部名称、参数和外套作用域中的名称(可能存在的外套函数的局部名称和参数,以及所有的全局名称)。容易看出,闭包只需记住外套作用域的部分,因为函数自身的局部名称在每次运行时都会重新创建。

总而言之,闭包对于静态作用域来说并不是什么新的概念,而是以函数为中心的视角来看待静态作用域,或者说是在函数可以被传递、返回的语言中为了贯彻静态作用域的理念而采取的一种实现层面的技术。如果不关心关于静态作用域如何实现的细节,完全可以忽略闭包的概念。因为仅仅就理念上理解和分析代码中名称的含义而言,掌握静态作用域的理论就足够了。本书后面有用到闭包术语的地方,也只是为了强调它反映出的视角和语境。

闭包虽然是在函数定义时就创建了,但并不意味着其中变量的值会停留在那一刻。只要闭包中的函数不是马上执行,程序的控制仍然保持在创建闭包的代码一方,闭包中的变量值就可能正常地改变,等到闭包中的函数执行时,它的引用环境中的变量值可能就不是最初设想的那样。下面这段为HTML元件批量添加事件处理程序的代码,就常被用来示范一种容易犯的错误。

var list = document.createElement('ul');
for (var i = 1; i <= 5; i++) {
    var item = document.createElement('li');
    item.appendChild(document.createTextNode('Item ' + i));

    item.onclick = function (e) {
        console.log('Item ' + i + ' is clicked.');
    };
    list.appendChild(item);
}

document.body.appendChild(list);

编写者的原意是创建5个li元件,在每个元件上单击时,分别从控制台输出的语句包含元件的编号。然而实际效果是,所有元件的输出都是“Item 6 is clicked.”。原因是,单击事件处理函数的闭包记住了变量i,但并不会记住i在闭包创建时的值,随着for循环执行完毕,i的值变为6,而这就是元件被单击时事件处理函数读取到的值。要解决这个闭包的效果导致的问题,可以再创建一个闭包。

var list = document.createElement('ul');
for (var i = 1; i <= 5; i++) {
    var item = document.createElement('li');
    item.appendChild(document.createTextNode('Item ' + i));

    item.onclick = function (j) {
        return function (e) {
            console.log('Item ' + j + ' is clicked.');
        };
    }(i);
    list.appendChild(item);
}

document.body.appendChild(list);

这个版本的事件处理函数是外套函数的返回值,它的闭包记住的是传递给外套函数的参数j,而这个参数值为for循环中变量i的当前值,不会再改变。上一节介绍了ECMAScript 2015引入的let语句和包块作用域,利用它们可以使上述代码更简洁。

let list = document.createElement('ul');
for (let i = 1; i <= 5; i++) {
    let item = document.createElement('li');
    item.appendChild(document.createTextNode('Item ' + i));

    item.onclick = function (e) {
        console.log('Item ' + i + ' is clicked.');
    };
    list.appendChild(item);
}

document.body.appendChild(list);

这段代码中的变量ifor语句中用let声明,作用域限于for语句的包块。初看上去似乎结果和第一个版本应该一样,i的值也发生了变化。实际得到的效果却是正确的,关键就在于这里的i和包块中声明的变量item在作用域和生存期上是一样的。在包块的顶部它们被创建,到包块的尾部失效,等到控制跳转回包块顶部进入下一次迭代时,iitem都重新被创建,只不过新的i自动继承了旧的i的值。也就是说在这个循环中,同名的变量i被创建了5次,它们分别被单击事件处理函数的闭包记住了。这个实例再次展现了包块作用域的用处。

本章的内容围绕编程语言中的名称,从名称的绑定开始,详细介绍了名称的作用域,包括决定作用域的两种方式、作用域的级别以及与之有关的变量的就近声明和前向引用问题,最后在静态作用域的基础上给出了对闭包的简明解释。下一章将介绍与JavaScript的函数式编程密切相关的另一个主题——类型系统。

[1] 英文中常用Lexical一词,在编程语言的语境中通常译为“词法的”,指的是从字符、单词的角度来考察代码的文本,用在中文中仍显生硬。本书中采用“文本的”或“空间的”说法,与“时间的”一词分别对应“静态的”和“动态的”。

[2] 有些文献中将“深绑定”定义为在函数被作为参数传递时绑定引用环境,JavaScript中的函数可以作为返回值被赋值给变量,也可以作为对象的方法被动态调用,所以函数被作为参数传递时才绑定引用环境是不能满足所有情况的需求的。


相关图书

HTML+CSS+JavaScript完全自学教程
HTML+CSS+JavaScript完全自学教程
JavaScript面向对象编程指南(第3版)
JavaScript面向对象编程指南(第3版)
JavaScript全栈开发
JavaScript全栈开发
HTML CSS JavaScript入门经典 第3版
HTML CSS JavaScript入门经典 第3版
HTML+CSS+JavaScript网页制作 从入门到精通
HTML+CSS+JavaScript网页制作 从入门到精通
JavaScript重难点实例精讲
JavaScript重难点实例精讲

相关文章

相关课程