函数的扩展
函数参数的默认值
基本用法
在 ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
1 | function log(x, y) { |
上面代码检查函数 log 的参数 y 有没有赋值,如果没有,则指定默认值为 World。这种写法的缺点在于,如果参数 y 赋值了,但是对应的布尔值为 false,则该赋值不起作用。就像上面代码的最后一行,参数 y 等于空字符,结果被改为默认值。
为了避免这个问题,通常需要先判断一下参数 y 是否被赋值,如果没有,再等于默认值。
1 | if (typeof y === 'undefined') { |
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
1 | function log(x, y = 'World') { |
可以看到,ES6 的写法比 ES5 简洁许多,而且非常自然。下面是另一个例子。
1 | function Point(x = 0, y = 0) { |
除了简洁,ES6 的写法还有两个好处:首先,阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数变量是默认声明的,所以不能用 let 或 const 再次声明。
1 | function foo(x = 5) { |
上面代码中,参数变量 x 是默认声明的,在函数体中,不能用 let 或 const 再次声明,否则会报错。
与解构赋值默认值结合使用
参数默认值可以与解构赋值的默认值,结合起来使用。
1 | function foo({x, y = 5}) { |
上面代码使用了对象的解构赋值默认值,而没有使用函数参数的默认值。只有当函数 foo 的参数是一个对象时,变量 x 和 y 才会通过解构赋值而生成。如果函数 foo 调用时参数不是对象,变量 x 和 y 就不会生成,从而报错。如果参数对象没有 y 属性,y 的默认值 5 才会生效。
下面是另一个对象的解构赋值默认值的例子。
1 | function fetch(url, { body = '', method = 'GET', headers = {} }) { |
上面代码中,如果函数 fetch 的第二个参数是一个对象,就可以为它的三个属性设置默认值。
上面的写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。
1 | function fetch(url, { method = 'GET' } = {}) { |
上面代码中,函数 fetch 没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量 method 才会取到默认值 GET。
再请问下面两种写法有什么差别?
1 | // 写法一 |
上面两种写法都对函数的参数设定了默认值,区别是写法一函数参数的默认值是空对象,但是设置了对象解构赋值的默认值;写法二函数参数的默认值是一个有具体属性的对象,但是没有设置对象解构赋值的默认值。
1 | // 函数没有参数的情况 |
参数默认值的位置
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
1 | // 例一 |
上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入 undefined。
如果传入 undefined,将触发该参数等于默认值,null 则没有这个效果。
1 | function foo(x = 5, y = 6) { |
上面代码中,x 参数对应 undefined,结果触发了默认值,y 参数等于 null,就没有触发默认值。
函数的 length 属性
指定了默认值以后,函数的 length 属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length 属性将失真。
1 | (function (a) {}).length // 1 |
上面代码中,length 属性的返回值,等于函数的参数个数减去指定了默认值的参数个数。比如,上面最后一个函数,定义了 3 个参数,其中有一个参数 c 指定了默认值,因此 length 属性等于 3 减去 1,最后得到 2。
这是因为 length 属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest 参数也不会计入 length 属性。
1 | (function(...args) {}).length // 0 |
如果设置了默认值的参数不是尾参数,那么 length 属性也不再计入后面的参数了。
1 | (function (a = 0, b, c) {}).length // 0 |
作用域
一个需要注意的地方是,如果参数默认值是一个变量,则该变量所处的作用域,与其他变量的作用域规则是一样的,即先是当前函数的作用域,然后才是全局作用域。
1 | var x = 1; |
上面代码中,参数 y 的默认值等于 x。调用时,由于函数作用域内部的变量 x 已经生成,所以 y 等于参数 x,而不是全局变量 x。
如果调用时,函数作用域内部的变量 x 没有生成,结果就会不一样。
1 | let x = 1; |
上面代码中,函数调用时,y 的默认值变量 x 尚未在函数内部生成,所以 x 指向全局变量。
如果此时,全局变量 x 不存在,就会报错。
1 | function f(y = x) { |
下面这样写,也会报错。
1 | var x = 1; |
上面代码中,函数 foo 的参数 x 的默认值也是 x。这时,默认值 x 的作用域是函数作用域,而不是全局作用域。由于在函数作用域中,存在变量 x,但是默认值在 x 赋值之前先执行了,所以这时属于暂时性死区(参见《let 和 const 命令》一章),任何对 x 的操作都会报错。
如果参数的默认值是一个函数,该函数的作用域是其声明时所在的作用域。请看下面的例子。
1 | let foo = 'outer'; |
上面代码中,函数 bar 的参数 func 的默认值是一个匿名函数,返回值为变量 foo。这个匿名函数声明时,bar 函数的作用域还没有形成,所以匿名函数里面的 foo 指向外层作用域的 foo,输出 outer。
如果写成下面这样,就会报错。
1 | function bar(func = () => foo) { |
上面代码中,匿名函数里面的 foo 指向函数外层,但是函数外层并没有声明 foo,所以就报错了。
下面是一个更复杂的例子。
1 | var x = 1; |
上面代码中,函数 foo 的参数 y 的默认值是一个匿名函数。函数 foo 调用时,它的参数 x 的值为 undefined,所以 y 函数内部的 x 一开始是 undefined,后来被重新赋值 2。但是,函数 foo 内部重新声明了一个 x,值为 3,这两个 x 是不一样的,互相不产生影响,因此最后输出 3。
如果将 var x = 3 的 var 去除,两个 x 就是一样的,最后输出的就是 2。
1 | var x = 1; |
应用
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
1 | function throwIfMissing() { |
上面代码的 foo 函数,如果调用的时候没有参数,就会调用默认值 throwIfMissing 函数,从而抛出一个错误。
从上面代码还可以看到,参数 mustBeProvided 的默认值等于 throwIfMissing 函数的运行结果(即函数名之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行(即如果参数已经赋值,默认值中的函数就不会运行),这与 python 语言不一样。
另外,可以将参数默认值设为 undefined,表明这个参数是可以省略的。
1 | function foo(optional = undefined) { ··· } |
rest 参数
ES6 引入 rest 参数(形式为“…变量名”),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
1 | function add(...values) { |
上面代码的 add 函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数。
下面是一个 rest 参数代替 arguments 变量的例子。
1 | // arguments变量的写法 |
上面代码的两种写法,比较后可以发现,rest 参数的写法更自然也更简洁。
rest 参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用 rest 参数改写数组 push 方法的例子。
1 | function push(array, ...items) { |
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
1 | // 报错 |
函数的 length 属性,不包括 rest 参数。
1 | (function(a) {}).length // 1 |
扩展运算符
含义
扩展运算符(spread)是三个点(…)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
1 | console.log(...[1, 2, 3]) |
该运算符主要用于函数调用。
1 | function push(array, ...items) { |
上面代码中,array.push(…items)和 add(…numbers)这两行,都是函数的调用,它们的都使用了扩展运算符。该运算符将一个数组,变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
1 | function f(v, w, x, y, z) { } |
替代数组的 apply 方法
由于扩展运算符可以展开数组,所以不再需要 apply 方法,将数组转为函数的参数了。
1 | // ES5的写法 |
下面是扩展运算符取代 apply 方法的一个实际的例子,应用 Math.max 方法,简化求出一个数组最大元素的写法。
1 | // ES5的写法 |
上面代码表示,由于 JavaScript 不提供求数组最大元素的函数,所以只能套用 Math.max 函数,将数组转为一个参数序列,然后求最大值。有了扩展运算符以后,就可以直接用 Math.max 了。
另一个例子是通过 push 函数,将一个数组添加到另一个数组的尾部。
1 | // ES5的写法 |
上面代码的 ES5 写法中,push 方法的参数不能是数组,所以只好通过 apply 方法变通使用 push 方法。有了扩展运算符,就可以直接将数组传入 push 方法。
下面是另外一个例子。
1 | // ES5 |
扩展运算符的应用
合并数组
扩展运算符提供了数组合并的新写法。
1 | // ES5 |
与解构赋值结合
扩展运算符可以与解构赋值结合起来,用于生成数组。
1 | // ES5 |
下面是另外一些例子。
1 | const [first, ...rest] = [1, 2, 3, 4, 5]; |
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
1 | const [...butLast, last] = [1, 2, 3, 4, 5]; |
函数的返回值
JavaScript 的函数只能返回一个值,如果需要返回多个值,只能返回数组或对象。扩展运算符提供了解决这个问题的一种变通方法。
1 | var dateFields = readDateFields(database); |
上面代码从数据库取出一行数据,通过扩展运算符,直接将其传入构造函数 Date。
字符串
扩展运算符还可以将字符串转为真正的数组。
1 | [...'hello'] |
上面的写法,有一个重要的好处,那就是能够正确识别 32 位的 Unicode 字符。
1 | 'x\uD83D\uDE80y'.length // 4 |
上面代码的第一种写法,JavaScript 会将 32 位 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。
1 | function length(str) { |
凡是涉及到操作 32 位 Unicode 字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
1 | let str = 'x\uD83D\uDE80y'; |
上面代码中,如果不用扩展运算符,字符串的 reverse 操作就不正确。
实现了 Iterator 接口的对象
任何 Iterator 接口的对象,都可以用扩展运算符转为真正的数组。
1 | var nodeList = document.querySelectorAll('div'); |
上面代码中,querySelectorAll 方法返回的是一个 nodeList 对象。它不是数组,而是一个类似数组的对象。这时,扩展运算符可以将其转为真正的数组,原因就在于 NodeList 对象实现了 Iterator 接口。
对于那些没有部署 Iterator 接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。
1 | let arrayLike = { |
上面代码中,arrayLike 是一个类似数组的对象,但是没有部署 Iterator 接口,扩展运算符就会报错。这时,可以改为使用 Array.from 方法将 arrayLike 转为真正的数组。
Map 和 Set 结构,Generator 函数
扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,比如 Map 结构。
1 | let map = new Map([ |
Generator 函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。
1 | var go = function*(){ |
上面代码中,变量 go 是一个 Generator 函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值,转为一个数组。
如果对没有 iterator 接口的对象,使用扩展运算符,将会报错。
1 | var obj = {a: 1, b: 2}; |
严格模式
从 ES5 开始,函数内部可以设定为严格模式。
1 | function doSomething(a, b) { |
《ECMAScript 2016 标准》做了一点修改,规定只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
1 | // 报错 |
这样规定的原因是,函数内部的严格模式,同时适用于函数体代码和函数参数代码。但是,函数执行的时候,先执行函数参数代码,然后再执行函数体代码。这样就有一个不合理的地方,只有从函数体代码之中,才能知道参数代码是否应该以严格模式执行,但是参数代码却应该先于函数体代码执行。
1 | // 报错 |
上面代码中,参数 value 的默认值是八进制数 070,但是严格模式下不能用前缀 0 表示八进制,所以应该报错。但是实际上,JavaScript 引擎会先成功执行 value = 070,然后进入函数体内部,发现需要用严格模式执行,这时才会报错。
虽然可以先解析函数体代码,再执行参数代码,但是这样无疑就增加了复杂性。因此,标准索性禁止了这种用法,只要参数使用了默认值、解构赋值、或者扩展运算符,就不能显式指定严格模式。
两种方法可以规避这种限制。第一种是设定全局性的严格模式,这是合法的。
1 | 'use strict'; |
第二种是把函数包在一个无参数的立即执行函数里面。
1 | const doSomething = (function () { |
name 属性
函数的 name 属性,返回该函数的函数名。
1 | function foo() {} |
这个属性早就被浏览器广泛支持,但是直到 ES6,才将其写入了标准。
需要注意的是,ES6 对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5 的 name 属性,会返回空字符串,而 ES6 的 name 属性会返回实际的函数名。
1 | var func1 = function () {}; |
上面代码中,变量 func1 等于一个匿名函数,ES5 和 ES6 的 name 属性返回的值不一样。
如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的 name 属性都返回这个具名函数原本的名字。
1 | const bar = function baz() {}; |
Function 构造函数返回的函数实例,name 属性的值为“anonymous”。
1 | (new Function).name // "anonymous" |
bind 返回的函数,name 属性值会加上“bound ”前缀。
1 | function foo() {}; |
箭头函数
基本用法
ES6 允许使用“箭头”(=>)定义函数。
1 | var f = v => v; |
上面的箭头函数等同于:
1 | var f = function(v) { |
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
1 | var f = () => 5; |
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用 return 语句返回。
1 | var sum = (num1, num2) => { return num1 + num2; } |
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。
1 | var getTempItem = id => ({ id: id, name: "Temp" }); |
箭头函数可以与变量解构结合使用。
1 | const full = ({ first, last }) => first + ' ' + last; |
箭头函数使得表达更加简洁。
1 | const isEven = n => n % 2 == 0; |
上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。
箭头函数的一个用处是简化回调函数。
1 | // 正常函数写法 |
下面是 rest 参数与箭头函数结合的例子。
1 | const numbers = (...nums) => nums; |
使用注意点
箭头函数有几个使用注意点。
(1)函数体内的 this 对象,就是定义时所在的对象,而不是使用时所在的对象。
(2)不可以当作构造函数,也就是说,不可以使用 new 命令,否则会抛出一个错误。
(3)不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 Rest 参数代替。
(4)不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。
上面四点中,第一点尤其值得注意。this 对象的指向是可变的,但是在箭头函数中,它是固定的。
1 | function foo() { |
上面代码中,setTimeout 的参数是一个箭头函数,这个箭头函数的定义生效是在 foo 函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时 this 应该指向全局对象 window,这时应该输出 21。但是,箭头函数导致 this 总是指向函数定义生效时所在的对象(本例是{id: 42}),所以输出的是 42。
箭头函数可以让 setTimeout 里面的 this,绑定定义时所在的作用域,而不是指向运行时所在的作用域。下面是另一个例子。
1 | function Timer() { |
上面代码中,Timer 函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的 this 绑定定义时所在的作用域(即 Timer 函数),后者的 this 指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1 被更新了 3 次,而 timer.s2 一次都没更新。
箭头函数可以让 this 指向固定化,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。
1 | var handler = { |
上面代码的 init 方法中,使用了箭头函数,这导致这个箭头函数里面的 this,总是指向 handler 对象。否则,回调函数运行时,this.doSomething 这一行会报错,因为此时 this 指向 document 对象。
this 指向的固定化,并不是因为箭头函数内部有绑定 this 的机制,实际原因是箭头函数根本没有自己的 this,导致内部的 this 就是外层代码块的 this。正是因为它没有 this,所以也就不能用作构造函数。
所以,箭头函数转成 ES5 的代码如下。
1 | // ES6 |
上面代码中,转换后的 ES5 版本清楚地说明了,箭头函数里面根本没有自己的 this,而是引用外层的 this。
请问下面的代码之中有几个 this?
1 | function foo() { |
上面代码之中,只有一个 this,就是函数 foo 的 this,所以 t1、t2、t3 都输出同样的结果。因为所有的内层函数都是箭头函数,都没有自己的 this,它们的 this 其实都是最外层 foo 函数的 this。
除了 this,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments、super、new.target。
1 | function foo() { |
上面代码中,箭头函数内部的变量 arguments,其实是函数 foo 的 arguments 变量。
另外,由于箭头函数没有自己的 this,所以当然也就不能用 call()、apply()、bind()这些方法去改变 this 的指向。
1 | (function() { |
上面代码中,箭头函数没有自己的 this,所以 bind 方法无效,内部的 this 指向外部的 this。
长期以来,JavaScript 语言的 this 对象一直是一个令人头痛的问题,在对象方法中使用 this,必须非常小心。箭头函数”绑定”this,很大程度上解决了这个困扰。
嵌套的箭头函数
箭头函数内部,还可以再使用箭头函数。下面是一个 ES5 语法的多重嵌套函数。
1 | function insert(value) { |
上面这个函数,可以使用箭头函数改写。
1 | let insert = (value) => ({into: (array) => ({after: (afterValue) => { |
下面是一个部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。
1 | const pipeline = (...funcs) => |
如果觉得上面的写法可读性比较差,也可以采用下面的写法。
1 | const plus1 = a => a + 1; |
箭头函数还有一个功能,就是可以很方便地改写 λ 演算。
1 | // λ演算的写法 |
上面两种写法,几乎是一一对应的。由于 λ 演算对于计算机科学非常重要,这使得我们可以用 ES6 作为替代工具,探索计算机科学。
绑定 this
箭头函数可以绑定 this 对象,大大减少了显式绑定 this 对象的写法(call、apply、bind)。但是,箭头函数并不适用于所有场合,所以 ES7 提出了“函数绑定”(function bind)运算符,用来取代 call、apply、bind 调用。虽然该语法还是 ES7 的一个提案,但是 Babel 转码器已经支持。
函数绑定运算符是并排的两个双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即 this 对象),绑定到右边的函数上面。
1 | foo::bar; |
如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。
1 | var method = obj::obj.foo; |
由于双冒号运算符返回的还是原对象,因此可以采用链式写法。
1 | // 例一 |
尾调用优化
什么是尾调用?
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
1 | function f(x){ |
上面代码中,函数 f 的最后一步是调用函数 g,这就叫尾调用。
以下三种情况,都不属于尾调用。
1 | // 情况一 |
上面代码中,情况一是调用函数 g 之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。
1 | function f(x){ |
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
1 | function f(x) { |
上面代码中,函数 m 和 n 都属于尾调用,因为它们都是函数 f 的最后一步操作。
尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数 A 的内部调用函数 B,那么在 A 的调用帧上方,还会形成一个 B 的调用帧。等到 B 运行结束,将结果返回到 A,B 的调用帧才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
1 | function f() { |
上面代码中,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于调用 g 之后,函数 f 就结束了,所以执行到最后一步,完全可以删除 f(x) 的调用帧,只保留 g(3) 的调用帧。
这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
1 | function addOne(a){ |
上面的函数不会进行尾调用优化,因为内层函数 inner 用到了外层函数 addOne 的内部变量 one。
尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
1 | function factorial(n) { |
上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度 O(n) 。
如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。
1 | function factorial(n, total) { |
还有一个比较著名的例子,就是计算 fibonacci 数列,也能充分说明尾递归优化的重要性
如果是非尾递归的 fibonacci 递归方法
1 | function Fibonacci (n) { |
如果我们使用尾递归优化过的 fibonacci 递归算法
1 | function Fibonacci2 (n , ac1 = 1 , ac2 = 1) { |
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 也是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。
递归函数的改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total ,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘,需要传入两个参数 5 和 1?
两个方法可以解决这个问题。方法一是在尾递归函数之外,再提供一个正常形式的函数。
1 | function tailFactorial(n, total) { |
上面代码通过一个正常形式的阶乘函数 factorial ,调用尾递归函数 tailFactorial ,看起来就正常多了。
函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
1 | function currying(fn, n) { |
上面代码通过柯里化,将尾递归函数 tailFactorial 变为只接受 1 个参数的 factorial 。
第二种方法就简单多了,就是采用 ES6 的函数默认值。
1 | function factorial(n, total = 1) { |
上面代码中,参数 total 有默认值 1,所以调用时不用提供这个值。
总结一下,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言(比如 Lua,ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
1.func.arguments:返回调用时函数的参数。
2.func.caller:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
1 | function restricted() { |
尾递归优化的实现
尾递归优化只在严格模式下生效,那么正常模式下,或者那些不支持该功能的环境中,有没有办法也使用尾递归优化呢?回答是可以的,就是自己实现尾递归优化。
它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。
下面是一个正常的递归函数。
1 | function sum(x, y) { |
上面代码中,sum 是一个递归函数,参数 x 是需要累加的值,参数 y 控制递归次数。一旦指定 sum 递归 100000 次,就会报错,提示超出调用栈的最大次数。
蹦床函数(trampoline)可以将递归执行转为循环执行。
1 | function trampoline(f) { |
上面就是蹦床函数的一个实现,它接受一个函数 f 作为参数。只要 f 执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。
然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。
1 | function sum(x, y) { |
上面代码中,sum 函数的每次执行,都会返回自身的另一个版本。
现在,使用蹦床函数执行 sum,就不会发生调用栈溢出。
1 | trampoline(sum(1, 100000)) |
蹦床函数并不是真正的尾递归优化,下面的实现才是。
1 | function tco(f) { |
上面代码中,tco 函数是尾递归优化的实现,它的奥妙就在于状态变量 active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归 sum 返回的都是 undefined,所以就避免了递归执行;而 accumulated 数组存放每一轮 sum 执行的参数,总是有值的,这就保证了 accumulator 函数内部的 while 循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
函数参数的尾逗号
ECMAScript 2017 将允许函数的最后一个参数有尾逗号(trailing comma)。
此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。
1 | function clownsEverywhere( |
上面代码中,如果在 param2 或 bar 后面加一个逗号,就会报错。
这样的话,如果以后修改代码,想为函数 clownsEverywhere 添加第三个参数,就势必要在第二个参数后面添加一个逗号。这对版本管理系统来说,就会显示,添加逗号的那一行也发生了变动。这看上去有点冗余,因此新的语法允许定义和调用时,尾部直接有一个逗号。
1 | function clownsEverywhere( |