函数式编程之尾调用和尾递归

1,918 阅读3分钟

尾调用

前一段时间偶然情况下了解到了尾调用这个概念,然后就去了解了一下,其实用代码来解释是非常容易的:

function foo(x){
    return x+1
}
function ex(){
    var num = 2
    return foo(num)
}
//尾调用不一定出现在函数尾部,只要是最后一步操作即可。
//当一个函数的最后一步是另一个函数的调用(只是某个函数的调用),那么,这种情况就称之为尾调用

尾调用为什么会单独拿出来作为一个概念并且被讨论,究其原因是函数调用会在内存中形成一个调用帧(用以保存调用位置,上下文变量等信息),如果在函数A内部调用了函数B,那么,会在A的调用帧上面再记录一个B的调用帧,等B执行结束将结果返回给A之后,B的调用帧消失;如果B内部还调用了函数C,那么,C的调用帧。还会出现在B的上方。这就是一个压栈的过程,因此调用过程其实是形成了一个调用栈的。 尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

优化

优化方式根据使用的语言不同则有不同实现,此处拿JavaScript来举例

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),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是"尾调用优化"的意义。

尾递归

尾调用的是自身,被称为尾递归。 递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度 O(n) 。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

由此可见,"尾调用优化"对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署"尾调用优化"。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存,ES6的尾调用优化只在严格模式下开启,正常模式是无效的。(参考自阮一峰:www.ruanyifeng.com/blog/2015/0…)