1. 尾调用优化
学习资源来自 阮一峰:尾调用优化
尾调用(Tail Call )是函数式编程的一个重要概念
1.1 什么是尾调用?
尾调用的概念非常简单,就是指 某个函数的最后一步是调用另一个函数。
// 函数 f 的最后一步是调用函数 g,这就叫尾调用。
function f(x) {
return g(x)
}
// 下面两种情况都不属于尾调用
// 1
function f(x) {
let y = g(x)
return y;
}
// 2
function f(x) {
return g(x) + 1;
}
情况 1 是调用函数 g 之后,还有别的操作,所以不属于尾调用,即使语义完全一样
情况 2 也属于 调用后还有操作,即使写在 一行 内
尾调用不一定出现在函数尾部,只要是最后一步操作即可
function f(x) {
if (x > 0) {
return m(x)
}
return n(x)
}
// 这里的 m 函数 和 n 函数都属于尾调用,因为它们都是函数 f 的最后一步操作。
1.2 尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存中形成一个 ‘调用记录’ ,又称为 ‘调用帧’(call frame),保存调用位置和内部变量等信息。如果在函数 A 的内部调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A, B 的调用记录才会消失。如果函数 B 内部还在调用函数 C,那就还有一个 C 的调用记录栈,以此类推,所有的调用纪律就形成了一个 调用栈(call stack)
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同于
function f() {
return g(3)
}
f();
// 等同于
g(3)
以上,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于 调用 g 之后 函数 f 就结束了,所以执行到最后一步 完全可以删除 f() 的调用记录,只保留 g(3) 的调用记录。
这就叫做 ‘尾调用优化’(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是 ‘尾调用优化’的意义。
1.3 尾递归
函数调用自身,称为递归。如果尾调用自身,就成为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生 ‘栈溢出’错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生 ‘栈溢出’错误。
// 阶乘函数
function f(n) {
if (n === 1) return 1;
return n * f(n - 1)
}
f(5);// 120
上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度O(n)。
如果改写成 尾递归,只保留一个调用记录,复杂度O(1)。
// 阶乘函数
function f(n, total) {
if (n === 1) return total;
return f(n-1, n * total)
}
f(5, 1);// 120
// 执行顺序
// f(5)
// f(4, 5)
// f(3, 20)
// f(2, 60)
// f(1, 120)
// 120
以上可以看出,‘尾调用优化’对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格,ES6 也是如此,第一次明确规定,所有 ES 的实现都必须部署 ‘尾调用优化’。这就是说,ES6 中,只要使用尾递归,就不会发生栈溢出,相当节省内存。
1.4 递归函数的改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的实例,阶乘函数 f 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘需要传入两个参数 5 和 1?
两个方法可以解决这个问题,
- 方法一是在为递归函数之外,再提供一个正常形式的函数。
function f(n, total) {
if (n === 1) return total;
return f(n - 1, n * total)
}
function f2(n) {return f(n, 1)}
f2(5);// 120
上面的代码通过一个正常形式的阶乘函数 f2 ,调用尾递归函数 f,看起来正常多了。
函数式编程有一个概念,叫做 柯里化(currying),意思是将多参数的函数转换成单参数的形式,这里也可以使用 柯里化。
function currying(f, n) {
return function(m) {
// 这里为什么还要用 call 啊,call 知识改变了 this 的指向,好像没有什么意义,
// call 和 apply ,就是参数不一样,apply 第二个参数是一个数组,把参数都放在数组里
return f.call(this, m, n);
}
}
function tf(n, total) {
if (n === 1) return total;
return tf(n -1, n * total)
}
const fun = currying(tf, 1);
fun(5);// 120
上面代码通过柯里化,将尾递归函数 tf 变为 只接受 1 个参数的 fun;
- 第二种方法就比较简单了(和我想得一样设默认值,哈哈)
function f(n, total = 1) {
if (n === 1) return total;
return f(n - 1, n * total);
}
f(5) // 120
总结:递归本质上是一种循环操作,纯碎的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持‘尾调用优化’的语言(比如:es6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
1.5 严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
arguments:返回调用时函数的参数
func.caller:返回调用当前函数的那个函数
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。