尾调用是函数式编程中一个很重要的概念,当一个函数执行时的最后一个步骤是返回另外一个函数的调用,这就叫作尾调用。node
注意这里函数的调用方式是无所谓的,如下方式都可:web
函数调用: func(···)
方法调用: obj.method(···)
call调用: func.call(···)
apply调用: func.apply(···)
复制代码
而且只有下列表达式会包含尾调用:编程
条件操做符: ? :
逻辑或: ||
逻辑与: &&
逗号: ,
复制代码
依次举例:浏览器
const a = x => x ? f() : g();
// f() 和 g() 都在尾部。
复制代码
const a = () => f() || g();
// g()有多是尾调用,f()不是
// 由于上述写法和下面的写法等效:
const a = () => {
const fResult = f(); // not a tail call
if (fResult) { return fResult; } else { return g(); // tail call } } // 只有当f()的结果为falsey的时候,g()才是尾调用 复制代码
const a = () => f() && g();
// g()有多是尾调用,f()不是
// 由于上述写法和下面的写法等效:
const a = () => {
const fResult = f(); // not a tail call
if (fResult) { return g(); // tail call } else { return fResult; } } // 只有当f()的结果为truthy的时候,g()才是尾调用 复制代码
const a = () => (f() , g());
// g()是尾调用
// 由于上述写法和下面的写法等效:
const a = () => {
f();
return g(); } 复制代码
函数在调用的时候会在调用栈(call stack)中存有记录,每一条记录叫作一个调用帧(call frame),每调用一个函数,就向栈中push一条记录,函数执行结束后依次向外弹出,直到清空调用栈,参考下图:bash
function foo () { console.log(111); } function bar () { foo(); } function baz () { bar(); } baz(); 复制代码
形成这种结果是由于每一个函数在调用另外一个函数的时候,并无 return 该调用,因此JS引擎会认为你尚未执行完,会保留你的调用帧。闭包
baz() 里面调用了 bar() 函数,并无 return 该调用,因此在调用栈中保持本身的调用帧,同时 bar() 函数的调用帧在调用栈中生成,同理,bar() 函数又调用了 foo() 函数,最后执行到 foo() 函数的时候,没有再调用其余函数,这里没有显示声明 return,因此这里默认 return undefined。app
foo() 执行完了,销毁调用栈中本身的记录,依次销毁 bar() 和 baz() 的调用帧,最后完成整个流程。编程语言
若是对上面的例子作以下修改:函数式编程
function foo () { console.log(111); } function bar () { return foo(); } function baz () { return bar(); } baz(); 复制代码
这里要注意:尾调用优化只在严格模式下有效。函数
在非严格模式下,大多数引擎会包含下面两个属性,以便开发者检查调用栈:
在尾调用优化中,这些属性再也不有用,由于相关的信息可能以及被移除了。所以,严格模式(strict mode)禁止这些属性,而且尾调用优化只在严格模式下有效。
若是尾调用优化生效,流程图就会变成这样:
咱们能够很清楚的看到,尾调用因为是函数的最后一步操做,因此不须要保留外层函数的调用记录,只要直接用内层函数的调用记录取代外层函数的调用记录就能够了,调用栈中始终只保持了一条调用帧。
这就叫作尾调用优化,若是全部的函数都是尾调用的话,那么在调用栈中的调用帧始终只有一条,这样会节省很大一部分的内存,这也是尾调用优化的意义。
先来看一下递归,当一个函数调用自身,就叫作递归。
function foo () { foo(); } 复制代码
上面这个操做就叫作递归,可是注意了,这里没有结束条件,是死递归,因此会报栈溢出错误的,写代码时千万注意给递归添加结束条件。
那么什么是尾递归? 前面咱们知道了尾调用的概念,当一个函数尾调用自身,就叫作尾递归。
function foo () { return foo(); } 复制代码
那么尾递归相比递归而言,有哪些不一样呢? 咱们经过下面这个求阶乘的例子来看一下:
function factorial (num) { if (num === 1) return 1; return num * factorial(num - 1); } factorial(5); // 120 factorial(10); // 3628800 factorial(500000); // Uncaught RangeError: Maximum call stack size exceeded 复制代码
上面是使用递归来计算阶乘的例子,操做系统为JS引擎调用栈分配的内存是有大小限制的,若是计算的数字足够大,超出了内存最大范围,就会出现栈溢出错误。
这里500000并非临界值,只是我用了一个足够形成栈溢出的数。
若是用尾递归来计算阶乘呢?
'use strict'; function factorial (num, total) { if (num === 1) return total; return factorial(num - 1, num * total); } factorial(5, 1); // 120 factorial(10, 1); // 3628800 factorial(500000, 1); // 分状况 // 注意,虽说这里启用了严格模式,可是经测试,在Chrome和Firefox下,仍是会报栈溢出错误,并无进行尾调用优化 // Safari浏览器进行了尾调用优化,factorial(500000, 1)结果为Infinity,由于结果超出了JS可表示的数字范围 // 若是在node v6版本下执行,须要加--harmony_tailcalls参数,node --harmony_tailcalls test.js // node最新版本已经移除了--harmony_tailcalls功能 复制代码
经过尾递归,咱们把复杂度从O(n)下降到了O(1),若是数据足够大的话,会节省不少的计算时间。 因而可知,尾调用优化对递归操做意义重大,因此一些函数式编程语言将其写入了语言规格。
尾递归的实现,每每须要改写递归函数,确保最后一步只调用自身。 要作到这一点,须要把函数内部全部用到的中间变量改写为函数的参数,就像上面的factorial()函数改写同样。
这样作的缺点就是语义不明显,要计算阶乘的函数,为何还要另外传入一个参数叫total? 解决这个问题的办法有两个:
'use strict'; function factorial (num, total = 1) { if (num === 1) return total; return factorial(num - 1, num * total); } factorial(5); // 120 factorial(10); // 3628800 复制代码
function tailFactorial (num, total) { if (num === 1) return total; return tailFactorial(num - 1, num * total); } function factorial (num) { return tailFactorial(num, 1); } factorial(5); // 120 factorial(10); // 3628800 复制代码
上面这种写法其实有点相似于作了一个函数柯里化,但不彻底符合柯里化的概念。 函数柯里化是指把接受多个参数的函数转换为接受一个单一参数(最初函数的第一个参数)的函数,而且返回接受余下参数且返回结果的新函数。
概念看着很绕口,咱们来个例子感觉一下:
// 普通加法函数
function add (x, y, z) { return x + y + z; } add(1, 2, 3); // 6 // 改写为柯里化加法函数 function add (x) { return function (y) { return function (z) { return x + y + z; } } } add(1)(2)(3); // 6 复制代码
能够看到,柯里化函数经过闭包找到父做用域里的变量,最后依次相加输出结果。 经过这个例子,可能看不出为何要用柯里化,有什么好处,这个咱们之后再谈,这里先引出一个概念。
是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,而且返回接受余下的参数且返回结果的新函数的技术。
若是用柯里化改写求阶乘的例子:
// 柯里化函数
function curry (fn) { var _fnArgLength = fn.length; function wrap (...args) { var _args = args; var _argLength = _args.length; // 若是传的是全部参数,直接返回fn调用 if (_fnArgLength === _argLength) { return fn.apply(null, args); } function act (...args) { _args = _args.concat(args); if (_args.length === _fnArgLength) { return fn.apply(null, _args); } return act; } return act; } return wrap; } // 尾递归函数 function tailFactorial (num, total) { if (num === 1) return total; return tailFactorial(num - 1, num * total); } // 改写 var factorial = curry(tailFactorial); factorial(5)(1); // 120 factorial(10)(1); // 3628800 复制代码
这是符合柯里化概念的写法,在阮一峰老师的文章中是这样写的:
function currying(fn, n) { return function (m) { return fn.call(this, m, n); }; } function tailFactorial(n, total) { if (n === 1) return total; return tailFactorial(n - 1, n * total); } const factorial = currying(tailFactorial, 1); factorial(5) // 120 复制代码
我我的认为,这种写法其实不是柯里化,由于并无将多参数的tailFacrotial改写为接受单参数的形式,只是换了一种写法,和下面这样写意义是同样的:
function factorial (num) { return tailFactorial(num, 1); } function tailFactorial (num, total) { if (num === 1) return total; return tailFactorial(num - 1, num * total); } factorial(5); // 120 factorial(10); // 3628800 复制代码
这篇文章咱们主要讨论了尾调用优化和柯里化。 要注意的是,通过测试,Chrome和Firefox并无对尾调用进行优化,Safari对尾调用进行了优化。 Node高版本也已经去除了经过--harmony_tailcalls参数启用尾调用优化。