第12章 函数式编程
第12章 函数式编程
函数式编程的本质就是一切皆函数,函数可以作为另外一个函数的输出或输入,一系列的函数使用最终会形成一个表达式链,通过这个表达式链可以最终求得一个值,而这个过程即为计算的本质。在函数式编程中,会发现代码中存在大量的连续运算。
函数式编程已经在实际应用中发挥着巨大作用,更有越来越多的语言不断地加入对诸如闭包、匿名函数等特性,从某种程度上来讲,函数式编程正在逐步同化命令式编程。本章将详细讲解JavaScript函数式编程的基本方法和技巧。
【学习重点】
▲ 了解函数式编程
▲ 使用表达式进行运算
▲ 掌握递归运算
▲ 掌握闭包函数
▲ 应用JavaScript函数式特性编写复杂程序
12.1 函数式编程概述
JavaScript是一门优美的语言,具有动态性、弱类型,也有C和LISP的双重语法。JavaScript虽然是基于对象编程,但对象不是第一型的,而函数是第一型的。
当然并不是支持函数的语言都是函数式语言,函数式中的函数除具有基本模块封装的功能外,还应该具有如下3个特性。
提示:从运算的角度分析,编程语言可分为3类:命令式、函数式和逻辑式。
☑ 命令式语言,也称为面向对象,面向对象是命令式的包装,如C、C++、Java、C#等。
☑ 函数式是基于数学函数的语言,函数式程序设计始于LISP,著名的函数式语言Scheme是LISP的分支。
☑ 逻辑式只有Prolog一种语言,主要用于人工智能。
12.1.1 函数是第一型
JavaScript是基于对象的语言,但对象(Object)不是第一型,而函数(Function)是第一型。第一型(First-class Data Types)表示第一类数据类型。
与一般语言中的数据类型概念相比,第一型也可以说是基础类型,相当于元数据,是指在语言中用来组织、声明其他类型的基础。
【示例】下面是一个为数组扩展的方法,为数组内每个元素应用指定的函数运算。
上面代码是命令式编程常用思路,如果使用函数式编程,则代码如下:
通过函数式设计之后,整个程序的代码仅有两个表达式,非常简洁。
12.1.2 函数是运算元
运算元表示运算的单元,大部分语言都支持将函数作为运算单元参与运算。不过从本质上分析,它们的运算方式不同,且运算效果也不同。
在命令式语言中,函数作为指针参与运算,而不是函数本身。但是在函数式语言中,函数作为一个值直接参与到表达式的运算中。对于函数指针的运算可以包括赋值、调用和地址运算。但是在JavaScript中,不仅可以把函数作为一个对象进行引用,还可以把它作为一个值进行计算。
在JavaScript中,函数可以作为参数使用,传递的是函数值,或者函数的引用,但没有地址概念。由于彻底杜绝了地址运算,也就杜绝了系统风险。函数调用实质上就是一个普通的运算,因此所谓传入参数可以被理解为运算元。对于传入参数,函数只有运算元的概念而没有地址的概念,函数参数与普通参数并没有什么不同。
12.1.3 函数是数据闭包
在函数式编程中,存在大量的连续运算,在连续运算中会产生大量数据,这些数据如何相互传递,又如何临时寄存?
函数由于自身的封闭性,它具有处理和存储数据的先天条件。在某些命令式语言中也有类似的性质,但与函数式语言存在根本不同。在命令式语言中,由于代码总是在代码段中执行,不具有可写性,因此函数中的数据只能是静态数据,函数内的私有变量也是不能被保存的。从程序执行的方式来讲,在函数执行结束后,所占用的资源被释放。因此函数内的数据不可能被保存。
在JavaScript的函数中,函数内的私有变量可以被修改,而且能够延迟释放,当下次进入函数时,局部变量保存的数据依然存在。
【示例】本示例演示如何使用函数临时寄存运算数据,并通过函数来改变内部数据。
函数f()内的变量set和get都是全局变量,可以在函数体外访问它们,从而实现间接访问函数内部变量的目的。当函数f()被调用之后,其内部变量依然存在,这个特性可以通过接口(Interface)向外暴露系统,或者通过读写器(get&setter)访问对象属性。由于在这种对象系统中,对象向外部系统展现的都是接口方法,从而有效地避免了外部系统直接修改对象成员。
12.2 表达式运算
函数式语言的核心是运算,整个程序被大量长短不一的表达式所充斥,弱化了语句和结构在代码中的地位。连续运算求值的基本设计模式如下:
☑ 表达式参与运算,并产生结果。
☑ 结果不会直接返回,而是进一步参与运算。
☑ 运算的最终结果,只是返回一个值。
12.2.1 连续运算
JavaScript提供大量运算符,这为连续运算奠定了基础。
【示例1】最常见的就是连续赋值运算。
var a=b=c=1;
上面代码相当于声明3个变量,并全部初始化值为1。
【示例2】三元运算符(?:)在连续运算中扮演了重要角色,使用它能够代替分支结构。
event ? event : window.event;
该表达式相当于下面分支结构:
【示例3】三元运算符不仅能够代替简单的分支结构,还能够代替多重分支结构,从而发挥连续运算的特性。
上面是一个多条件的分支结构,利用三元运算符把它转换为一个复杂的表达式,从而实现连续运算。为了便于阅读,可以对表达式进行格式化编排,换行时应注意语义性问题,避免JavaScript误解代码。如果使用多分支结构表示,则代码如下:
从形式上分析,连续运算的代码比较经济;从运行上分析,连续运算的结果还是可以继续参与到其他表达式中,作为一个运算元来使用的,而对于多条件分支结构是无法实现这样的目标的。
【示例4】除了分支结构可以转换为表达式外,对于对象、函数和方法调用都可以作为运算元,参与到表达式的运算中去。
上面代码是一个简单的函数声明与调用的示例。如果使用表达式来表示,则可以使用如下形式来实现:
上面表达式直接使用函数调用运算符为匿名函数传递参数进行计算。
【示例5】如果是多层嵌套函数,则可以使用多个小括号进行连续调用。
上面表达式是一个3层嵌套的函数结构,然后直接在最外层函数通过小括号进行调用,参数在中间小括号中进行传递。上面的表达式如果转换为命令式语句,则代码如下:
【示例6】本示例演示如何使用表达式来创建对象的过程。
上面示例中利用三元运算符连续运算判断变量o的值,然后使用new运算符创建相应对象,最后通过点运算符调用toString()方法把新创建的对象转换为字符串。转换的字符串作为一个参数传递给alert()函数显示出来。
提示:用户可以清除小括号运算符,只要遵循运算符的优先级,就能够确保运算正常、有序地执行。例如,点运算符的优先级要低于new运算符,不使用小括号来进行分隔,JavaScript同样遵循先创建对象,然后再调用对象方法的逻辑顺序。为了更好地显示结构的逻辑层次和顺序,把示例代码以命令式的格式进行书写,实际上很多连续运算都可以在一行内完成:
12.2.2 把命令转换为表达式
表达式运算本质上是值运算,即求值运算。任何复杂的对象(如Object、Function、Array等),从运算的角度来分析,其实都是系统对值的一种理解而已。由于运算只产生值,因此可以把所有命令式语句都转换为表达式,并进行求值。
【示例1】12.2.1节提及三元运算符可以把分支结构转换为表达式,用户也可以使用布尔型表达式来转换分支结构。针对12.2.1节的多分支结构示例,则可以使用多个逻辑表达式来执行连续运算。
上面代码主要利用逻辑运算符“&&”和“||”来执行连续的运算。对于逻辑与运算来说,如果运算符左侧的运算元为true,才会执行右侧运算元的计算,否则就会忽略右侧的运算元;而对于逻辑或运算来说,如果运算符左侧的运算元为false,才会执行右侧运算元的计算,否则就会忽略右侧的运算元。
逻辑与和逻辑或的组合使用可以达到三元运算符的逻辑功能。这说明JavaScript中的逻辑运算本质上并非是为了布尔值计算而设计的,它实际上是分支结构的一种逻辑简化,从而为表达式的连续运算奠定了基础。
【示例2】对于分支结构来说,有两种途径可以实现连续运算,那么对于循环结构也可以通过递归运算实现连续运算的目的。
上面的循环结构可以使用如下的递归函数来表示:
对于上面的代码还可以使用如下嵌套函数进行进一步的封装,从而实现一个完整的表达式运算。
提示:使用函数来转换循环结构,会存在内存溢出风险,这是一种低效策略。由于函数递归运算需要为每次函数调用保留私有空间,因此会消耗大量的系统资源。不过使用尾递归可以避免此类问题。
不用循环和分支结构,其他子句也就没有存在的价值,如流程控制中的子句break和continue,以及标签语句等。同时,函数式语言可以不使用寄存器,只需要值声明,而不需要变量声明,所以在函数式语言中,变量声明语句也是不需要的。总之,在函数式语言中,除了值声明和函数中的返回子句外,其他语句都可以省略。
12.2.3 表达式中的函数
在表达式运算中,求值是运算的核心。函数作为表达式中的一个运算元,也具有值的含义。不管函数内部结构多么复杂,最终返回的只是一个值。因此,用户可以在函数内封装各种逻辑。
例如,在函数中包含循环语句来执行高效运算。这样就间接实现了把语句作为表达式的一部分直接参与到连续运算中来,这对于在特殊环境下只能够使用表达式连续运算来说是一个不错的选择,如浏览器地址栏内仅能够运行表达式代码等。
【示例1】在IE浏览器的CSS中支持expression()运算函数,该函数能够实现CSS技术的脚本化控制。
【示例2】本示例是一个连续运算的表达式,该表达式是一个逻辑复杂的分支结构,并在分支结构中包含函数结构体,以判断两种表达式的大小,并输出提示信息。可以看到,整个代码是在无命令语句的情况下完成的任务,与命令式语言风格迥然不同。
【示例3】本示例使用函数封装复杂的循环结构,并让它直接参与到表达式运算。
上面代码把两个嵌套的循环结构封装在函数体内,从而实现连续求值的目的。因此,使用连续运算的表达式可以设计足够复杂的系统。
【示例4】这种连续运算的表达式也存在一定的调试风险,对于如此复杂结构的表达式,阅读和调试将是一个极大挑战。例如,在没有上面代码的提示下,很难简单看明白下面一行表达式的逻辑和语义。
用户应该养成良好的编码习惯,良好的结构和代码组织能够降低代码的复杂度。对于函数式编程来说,实现代码的良好组织,使用函数应该是最有效的方法之一。
对于长表达式,特别是逻辑结构非常明显的表达式,应该对其进行格式化。从语义上分析,函数的调用过程实际上就是表达式运算中求值的过程。从这一点来看,在函数式编程中,函数是一种高效的连续运算的工具。如对于循环结构来说,使用函数递归运算会存在很大的系统损耗,但是如果把循环语句封装在函数结构中,然后把函数作为值参与表达式的运算,实际上也是高效实现循环结构表达式化。
12.3 递归运算
递归是数学运算中一种重要的方法,也是一种重要的编程技巧。本节将讲解如何在JavaScript中实现各种类型的递归运算。
12.3.1 认识递归
递归不仅是一种算法,也是一种重要的思想。很多问题也只有使用递归的思想才可以求解。例如,数学上常用的阶乘函数、幂函数和斐波那契数列,以及汉诺塔问题。
递归算法的原理是:递归是函数对自身的调用。
递归有两种形式:直接调用和间接调用。如果在调用函数f()的过程中,又要调用函数f(),则为直接函数调用;如果在调用函数f1()的过程中,又要调用函数f2(),而在调用函数f2()的过程中,又要调用函数f1(),如图12-1所示,则称为间接调用。
任何一个有意义的递归总是由两部分组成的:递归调用和递归终止条件。递归运算在无限制的情况下,会无终止地自身调用。显然,程序不应该出现这种无休止的递归调用,而只应出现有限次数的、有终止的调用。为此,一般在递归运算中要结合if语句来进行控制。只有在某种条件成立时才可以继续执行递归调用,否则就不再继续。
在以下3种情况下,利用递归求解问题是非常有效的。
1.问题的定义是递归的
【示例1】数学上常用的阶乘函数、幂函数和斐波那契数列。以阶乘函数为例,已知其定义如图12-2所示。
图12-1 函数间接相互调用演示效果图
图12-2 阶乘算式示意图
对于这种递归定义的函数,可以使用递归过程来求解:
在这个过程中,利用分支结构把递归结束条件和需要继续递归求解的情况区分开来。对于比较复杂的问题,如果能够分解为若干个相对简单且解法相同或类似的子问题,那么当这些子问题获得解决时,原问题自然也就获得解决,这是一个递归求解的过程。
在一般情况下,这种求解方法被称为“分治策略”。当分解后的子问题无须分解就可以直接解决时,则停止分解,直接求解该子问题,我们把这些可以直接求解的子问题叫做递归结束条件。递归定义的函数都可以通过使用递归过程来编程实现,递归过程直接反映了定义的结构。
2.问题所涉及的数据结构是递归的
问题本身虽然不是递归定义的,但是它所用到的数据结构是递归的。
【示例2】文档树就是一种递归的数据结构,下面使用递归运算来计算指定节点内所包含的全部节点数:
3.问题的解法满足递归的性质
有些问题最适合采用递归的方法求解,例如,Hanoi(汉诺)塔问题。
递归算法的正确性可以用数学归纳法来证明,因为数学归纳法是递归和递归过程求解问题的理论基础。可以说,递归的思想来自数学归纳法。
12.3.2 案例:Hanoi(汉诺)塔算法
Hanoi(汉诺)塔,也称河内塔,这个问题源于印度一个古老的传说。传说开天辟地的神勃拉玛在一座庙里留下了3根金刚石的柱子,第一根套着64个圆的金片,最大的金片放在最下面,其余一个比一个小,依次往上叠。庙里的众僧要不知疲倦地把它们一个个从这根柱子搬到另一根柱子上,规定可利用中间的一根柱子作为辅助,但每次只能搬一个金片,而且大的金片不能放在小的金片上面。面对庞大的数字,移动金片的次数需要18 446 744 073 709 551 615次,众僧们耗尽毕生精力也不可能完成金片的移动工作。后来,这个传说就演变为汉诺塔游戏。
汉诺塔问题简单概括就是:将大小不同的金片通过第二个柱子作为中介从第一个柱子移动到第三个柱子上,要求每次移动都不能出现大的金片压住小的金片的现象,如图12-3所示。
图12-3 汉诺塔演示示意图
根据这个命题,分解为3个前提条件。
☑ 条件一:有3根柱子(如A、B、C)。A柱子有若干个金片。
☑ 条件二:每次移动一个金片,小的只能叠放在大的上面。
☑ 条件三:把所有金片从A柱子全部移到C柱子上。
经过研究发现,汉诺塔的**很简单,就是按照移动规则向一个方向移动金片。例如,如果是3个金片的汉诺塔移动,则可以采用如下操作步骤(箭头表示移动的位置)。
A→C、A→B、C→B、A→C、B→A、B→C、A→C
此外,汉诺塔问题也是程序设计中的经典递归问题。设A柱上有n个金片,全部移到C柱上的程序算法分析如下。
☑ 如果n=1,则:将金片从A直接移动到C。
☑ 如果n=2,则:
(1)将A上的n-1(等于1)个金片移到B上。
(2)将A上的一个金片移到C上。
(3)将B上的n-1(等于1)个金片移到C上。
☑ 如果n=3,则:
(1)将A上的n-1(等于2,再假设其为n)个金片移到B(借助于C)。
① 将A上的n-1(等于1)个金片移到C上。
② 将A上的一个金片移到B。
③ 将C上的n-1(等于1)个金片移到B。
(2)将A上的一个金片移到C。
(3)将B上的n-1(等于2,再假设其为n)个金片移到C(借助A)。
① 将B上的n-1(等于1)个金片移到A。
② 将B上的一个金片移到C。
③ 将A上的n-1(等于1)个金片移到C。
到此,完成了3个金片的移动过程。
从上面分析可以看出,当n大于等于2时,移动的过程可分解为3个步骤。
第一步,把A上的n-1个金片移到B上。
第二步,把A上的1个金片移到C上。
第三步,把B上的n-1个金片移到C上。其中第一步和第三步是类似的。
当n=3时,第一步和第三步又分解为类似的3步,即把n-1个金片从一个柱子移到另一个柱子上,这里的n=n-1。显然这是一个递归过程,据此算法可编程如下:
运行结果如图12-4所示。
图12-4 汉诺塔演示效果图
12.3.3 案例:尾递归算法
尾递归(Tail Recursion)是针对传统递归算法的一种优化算法,它是从最后开始计算,每递归一次就算出相应的结果。也就是说,函数调用出现在调用函数的尾部,因为是尾部,所以就不用去保存任何局部变量,返回时调用函数可以直接越过调用者,返回到调用者的调用者。
【示例1】下面是阶乘的一种普通线性递归运算:
使用尾递归算法后,则可以使用如下方法:
当n=5时,线性递归的递归过程如图12-5所示。
图12-5 递归算式示意图
而尾递归的递归过程如下:
很容易看出,普通的线性递归比尾递归更加消耗资源,每次重复的过程调用都使得调用链条不断加长,系统不得不使用栈进行数据保存和恢复,而尾递归就不存在这样的问题,因为它的状态完全由变量n和a保存。
【示例2】从理论上来分析,尾递归也是递归的一种类型,不过它的算法具有迭代算法的特征。上面的阶乘尾递归可以改写为下面的迭代循环:
最后,把两种递归进行简单比较。
☑ 线性递归:f(n),返回值会被调用者使用。
☑ 尾递归:f(m,n),返回值不会被调用者使用。
尾递归由于直接返回值,不需要保存临时变量,所以性能不会产生线性增加。并且JavaScript解释器会将尾递归形式优化成非递归形式。
12.3.4 案例:Fibonacci(斐波那契)数列
1202年,意大利数学家Fibonacci(斐波那契)出版了《算盘全书》。在该书中提出了一个有趣的问题:如果一对兔子每月能生一对小兔(一雄一雌),而每对小兔在出生后的第三个月里,又能开始生一对小兔,假定在不发生死亡的情况下,由一对出生的小兔开始,50个月后会有多少对兔子?
在第一个月时,只有一对小兔子。过了一个月,那对兔子成熟了,在第三个月时便生下一对小兔子,这时有两对兔子。再过一个月,成熟的兔子再生一对小兔子,而另一对小兔子长大,有3对小兔子。如此推算下去,我们便发现一个规律,如表12-1所示。
表12-1 斐波那契数列推算
由此可知,从第一个月开始以后每个月的兔子总对数是:
1、1、2、3、5、8、13、21、34、55、89、144、233……,如果把上述数列继续写下去,得到的数列便称之为斐波那契数列。数列中每个数便是前两个数之和,而数列的最初两个数都是1。
根据上面数列的规律,则Fibonacci数列的JavaScript算法如下:
该算法简单明了,但是执行速度太慢,因为编译器是按如下方式进行计算的,如图12-6所示。
图12-6 递归算式示意图
从上面的递归展开式可以看出f(4)和f(3)都被计算了两次,而且递归函数以2的指数增长,所以当计算到30时就会变得非常慢。使用尾递归来重新设计这个算法,则代码如下:
这样的计算过程都是在每次进入递归函数时计算的(尾部),所以是一个线性增长。只要编译器允许,计算f(100)都非常迅速。尾递归一般都是在函数式编程中出现的,而且JavaScript解释器可以进行优化。针对上面的尾递归算法,当JavaScript解释器在解析时,会将尾递归形式优化成非递归形式。
12.3.5 递归算法的优化
递归虽然直观、方便,但不是高效的方法,它是影响JavaScript性能的一个杀手锏。主要是因为递归方法过于频繁的函数调用和参数传递。在这种情况下,若采用循环或递归算法的非递归实现,将会大大提高算法的执行效率。
从理论上来分析,所有递归程序都可以使用非递归程序来实现。这是因为递归程序的计算总能用树形结构来表示。递归计算从求树根节点的值开始,树根节点的值依赖一个或多个子节点的值,子节点的值又依赖下一级子节点的值,如此循环,直至树的叶子节点。叶子节点的值能直接计算出来,也就是递归程序的出口。
对于简单的递归计算(如尾部递归),可以直接使用循环结构来转换成非递归。对于复杂的递归,如递归程序中递归调用点有多个,就是树形结构中一个父节点的值会依赖多个子节点的值,这种递归转换成非递归通常需要借助堆栈加循环的方式。
从时间上来讲,如果递归调用点有多个,会由于中间计算结果没有被保存重用而导致大量的重复计算。例如,递归函数f(x)=f(x-1)+f(x-3),计算f(x)时,会先计算f(x-1)的值,在计算f(x-1)时会计算出许多子节点的值,这些值在计算f(x-3)时是可以重用的,但是当退出f(x-1)的调用后都被丢弃了,导致在计算f(x-3)时会重复很多计算。空间上,由于每次递归调用都要保留现场,递归变深时,树会迅速膨胀,占用的空间也会激增。
【示例1】把递归程序转换成非递归程序时,可以有固定的模式,将这个模式抽象成通用的模板,就有可能实现递归至非递归程序的自动转换。根据前面的分析,将递归计算用树形结构来表达,计算父节点时,会要求计算出其子节点的值。如果用一个堆栈来表示,则计算父节点时,会先将该父节点压入堆栈,待到其依赖的所有子节点的值都计算出时,再将其弹出并计算其值。在计算其子节点时依照相同的原理,直至遇到叶子节点。
函数memoizer()主要应用在那些返回整数的递归运算中。当然并不是所有的递归函数都返回整数,所以我们需要一个更加通用的memoizer()函数来处理更多类型的递归函数。第一个参数递归函数,第二个参数是缓存对象,为可选参数,因为并不是所有的递归函数都包含初始信息。在函数内部,将缓存对象的类型从数组转换为对象,这样这个版本就可以适应那些不是返回整数的递归函数。
shell()函数使用in运算符来判断参数是否已经包含在缓存中。这种写法比测试类型不是undefined更加安全,因为undefined是一个有效的返回值。
【示例2】在斐波纳契数列中:
执行fibonacci(40)这个函数,只会对原有的函数调用40次,而不是夸张的331 160 280次。memoization()对于那些有着严格定义的结果集的递归算法来说,执行效率会非常高。
12.4 闭包函数
闭包是JavaScript语言强大的特性之一,也是JavaScript程序设计的一个重点。创建闭包非常容易,有时会无意识创建多个闭包函数,大量无意识创建的闭包函数会在无形中拖累程序执行效果,尤其是在浏览器环境下。使用闭包的前提是必须理解闭包,本节将介绍闭包函数的特性、作用,以及基本应用。
12.4.1 认识闭包函数
闭包(Closure)是指词法表示包括不必计算的变量的函数,也就是说,该函数能够使用函数外定义的变量。闭包与函数有着紧密的关系,它是函数的代码在运行过程中的一个动态环境,是一个运行期的、动态的概念。
简单描述,闭包就是嵌套函数结构,在一个函数内定义的一个函数或函数表达式。作为闭包的必要条件,内部函数应该访问外部函数中声明的私有变量、参数或其他内部函数。当上述的两个必要条件实现后,此时如果在外部函数外调用这个内部函数,于是它就成为了闭包函数。
【示例】本示例是一个经典的闭包结构。
演示步骤说明如下。
(1)程序预编译之后,程序从第9行开始解析执行,创建执行环境,创建调用对象,把参数和局部变量、内部的函数转换为对象属性。
(2)执行函数体内代码。在第6行执行局部变量a的递加运算,并把这个值传递给对象属性a,同时内部函数动态保持与局部变量a的联系,也更新自己内部调用变量的值。
(3)外部函数把内部函数返回给全局变量c,实现内部函数的定义,此时c完全继承了内部函数的所有结构和数据。
(4)外部函数返回后(即返回值后,也即调用完毕),会自动销毁,内部的结构、标识符和数据也随之丢失。
(5)执行第10行代码命令,调用内部函数,此时返回的是外部函数返回时(销毁之前)保存的变量a所存储的最新数据值,即返回6。
如果没有闭包函数的作用,那么这种数据寄存和传递就无法得以实施:
通过上面示例可以很直观地看到,在没有闭包函数的辅助下,第8行代码执行后返回的值并没有与外部函数的局部变量a最后更新的值保持一致。
提示:闭包函数与函数有着紧密的联系,但它们还是存在很多不同。在JavaScript中,函数实际上仅是一段代码,也可以把它理解为静态文本。在被调用之前,函数仅是词法意思上的结构,没有实际的价值。包括在JavaScript解释器在预编译函数时,也仅是简单的分析函数的词法、语法结构,并根据函数标识符预定了一个函数占据的内存空间,其内部结构和逻辑并没有被运行。
但是,一旦函数被调用执行,则闭包体也会随之诞生。可以这样说,闭包是函数运行期中的一个动态环境,它是一个动态概念,与函数的静态性是截然不同的概念。由于每个函数都是一个独立的上下文环境(即执行环境),因此当闭包函数被再次执行或者通过某种方法进入函数体时,就可以获取闭包内包含的信息。两者的简单比较如表12-2所示。
表12-2 函数与闭包的比较
如果简单描述,闭包可以说是函数的数据包,存储数据。这个数据包在函数执行过程中始终处于**状态。当函数调用返回之后,闭包保存着与函数关联变量的动态联系。
闭包中存储的数据包含:函数运行实例的引用、环境表(即用来查找私有变量的表),以及由众多标识符构成的数组。在函数运行时,闭包可以实时访问上一级函数作用域中其他标识符的值。同一个函数中所有闭包都可以引用函数体内相同标识符的值,且相互影响。
12.4.2 使用闭包
初步认识了闭包后,下面通过几个示例介绍闭包的简单使用,以便能够更透彻理解什么是闭包,以及闭包的作用和用法。
【示例1】使用闭包结构能够跟踪动态环境中数据的实时变化,并即时存储。
在上面示例中,闭包中的变量a,其存储的值并不是从上面行变量a的值简单复制,而是继续引用外函数定义的局部变量a中的值,直到外部函数f()调用返回。
【示例2】闭包不会因为外部函数环境的注销而消失,并始终存在。
在上面示例中,普通函数f()中定义了3个闭包函数,它们分别指向并寄存局部变量a的值,并根据不同的操作动态跟踪变量a的值。
当在浏览器中预览时,首先应该单击“按钮1”,调用函数f(),将生成3个闭包,3个闭包同时指向局部变量a的引用,因此当函数f()返回时,3个闭包函数都没有被注销,而变量a由于被闭包引用而继续存在。这时如果直接单击“按钮2”、“按钮3”和“按钮4”,则由于没有在系统中生成闭包结构,则会弹出编译错误。
单击“按钮3”,则将动态递增变量a的值,此时如果单击“按钮2”,则会弹出提示值为2。如果单击“按钮4”,则向变量a传递值100,将动态改变闭包中寄存的值,此时如果单击“按钮2”,则会弹出提示值为100。
【示例3】如何利用闭包存储变量所有变化的值。
先看一个示例:
在这个示例中,函数f()的功能是:把数组类型的参数中每个元素的值分别封装在闭包结构中,然后把闭包存储在一个数组中,并返回这个数组。
但是,在函数e()中调用函数f(),并向其传递一个数组(["a","b","c"]),然后遍历函数f()返回数组,结果发现,数组中每个元素的值都是“c undefined”。
原来闭包中的变量temp并不是固定的,它会随时根据函数运行环境中的变量temp的值变化而更新,导致临时数组元素的值都是字符“c”,而不是“a”、“b”、“c”,同时由于循环变量i递增之后,最后的值是3,则x[3]超出了数组的长度,结果就是undefined。
解决方法:可以为闭包再包裹一层函数,然后运行该函数,并把外界动态值传递给它,当这个函数接收这些值,然后传递给内部的闭包函数,自己就注销了,从而阻断了闭包与最外层函数的实时联系。具体代码如下:
【示例4】利用同一个闭包体声明多个闭包。同一个闭包,通过分别引用,能够在当前环境中生成多个闭包。
12.4.3 闭包标识系统
在结构上比较,闭包函数与普通函数完全相同,它们都包含以下类型标识符。
☑ 函数参数(形参变量)。
☑ arguments属性。
☑ 局部变量。
☑ 内部函数名。
☑ this(指代闭包函数的调用对象)。
其中this和arguments是系统默认标识符,不需要特别声明。这些标识符在闭包体内的优先级是(其中左侧优先级要大于右侧):
this→局部变量→形参→arguments→函数名。
【示例1】本示例将在函数结构内显示函数结构的字符串。
【示例2】如果在函数f()中定义形参f,则同名情况下参数变量的优先权会大于函数的优先权。
【示例3】比较形参与arguments属性的优先级。
上面示例说明了形参变量会优先于arguments属性对象。
【示例4】比较arguments属性与函数名的优先级。
上面示例在JScript中会提示编译错误,不允许使用默认关键字来定义标识符的名称。
【示例5】比较局部变量和形参变量的优先级。
上面示例说明函数内局部变量要优先于形参变量的值。
【示例6】如果局部变量没有赋值,则会选择形参变量。例如:
当局部变量与形参变量重名时,如果局部变量没有赋值,则形参变量要优先于局部变量。
【示例7】本示例说明了当局部变量与形参变量混在一起使用时,它们之间存在的微妙关系。
如果从局部变量与形参变量之间的优先级来看,则var x=x左右两侧都应该是局部变量,由于x初始化值为undefined,所以该表达式就表示把undefined传递给自身。但是从上面示例来看,这说明左侧的是由var语句声明的局部变量,而右侧的是形参变量。也就是说,如果当局部变量没有初始化时,应用的是形参变量优先于局部变量。
12.4.4 闭包作用域和生命周期
JavaScript是一种动态语言,因此作用域和生命周期是该语言的重要特征。作用域决定了标识符的可见性(即在指定范围内可用)。从状态角度分析,作用域可以包括语法作用域和活动作用域。但是从使用角度分析,作用域可以包括表达式作用域、局部作用域和全局作用域3种类型。
☑ 使用var语句声明的全局变量,则在全局范围内可见。
☑ 不使用var语句在任意位置隐式声明的变量,则在全局范围内可见。
☑ 使用var语句在函数体内显式声明的变量,则仅在函数体内可见。
☑ 如果变量在函数体内有效,则在其所有内嵌函数中都有效。
【示例1】下面示例中参数变量x是函数f()的私有变量,该变量将在函数f()内部所有内嵌函数中都是可见的。
☑ 函数内声明的局部变量能够覆盖外部同名变量。
【示例2】本示例中,内部函数的同名局部变量会逐层覆盖,并显示最里层的变量值。
☑ 如果在内部函数中声明局部变量时,该作用域内所有引用外部同名变量值将被覆盖,初始化显示为undefined。
【示例3】函数内的私有变量具有较大的访问优先级。
通过语法分析可以看到,函数被解析时,会把内部的所有使用var语句声明的变量列入调用对象内的局部变量列表中,然后根据作用域链逐层上访变量。如果在当前作用域内发现了该变量,则会使用该变量,否则就会向上访问同名变量。如果变量为隐式使用,则将作为全局变量被列入全局作用域内的全局对象变量列表中。
JavaScript采用垃圾自动收集机制,如果对象不被引用时,使执行代码无法再访问到它,该对象就成为垃圾收集的目标。因而,在将来的某个时刻会将这个对象销毁并将它所占用的一切资源释放,以便操作系统重新利用。
JavaScript生存周期只有两个:函数内部局部执行期间和函数外全局执行期间。在正常情况下,当退出一个执行环境时就会满足类似的条件。此时,作用域链中的调用对象,以及在该执行环境中创建的任何对象(包括函数对象)都不可再引用,因此将成为垃圾收集的目标。
【示例4】在本示例中,调用外部函数f(),在执行环境中所创建的调用对象就不会被当作垃圾收集,因为该调用对象被一个全局变量b所引用,而且仍然是可以访问的,甚至可以通过b(n)来执行。同时,在这个被变量b引用的内部函数对象的作用域链中,包含属于创建该内部函数对象的执行环境的活动对象。
由于在执行被b引用的函数对象时,每次都要把该函数对象所引用的整个作用域链添加到内部函数创建的执行环境的作用域中,该作用域中包括内部执行环境的调用对象、外部执行环境的调用对象、全局对象。所以这个外部执行环境的调用对象也不会被当作垃圾收集。
由于调用对象受限于内部函数对象(现在被全局变量b引用)的作用域链引用,所以调用对象连同它的变量声明(即属性的值)都会被保留。而在对内部函数调用的执行环境中进行作用域解析时,将会把与调用对象的命名属性一致的标识符作为该对象的属性来解析。活动对象的这些属性值即使是在创建它的执行环境退出后,仍然可以被读取和设置。
在上面例子中,当外部函数返回(退出它的执行环境)时,在其调用对象的变量声明中依然记录了形参、内部函数定义,以及局部变量的值。x属性值为2,y属性值为3,局部变量a的值是1,还有一个e属性,它引用由外部函数返回的内部函数对象。
【示例5】对于闭包结构来说,主要依赖于函数实例被引用、释放引用和销毁的周期情况。
在上面示例中,函数g()访问了两个存在依赖的变量。其一,通过参数变量x引入了外部变量a,该外部变量a将在函数内被持有。由于参数仅作为函数内表达式的一部分参与运算,因此在闭包退出后,参数变量所绑定的外部变量a将被复位。因此,对于g(a);来说,函数被调用之后,即释放资源,从而说明外部变量a并没有被函数g()长期持有。
但是,对于变量e来说,由于该变量内闭包与外部变量a存在长期联系,从而导致如果闭包函数g()还存活时,外部函数f()也将存在。这对于动态语言的JavaScript来说,外部函数内的局部变量a可能会随时发生变化,同时函数g()也可能被其他变量所引用,因此当函数f()调用返回后,变量a也不会被清除。
【示例6】除了在闭包体内通过标识符显式引用外部函数的变量值,从而导致闭包与闭包的生存周期发生关联以外,还有一种情况会导致闭包之间相互关联。
在这个示例中,函数e()被函数g()中的局部变量y获得了一个引用。这与上面示例中传入的值数据,且参与表达式运算不同,它是对外部函数的引用,从而导致闭包与外部变量之间建立关联。
12.4.5 案例:比较函数调用和引用
函数引用与调用是两个不同的概念。当引用函数时,多个变量存储的是函数的地址。因此,对于同一个函数来说,不管有多少个变量引用该函数,它们的值都是相同的,即为该函数的入口指针地址。
例如,如果针对上面第二个示例来说,如果变量a和b都引用函数f(),而不是调用,则它们是完全相同的。相反,函数调用时是执行函数,并把返回的值传递给变量a和b。也就是说,变量a和b存储的是值,而不是函数的入口指针地址。
【示例1】本示例中,变量a与变量b完全相同,因为它们引用同一个函数。
【示例2】本示例中,变量a与变量b不完全相同,因为它们引用同一个函数的不同的调用对象。
函数引用和函数调用之间的比较和区别如图12-7和图12-8所示。
图12-7 函数引用示意图
图12-8 函数调用示意图
【拓展】在第一个示例中,如果变量a和b也都调用函数f,但是它们却相同(如下所示),而第二个示例调用却不相同。
这是因为在第一个示例中,函数调用后返回的是值类型数据(即为数值5),两个数值5自然是相同的。但是第二个示例中,函数调用返回值是一个闭包函数,即引用类型的数据。虽然返回的闭包结构是完全相同的,但是由于它们存储在不同变量中,即它们的地址指针是完全不同的,因此也无法相同。
【示例3】判断本示例中变量a和b是否相等?
结果是不相等的,alert(a===b);返回值为false,说明变量a与变量b不完全相同。
【示例4】判断本示例中变量a和b是否相等?
结果是不相等的,alert(a===b);返回值为false,说明变量a与变量b不完全相同。
通过new运算符可以克隆函数的结构,从而实现函数实例化的目的。实例化的过程实际就是对函数结构进行复制和初始化的操作过程。因此,当实例化函数F()并赋值给变量a时,a所引用的函数结构并非是原来函数的结构,而是内存运行区中另一块函数结构,自然它们是完全不相同的,如图12-9所示。
图12-9 函数实例化过程示意图
12.4.6 案例:比较闭包函数和函数实例
函数实例就是对函数结构的克隆,函数实例与原函数(初次定义函数)是两个不同的对象,因此应把函数实例与函数引用区分开来。实际上,闭包函数就是函数实例的一种应用形式。
【示例1】本示例是一个简单的函数结构,具有返回值,但是使用new运算符实例化该函数之后,实例对象a却无法访问:
如果在函数体内使用点运算符为函数定义属性,则可以通过点运算符来访问函数成员变量:
也就是说,函数实例是基于函数对象的基础上才能够有效访问的,当然使用new运算符能够对任意函数进行实例化。也就是说,只有构造函数才能够执行有效实例化操作。
【示例2】本示例分析函数实例与闭包函数的关系。
上面示例是通过构造函数的方法创建一个函数对象F(),对象中包含一个方法y。分别在变量a和b中实例化对象F(),则发现它们的实例并不相同,以及它们的方法也是不同的,说明a和b属于不同的实例,且它们的方法也是分别独立的闭包结构体。
提示:使用原型方法定义函数对象时,实例化对象及它们的方法存在不同的结果,这说明实例化的仅是对象结构本身。而对于对象的原型方法,则仅是引用,没有产生实例化对象方法,因此也就无从分析闭包的执行环境问题,如果用示意图来演示则如图12-10所示。
图12-10 原型对象实例化过程示意图
【示例3】使用不同的方法创建的函数对象,它们是否包含闭包结构还需要具体分析,这里主要观察函数对象被实例化之后,是否生成了实例化的方法。
【示例4】本示例说明了a和b是两个不同的闭包结构。但是如果从本质来分析,它们实际上也是两个具体的函数实例,而不是对函数的引用。
【示例5】在本示例中,a和b看起来似乎是两个不同的实例,实际上它们都是对同一个函数F()的引用,因此这里也就没有闭包环境了。
【示例6】本示例是一个多层嵌套的函数结构,函数f()内部嵌套了两层函数。调用函数f()会返回一个双层的闭包结构,且变量a和b分别引用的是不同函数实例,因此它们都属于不同的闭包结构。
【示例7】如果把上面结构调整一下,把函数f()修改为匿名函数并在表达式中直接调用,同时把内部的多层嵌套的函数分拆开来,则会发现a等于b。
首先,变量f存储着一个调用的函数表达式,编译之后自动生成一个匿名函数结构体,同时在函数结构体内又定义了一个匿名函数结构,此时可以称之为函数实例或闭包,并把它指向局部变量e。
然后,当这个匿名函数结构体被调用时,将返回的是变量e存储的值,该值是指向已经定义的匿名函数的入口指针地址,而不是匿名函数结构体本身。因此,在变量a和变量b中,我们看到的是相同的闭包结构的引用地址值,如图12-11所示。
图12-11 多层匿名函数嵌套过程示意图
【示例8】调整示例7的结构:
形成上面奇特的闭包引用结果的必要条件。
☑ 函数在调用之前已经被调用,这样在调用之前已经创建了调用对象。
☑ 函数内部已经定义了函数,而不是被调用之后才定义。这样返回的是定义函数的引用地址,而不是匿名函数体本身。
【示例9】下面改进示例可以帮助理解上面的两个条件:
闭包与实例实际上存在紧密的联系,可以说它们是两个相等的概念。函数实例是闭包结构的体现,而闭包也是实例的特殊结构。函数实例都拥有自己的闭包,但是也存在一个函数实例拥有多个闭包的特殊情况。
12.4.7 案例:比较闭包函数和调用对象
函数与对象都是复杂的引用型数据,函数的私有变量与对象的属性相似,私有变量其实就是函数的调用对象的属性。但是,函数与对象是完全不同的类型,函数有可执行的环境(上下文环境),对外不可见,而对象的上下文环境是静态的,对外是可见的。如果说对象是数据存储结构,那么函数就是代码执行空间。对象更像是仓库,而函数则像加工车间。
【示例1】下面的代码分别定义了一个函数f()和对象o。
通过上面函数和对象结构的比较,可以很直观地看出函数和对象的不同,两者的详细比较如表12-3所示。
表12-3 函数与对象的比较
闭包函数实际上就是嵌套函数,而调用对象就是当前调用函数的对象结构(Script Object),该对象存储着函数内部所有的数据,如参数变量列表、局部变量列表、内部函数列表,以及其他代码(可执行逻辑)等。
当函数被调用并开始执行时,所有局部变量初始化为undefined,但是由于函数在预编译时已经被解释器进行语法分析,所有局部变量、参数变量都已经被检索,此时访问局部变量不会发生未定义的语法错误。
【示例2】本示例的函数不是闭包,当调用函数之后,如果再次调用函数f(),函数将被再次初始化,等于重新创建了一个调用对象,第一次调用时创建的调用对象已被注销。
【示例3】本示例的函数是一个闭包,当函数被调用之后,其内部的变量不会被注销,变量值依然被保存在调用对象属性内,这种特性实际上就是闭包函数的基本特征。
函数f()在被调用后不会自动注销,因为其内部变量被外部函数引用到外部环境中,当函数调用完毕之后,调用对象依然存在,其内部的成员值依然能够保存着,不会被重置。
12.4.8 案例:保护闭包数据的独立性
先看一个示例,该示例设想在一个循环结构中通过闭包来自动更改一个数组内所有元素的值,代码如下:
运行结果发现,数组的值都相同,为最后一个元素下标数的平方。这是为什么呢?原来在遍历操作中,闭包中变量i的值共享外部变量i的值,它们都是对于同一个值的不同引用。
如果检测3个函数实例,结果发现它们并不相等,说明这3个函数实例是相互独立的:
也许,会认为何不直接使用表达式来为每个元素赋值呢?如下所示:
这种做法是正确的,但是在特定的环境中,要求必须赋值为函数体时,如为事件属性赋值,此时就必须使用闭包函数。如下:
在前面的示例中也曾经讲解过,解决这个问题的方法是为闭包函数再包裹一层函数体,因为我们都知道函数结构具有存储数据的天性。把局部变量作为参数传递给函数,则函数就会把该参数作为私有数据进行保护,从而防止闭包内的数据与外层数据建立动态联系。具体代码如下:
通过在闭包外面包裹一层函数来实现闭包数据的单独存储,也存在一定的问题。因为多了一层闭包函数,将增大系统的负担。不过,通过上面示例的代码也发现,每次遍历中,所定义的闭包结构都属于不同的函数实例,既然它们属于不同的结构体,我们可以把这个局部变量值交给函数实例自己保存,从而减少了一层闭包结构,减轻了系统的负担,这在大循环中效果非常明显。代码如下:
通过上面的示例可以看到,在循环体内定义匿名函数,在该函数体内获取函数属性value的值,然后在闭包体的底部定义函数的属性value的值为局部变量的值,通过这种方式把局部变量传递给函数实例的属性value,然后在匿名函数内调用函数属性即可实现保护闭包体内数据的独立性目标。
12.4.9 案例:定义构造函数闭包
Function()构造函数也能够定制闭包结构体。
构造函数闭包不管在什么位置创建,它都属于全局结构,即作为Function()构造函数的实例而存在。
【示例1】在下面这个示例中,在函数f()中定义一个构造函数实例e,然后在函数体调用该实例函数,则返回值为1,而不是局部变量2:
由于构造函数的参数为字符串,在编译期不会被解析,因此也不会与局部变量发生任何关系。但是由于构造函数是全局对象成员,作为全局对象的闭包只会引用全局变量的值。
因此,可以在复杂的函数嵌套结构中,通过构造函数创建闭包,从而使闭包体能够即时释放,避免闭包之间数据相互干扰。
【示例2】下面示例通过Function()构造函数来闭包数据不能够相互保持独立的特性。每次解析Function()构造函数时,会重新为函数实例存储数据,从而阻断了闭包数据之间的相互干扰性。如下:
使用Function()构造函数创建函数,系统就不会维护多个闭包,也不会在函数实例中绑定多余的成员,这样就避免了闭包泛滥所带来的资源灾难,避免内存外溢,关于这个问题还会在后面的小节中进行详细讲解。当然,由于Function()构造函数的参数必须是字符串,这在一定程度上限制了它的应用范围,因为在解析Function()构造函数的参数时,首先要把所有变量都转换为字符串,使用字符串作为参数有时会改变变量的原来意图,特别是引用类型的变量。
【示例3】使用Function()构造函数可以产生多个函数实例,但是却不会产生多个闭包,从而降低系统维护多个闭包所占用的资源。如下:
上面的用法可以使用闭包函数来表示,但是它将会产生多个闭包体,从而占用大量系统资源。如下:
12.4.10 案例:闭包存储器
JavaScript函数式编程的最大功能应该属于闭包了。闭包无所不能,它能够帮助JavaScript开发任意的前台应用。不怕做不到,就怕想不到,闭包就有这样的神奇功能,让在不经意间发现它的可爱和魔力,下面结合几个实例讲解它的应用奥妙。
闭包常见的用法就是为要执行的函数提供参数。例如,为事件属性传递动作,为定时器函数传递行为等。这在Web前端中是非常常见的一种应用。
【示例1】下面看一个示例:
预定义函数setTimeout()用于有计划地执行一个函数,或者一串JavaScript脚本,要执行的函数是其第一个参数,第二个参数是以毫秒表示的执行间隔。也就是说,当在一段代码中使用setTimeout()函数时,需要将一个函数的引用作为它的第一个参数,而将以毫秒表示的时间值作为第二个参数。但是,传递函数引用的同时就无法调用函数。类似下面的用法是错误的:
然而,可以在代码中调用另外一个函数,由它返回一个对内部函数的引用,再把这个对内部函数对象的引用传递给setTimeout()函数。执行这个内部函数时要使用的参数在调用外部函数时进行传递,这样,setTimeout()函数在执行内部函数时,不用再传递参数,但该内部函数仍然能够访问在调用外部函数时传递的参数。
【示例2】再看一个示例,该示例演示了如何使用闭包作为值来进行传递。当文档加载完毕后,会自动弹出一个提示对话框。其中正是利用闭包来实现向Window对象的onload属性传递一个闭包函数的,从而实现动态调用的效果。如下:
闭包还可以用于创建额外的作用域,通过该作用域可以设计动态数据管理器。利用动态数据管理器将相关的和具有依赖性的代码组织起来,以便应对复杂的交互操作。
例如,预设计一个字符串动态生成函数。该函数的功能是,把所有字符单元存储在一个数组中,通过数组方法把它们连接在一起并返回为一串字符串。如果说仅就一个数组进行操作,这种静态的、单一的处理就没有实际意义了。现在的问题是,我们希望数组中部分元素的值是动态的,然后把这个动态数组的元素值连接在一起生成一个字符串。
如果每次生成字符串时,都重新定义数组,那么也就没有必要去研究了,直接把数组作为参数传递给函数即可。现在我们希望仅更新数组的部分元素值,该如何是好呢?
一种解决方案是将这个数组声明为全局变量,这样就可以重用这个数组,而不必每次都建立新数组。但这个方案的结果是,除了引用函数的全局变量会使用这个缓冲数组外,还会多出一个全局属性引用数组自身。如此一来,不仅使代码变得不容易管理,而且,如果要在其他地方使用这个数组时,开发者必须要再次定义函数和数组。这样一来,也使得代码不容易与其他代码整合,因为此时不仅要保证所使用的函数名在全局命名空间中是唯一的,而且还要保证函数所依赖的数组在全局命名空间中也必须唯一。
【示例3】通过闭包可以使作为缓冲器的数组与依赖它的函数关联起来,实施优雅的打包,同时也能够维持在全局命名空间外指定的缓冲数组的属性名,免除了名称冲突和意外交互的危险。代码如下:
其中关键的技巧就在于通过执行一个函数表达式创建一个额外的执行环境,而将该函数表达式返回的内部函数作为在外部代码中使用的函数。此时,缓冲数组被定义为函数表达式的一个局部变量。这个函数表达式只需执行一次,而数组也只需创建一次,就可以供依赖它的函数重复使用了。
上面的示例设计一个数组,该数组包含10个元素,其中最后5个元素的值都是静态的,每次创建动态字符串时,仅希望更新前面5个元素的值。这时就使用了闭包作为一个特殊作用域,然后该作用域与外部函数中的局部数组变量关联在一起。这样每次调用时,只需要动态向闭包函数传递动态更新的值,然后由闭包结构把更新的值传递给数组,并把数组生成为字符串返回即可。
12.4.11 案例:事件处理中闭包应用
下面再看一个闭包的典型应用。很多时候我们希望引用一个函数后能够暂停执行,因为在复杂的环境中不等到被执行时是很难知道其具体参数的,而先前被引用时更是无法预知所要传递的参数。
【示例】希望为页面中特定的元素或标签绑定几个事情,使其能够在鼠标经过、离开和单击时呈现不同的背景颜色,代码如下:
但是,现在还无法预知所要控制的元素。也许,可以定义一个函数,通过参数形式来定位预控制的标签,然后调用该函数即可。如下:
但是,这种做法比较原始,使用JavaScript函数来封装与特定DOM元素的交互。如果创建与不同DOM元素关联的任意数量的JavaScript对象,每个对象实例并不知道实例化它们的代码将会如何操纵它们。也就是说,把注册事件处理函数与定义相应的事件处理函数分离。我们不妨使用这种方法。
这个函数用于创建将自身与DOM元素关联的对象,DOM元素的标签名作为构造函数的字符串参数。所创建的对象会在相应的元素触发onclick、onmouseover或onmouseout事件时,调用相应的方法。如下:
也就是说,把每种事件处理的函数分离出来,单独定义,这样就能够实现代码的优化。这时会发现,由于事件属性只能够接收函数结构,而无法直接传递参数。为此定义一个事件处理程序,能够把事件函数与实例对象关联在一起。在下面的代码中,使用一个返回闭包函数的方法,把外部指定的函数对象,以及要绑定的方法进行封装和转换,从而实现复杂条件下轻松处理事件处理问题。
☑ 功能:把对象和方法捆绑为一个事件处理的函数。
☑ 参数:o表示调用对象的实例(即触发函数),m表示该对象的事件处理方法。
☑ 返回:闭包函数,该内部函数将把对象实例和方法封装为事件处理函数,并传递必要参数。
其中第3行代码表示,在支持标准DOM规范的浏览器中,事件对象会被解析为参数e ,如果是IE浏览器,则使用IE的事件对象来规范化事件对象。
第4行代码表示,事件处理器通过保存在字符串变量m中的方法名调用了对象o的一个方法,并传递已经规范化的事件对象和触发事件处理器的元素的引用this,this在这里指代该元素。
最后,完整的示例代码如下:
演示效果如图12-12所示。
图12-12 使用闭包把对象与事件处理关联在一起演示
12.4.12 综合案例:设计折叠面板
本案例将设计一个可折叠的面板,效果如图12-13所示。折叠面板默认显示为展开效果,当使用鼠标单击标题栏时,则折叠面板以动画形式逐步收起内容框,再次单击标题栏,内容框又会以动画形式缓慢展开。
图12-13 可折叠面板演示效果
这是一个简单的DOM界面应用效果,如果不考虑代码封装和优化,可以直接为标题栏绑定鼠标单击事件,然后在事件处理函数中通过条件语句设计内容框的显示或隐藏,动画设计部分可以参考第17章脚本化CSS编程。
本案例的目的不仅仅要完成这个折叠动画的任务,而是通过这个案例为大家展示闭包在Web开发中的具体应用,同时提供一种更具通用的应用模式,解决UI动画设计的基本套路。案例借助闭包,把动画设计与管理封装在一个匿名函数内,并让匿名函数自执行,从而形成一个独立的、封闭的上下文环境,在该函数内声明一个全局类型函数animateManage(),并为该类型函数扩展大量原型方法,以便实现动画的高效管理。
☑ 设计闭包体
本例模式模仿jQuery结构。定义一个自执行匿名函数,并把全局对象window传入函数,然后在函数体内通过window.animateManage定义一个全局函数,并为该构造函数animateManage()定义原型对象。
实际上,上述写法完全可以转换为:
var animateManage=function(optios) {} animateManage.prototype={}; (function (window, document, undefined) {})(window, document)
但是,本例写法能够实现在animateManage()构造函数或其原型对象中访问外部匿名函数的私有变量。由于函数animateManage()内部没有引用外部匿名函数的私有变量:
且使用new运算符调用它,创建实例化对象:
new animateManage({});
因此,外部匿名函数暂时还不是一个闭包体,调用完毕后自动被销毁。但是,animateManage()的原型对象包含很多方法,这些方法内部与外部匿名函数的私有变量保持联系。作为全局作用域上的对象,这些原型对象就构成了对外部匿名函数的引用,确保外部匿名函数被调用后,依然存在,从而形成一个持久存在的闭包体。
☑ 设计动画管理类
animateManage()是一个动画管理的类型函数,参数optios表示一个参数对象,可以设置如下属性。
▶ context:被操作的元素上下文。
▶ effect:动画效果的算法函数。
▶ time:效果的持续时间。
▶ starCss:元素的起始偏移量。
▶ css:元素的结束偏移量。
animateManage()包含多个原型方法,具体说明和完整代码如下:
☑ 应用动画管理类型
设计好动画管理的工具类型,就可以在具体案例中应用了。在本示例中,为标题栏标签<dt id="header">绑定鼠标单击事件,在事件处理函数中,实例化animateManage()类型,并传入参数对象,然后调用init()方法,开始执行动画。完整代码可以参考本节实例源代码。
12.5 案例实战
下面通过几个案例介绍函数式编程中几个经典应用,以提高用户灵活使用函数的能力,加深对函数应用技巧的体悟。
12.5.1 惰性求值
在JavaScript中,使用函数式风格编程,应该对于表达式有着深刻的理解,并能够主动使用表达式的连续运算来组织代码。
☑ 在运算元中,除了JavaScript默认的数据类型外,函数也作为一个重要的运算元参与运算。
☑ 在运算符中,除了JavaScript的大量预定义运算符外,函数还作为一个重要的运算符进行计算和组织代码。
函数作为运算符参与运算,具有非惰性求值特性。非惰性求值行为自然会对整个程序产生一定的负面影响。先看下面这个示例:
在上面示例中,两次调用同一个函数并传递同一个变量,所返回的值却不一样。在第一次调用函数时,向其传递了两个参数,第二个参数是一个表达式,该表达式对变量a进行重新计算和赋值。也就是说,当调用函数时,第二个参数虽然不用,但是也被计算了。这就是JavaScript的非惰性求值特性。就是不管表达式是否被利用,只要在执行代码行中,都会被计算。
如果在一个函数参数中无意添加了几个表达式,虽然这样不会对函数的运算结果产生影响,但是由于表达式被执行,就会对整个程序产生潜在的负面影响。
在惰性求值语言中,如果参数不被调用,那么无论参数是直接量,还是某个表达式,都不会占用系统资源。但是,由于JavaScript支持非惰性求值,问题就变得很特殊了。
function f(){} f(function(){while(true);}())
在上面的示例中,虽然函数f()没有参数,但是在调用时将会执行传递给它的参数表达式,该表达式是一个死循环结构的函数值,最终将导致系统崩溃。
惰性函数模式是一种将对函数或请求的处理延迟到真正需要结果时进行的通用概念。很多应用程序都采用了这种概念,从惰性编程的角度来思考问题,可以帮助消除代码中不必要的计算。
【示例】在Scheme语言中,delay特殊表单接收一个代码块,它不会立即执行这个代码块,而是将代码和参数作为一个promise存储起来。如果需要promise产生一个值,就会运行这段代码。promise随后会保存结果,这样将来再请求这个值时,该值就可以立即返回,而不用再次执行代码。这种设计模式在JavaScript中大有用处,尤其是在编写跨浏览器的、高效运行的库时非常有用。例如,下面是一个时间对象实例化的函数。
上面的示例使用全局变量t来存储时间对象,这样在每次调用函数时都必须进行重新求值,代码的效率没有得到优化,同时全局变量t很容易被所有代码访问和操作,存在安全隐患。当然,用户可以使用闭包隐藏全局变量t,只允许在函数f内访问。
这仍然没有优化调用时的效率,因为每次调用f()依然需要求值:
在上面的示例中,函数f()的首次调用将实例化一个新的Date对象并重置f到一个新的函数上,f在其闭包内包含Date对象。在首次调用结束之前,f的新函数值也已被调用并提供返回值。
函数f()的调用都只会简单地返回t保留在其闭包内的值,这样执行起来非常高效。弄清这种模式的另一种途径是,外部函数f()的首次调用是一个保证(promise),它保证了首次调用会重定义f为一个非常有用的函数,保证来自于Scheme的惰性求值机制。
12.5.2 记忆
函数可以利用对象去记住先前操作的结果,从而能避免无谓的运算。这种优化被称为记忆。JavaScript的对象和数组要实现这种优化是非常方便的。
【示例】使用递归函数计算fibonacci数列。一个fibonacci数字是之前两个fibonacci数字之和。最前面的两个数字是0和1。
返回下面值:
0: 0 1: 1 2: 1 3: 2 4: 3 5: 5 6: 8 7: 13 8: 21 9: 34 10: 55
在上面代码中fibonacci函数被调用了453次,其中循环调用了11次,它自身调用了442次,去计算可能已被刚计算过的值。如果使该函数具备记忆功能,就可以显著减少它的运算次数。
先使用一个临时数组保存存储结果,存储结果可以隐藏在闭包中。当函数被调用时,先看是否已经知道存储结果,如果已经知道,就立即返回这个存储结果。
这个函数返回同样的结果,但是它只被调用了29次,其中循环调用了11次,它自身调用了18次,去取得之前存储的结果。当然我们可以把这种函数形式抽象化,以构造带记忆功能的函数。memoizer()函数将取得一个初始的memo数组和fundamental函数。memoizer()函数返回一个管理memo存储和在需要时调用fundamental函数的shell函数。memoizer()函数传递这个shell函数和该函数的参数给fundamental函数。
现在,就可以使用memoizer()来定义fundamental函数,提供初始的memo数组和fundamental函数。
通过设计能产生其他函数的函数,可以极大减少必要的工作。例如,要产生一个可记忆的阶乘函数,只须提供基本的阶乘公式即可。
12.5.3 套用
套用是JavaScript函数一个很有趣的应用。所谓套用就是将函数与传递给它的参数相结合,产生一个新的函数。在函数式编程中,函数本身也是一个值,这种特性允许用户以有趣的方式去操作函数值。
【示例】在下面代码中定义一个add()函数,该函数能够返回一个新的函数,并把参数值传递给这个新函数,从而实现连加操作。
当然,也可以为JavaScript扩展一个curry()方法,实现函数的套用应用。
curry()方法通过创建一个保存原始函数和被套用函数的参数的闭包来工作。该方法返回另一个函数,该函数被调用时会返回调用原始函数的结果,并传递调用curry()时的参数加上当前调用的参数的所有参数。curry()使用Array的concat()方法连接两个参数数组。但是arguments数组并非一个真正的数组,所以它并没有concat()方法,要避开这个问题,必须在两个arguments数组上都应用数组的slice方法,这样才会产生出拥有concat()方法的常规数组。
下面就来应用curry()方法,通过curry()方法调用add()函数,会返回一个新的函数add1(),在这个新的返回函数中保存了调用add()函数时传递的值,当调用add1()函数时,将新旧函数的参数进行相加,返回7。
12.5.4 模块化
使用函数和闭包可以构建模块。所谓模块,就是一个提供接口却隐藏状态与实现的函数或对象。通过使用函数构建模块,可以完全摒弃全局变量的使用,从而规避JavaScript语言缺陷。全局变量是JavaScript的最为糟糕的特性之一,在一个大中型Web应用中,全局变量简直就是一个魔鬼,会带来无穷的灾难。
【示例1】要为String扩展一个deentityify()方法,其设计任务是寻找字符串中的HTML字符实体并将其替换为对应的字符。在一个对象中保存字符实体的名字及与之对应的字符是有意义的。
也许可以把deentityify放到一个全局变量中,但全局变量是魔鬼。也许可以把deentityify定义在该函数本身中,但是这会带来运行时的损耗,因为在该函数每次被执行时,这个方法都会被求值一次。理想的方式是将deentityify放入一个闭包,而且也许还能提供一个增加更多字符实体的扩展方法。
在上面代码中,为String类型扩展了一个deentityify()方法,它调用String对象的replace()方法来查找以'&'开头和以';'结束的子字符串。如果这些字符可以在字符实体表entity中找到,那么就将该字符实体替换为映射表中的值。deentityify()方法用到了一个正则表达式:
在最后一行使用()运算符立刻调用刚刚构造出来的函数。这个调用所创建并返回的函数才是deentityify()方法。
document.writeln('<">'.deentityify()); //<">
模块利用了函数作用域和闭包来创建绑定对象与私有成员的关联。在这个示例中,只有deentityify()方法才有权访问字符实体表entity这个数据对象。模块开发的一般形式是:一个定义了私有变量和函数的函数,利用闭包创建可以访问到的私有变量和函数的特权函数,最后返回这个特权函数,或者把它们保存到可访问的地方。
使用模块可以避免全局变量的乱用,从而保护信息的安全性,实现优秀的设计实践。使用这种模式也可以实现应用程序的封装,或者构建其他实例对象。
模块模式通常结合实例模式使用。JavaScript的实例就是用对象字面量表示法创建的,对象的属性值可以是数值或函数,并且属性值在该对象的生命周期中不会发生变化。模块通常作为工具为程序其他部分提供功能支特。通过这种方式能够构建比较安全的对象。
【示例2】下面代码构造一个用来产生***的对象。serial_maker()函数将返回一个用来产生唯一字符串的对象,这个字符串由两部分组成:字符前缀+***。这两部分可以分别使用set_prefix()和set_seq()方法进行设置,然后调用实例对象的gensym()方法读取这个字符串。执行该方法,都会自动产生唯一一个字符串。
seqer包含的方法都没有用到this或that,因此没有办法损害seger,除非调用对应的方法,否则无法改变prefix或seq的值。seqer对象是可变的,所以它的方法可能会被替换掉,但是替换后的方法依然不能访问私有成员。seqer就是一组函数的集合,而且这些函数被授予特权,拥有使用或修改私有状态的能力。如果把seqer.gensym作为一个值传递给第三方函数,这个函数就能通过它产生唯一字符串,却不能通过它来改变prefix或seq的值。
12.5.5 柯里化
柯里化是把接收多个参数的函数变换成接收一个单一参数的函数,并且返回一个新函数,这个新函数能够接收原函数的参数。
【示例】下面可以通过例子来帮助理解。
函数adder()接收一个参数,并返回一个函数,这个返回的函数可以像预期的那样被调用。变量add5保持着adder(5)返回的函数,这个函数可以接收一个参数,并返回参数与5的和。柯里化在DOM的回调中非常有用。
函数柯里化的主要功能是提供了强大的动态函数创建。通过调用另一个函数并为它传入要柯里化(currying)的函数和必要的参数。说白点就是利用已有的函数,再创建一个动态的函数,该动态函数内部还是通过已有的函数来发生作用,只是传入更多的参数来简化函数的参数方面的调用。
在curry()函数的内部,私有变量args就相当于一个存储器,用来暂时存储在调用curry()函数时所传递的参数值,这样再和后面动态创建函数调用时的参数合并并执行,就得到了一样的效果。
函数柯里化的基本方法和函数绑定是一样的:使用一个闭包返回一个函数。两者的区别在于,当函数被调用时,返回函数还需要设置一些传入的参数。
创建柯里化函数的通用方式:
curry()函数的主要功能就是将被返回的函数的参数进行排序。为了获取第一个参数后的所有参数,在arguments对象上调用了slice()方法,并传入参数1,表示被返回的数组的第一个元素应该是第二个参数。
12.5.6 高阶函数
高阶函数作为函数式编程众多风格中的一项显著特征,经常被使用。实际上,高阶函数即对函数的进一步抽象。高阶函数至少满足下列条件之一:
☑ 接收函数作为输入。
☑ 输出一个函数。
在函数式语言中,函数不但是一种特殊的对象,还是一种类型,因此函数本身是一个可以传来传去的值。也就是说,某一个函数在刚开始执行时,总可以送入一个函数的参数。传入的参数本身就是一个函数。当然,这个输入的函数相当于某一个函数的另外一个函数。当函数执行完毕之后,又可以返回另外一个新的函数,这个返回函数取决于return fn(){...}。上述过程出现3个不同的函数,分别有不同的角色。要达到这样的应用目的,需要把函数作为一个值来看待。
JavaScript不但是一门灵活的语言,而且是一门精巧的函数式语言。下面看一个函数作为参数的示例。
document.write([2,3,1,4].sort()); //"1,2,3,4"
这是最简单的数组排序语句。实际上Array.prototype.sort()还能够支持一个可选的参数“比较函数”,其形式如sort(fn)。fn是一个函数类型的值,说明这里就应用到高阶函数。
【示例1】下面的代码根据日期对对象进行排序。
在数组排序时就会执行function(x,y) {return x.date-y.date; }这个传入的函数。当没有传入任何排序参数时默认:当x大于y时返回1,当x等于y时返回0,当x小于y时返回-1。
【示例2】除了了解函数作为参数使用外,下面再看看函数返回值作为函数的情况。定义一个wrap()函数,该函数的主要用途是产生一个包裹函数。
var B=wrap('B');这一语句已经决定了这是一个“加粗体”的特别函数,执行该B()函数就会产生<b>……内容……</b>的效果。若是wrap('div')便产生<div>……内容……</div>,若是wrap('li')便产生<li>……内容……</li>……,依此类推。wrap('B')返回到变量B的是一个函数。若不使用变量,wrap('B')也是合法的JavaScript语句,只要最后一个括号()前面的是函数类型的值即可。为什么stag+x+etag中的stag/etag没有输入也会在wrap()内部定义?因为warp作用域中就有stag、etag两个变量。如果从理论上描述这一特性,应该属于闭包方面的内容。
【示例3】实际上,map()函数即为一种高阶函数,在很多的函数式编程语言中均有此函数。map(array, func)的表达式已经表明,将func函数作用于array中的每一个元素,最终返回一个新的array。应该注意的是,map对array和func的实现是没有任何预先的假设的,因此称之为“高阶”函数。
mapped和mapped2均调用了map,但是得到了截然不同的结果,因为map的参数本身已经进行了一次抽象,map函数做的是第二次抽象,高阶的“阶”可以理解为抽象的层次。