JS进阶 - 什么是 尾调用、尾递归、蹦床函数

JS函数进阶相关,为 尾调用 尾回调 蹦床函数 做个笔记

具体的可以看 阮一峰 ECMAScript 6 入门 函数的扩展

尾调用

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

  • 最简单的尾调用实现
   function f(x){
     return g(x);
   }
   
   // 或者这样的
   function f(x) {
     if (x > 0) {
       return m(x)
     }
     return n(x);
   }
  • 错误的示范
  // 情况一
  function f(x){
    let y = g(x);
    return y;
  }
  
  // 情况二
  function f(x){
    return g(x) + 1;
  }
  
  // 情况三
  function f(x){
    g(x);
  }

以上的示例,都不属于尾调用

尾调用通俗解释

  1. 在函数结束的时候,通过 return 调用函数。只能调用函数,不能在进行其他的处理,就像情况2一样还需要在进行处理的函数
  2. 函数结束不一定就是函数的最后一行,这个要区分好

尾调用的意义

(以下的结论引用于 阮一峰 ECMAScript 6 入门 函数的扩展 )

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了

总之:就是减少页面的调用帧 . 避免 调用栈 溢出。


尾递归

有了上面 尾调用 的初步认识,尾递归其实就是 尾回调 + 递归 的结合体
众所周知递归的 调用栈 是很可怕的。甚至一不小心就会 栈溢出

我们可以准备个经典的递归案例:斐波拉契数列

  • 普通递归版:
function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2); // 这里返回了2个函数,并且都进行了加法运算,不属于尾调用
}

Fibonacci(10) // 89
Fibonacci(100) // 堆栈溢出
Fibonacci(500) // 堆栈溢出

chrome浏览器在运行 100 或者以上的时候,会页面崩溃。其实就是 堆栈溢出 的体现,就算没有溢出,相信也已经运行了非常久才出的结果

  • 尾递归优化版本
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2); // 直接返回一个函数,属于尾调用
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

优化后,可以做到秒出结果。这也就是 尾调用 的魅力所在

然而。尾调用和尾递归,需要在严格模式下才能使用
可以在方法开始的地方,加上 'use strict';

如果因为历史原因(项目过大,时间太久远,不能贸然使用严格模式)。那还是有办法的,就是下面要介绍的 蹦床函数

蹦床函数(trampoline)

蹦床函数(trampoline)就是将 递归执行 转为 循环执行
执行的都是同样的步骤,只是反复执行,就好像在蹦床,跳上去,掉下来,在跳上去…

  • 蹦床函数的实现:
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

它接受一个函数f作为参数。只要f执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题

  • 一个简单的小demo:
// 蹦床函数
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

// 递归函数 参数x是需要累加的值,参数y控制递归次数。
function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1);
  } else {
    return x;
  }
}

// 使用蹦床函数调用递归函数
trampoline(sum(1, 100000))

细心的可能会发现,为啥 12 行会有一个 bind 函数,为啥不用applycall

因为bind和他们最大的不同就是bind不会立刻执行,而是返回一个函数 。 返回函数这个很重要,这样才能用于蹦床函数。

sum方法也因为这个 bind 每一步返回另一个函数

emmm… 这样蹦床函数用起来,好像又偏复杂了。还得把原先的递归函数改成返回另外一个函数,看着有点绕

然而。蹦床函数并不是尾递归优化的最终版本

下面的实例才是:

尾递归优化实现

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  }
  else {
    return x
  }
});

sum(1, 100000)
// 100001

tco函数是尾递归优化的实现
它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。


搞了那么多,都是为了一个优化,加上es6的普及和浏览器的支持,明明一句 'use strict'; 可以解决的问题,又何必写那么多复杂的函数。

不过这些优化可以用作底层原理的理解还是很不错的。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值