javascript的递归、尾调用和蹦床函数: 各种解决方案的性能对比及Babel和ES6优化([翻译自外网博客]

递归,尾调用和蹦床函数(trampoline)

目录

函数和栈
遍历
递归
尾部调用和递归
尾递归和蹦床函数
使用递归时的思考
性能对比
es6中的尾调用优化

递归是指一个函数在返回一个确定的结果之前,调用了它本身。许多函数式语言都是围绕着递归和惰性求值1建立的。尽管javascript这门语言有许多函数式的特性,然而,目前它并不能高效安全地处理递归。

这意味着我们不能在程序中使用递归吗?不,并不真的是这样。有时,采用递归的形式能够显著提高函数的可读性、可维护性,使其更加精炼。一些数学函数和算法就属于这类范畴。

写程序是为了能够让人阅读,仅仅在某些时候才是为了让机器执行。

—— Abelson & Sussman, Structure and Interpretation of Computer Programs

但情况并不是总是这样。有些时候,采用遍历(iteration),Promise 或 浏览器本身的事件循环等其他可重复操作的机制可能是更好的选择。

话虽如此,也来让我们看看javascript中不同的递归方式,以及它们是怎么工作的。


函数和栈

在展开话题之前,首先要理解下Javascript中执行模型的涵义,具体来说,就是执行栈,或回调栈。

Javascript是单线程的,也就是说,在一个特定时间内,只能有一个任务在执行。当页面上Javascript解释器开始执行代码的时候,它的运行环境被称为全局执行上下文(global execution context)。创建一个新的执行上下文有很多种方式,如函数, eval语句, let 块级域, 闭包等等。

所以,在执行上下文中,什么是代码可用(available, accessible)的呢?这就是一个scoping的问题,在这一范围内,变量和函数是可以取得到的。举个例子:

scoping in Javascript

如上图,当javascript解释器碰到函数foo 和 bar 的时候,会创建新的执行上下文。这些上下文以栈型的数据结构被储存,意味着如果一个新的上下文在当前的运行状态下被创建,新的执行上下文会被推入栈顶。(后进先出)。

下图即是一个简单的执行栈模式。要记住,因为scope链的原因,执行上下文将可以取得之前父级栈的变量和函数。
Javascript execution stack

重要的是,嵌套的函数回调是以栈的形式被加入的。当前的执行上下文完成时,栈的入口会被销毁,程序流的控制权会回到上一个执行上下文(父级栈)。


遍历

用一个简单的函数来说明。给定一个起始值和一个终止值(均为整数),要求枚举出这一闭区间内所有的整数。

下面的range函数简单地采用了while循环来构造和返回结果数组。

function range(s,e){
    var res =[]

    while(s!==e){
        res.push(s)
        s < e ? s++ : s--;
    }
    res.push(e) // or  res.push(s)
    return res
}

range(1,4);  // [1,2,3,4]  
range(-5,1); // [-5,-4,-3,-2,-1,0,1]  

该函数的执行上下文如下:

在全局上下文中调用了range函数,于是创建了一个新的执行上下文。数组返回时,该执行上下文被销毁,控制权回到了全局执行上下文中。

这不是一个你想要转换成递归形式的好例子,这样做会大大降低性能和安全性。然而某些时候,由于使用递归会让代码看上去更加清晰、简洁,网盘,我们可以做出一些让步。以range函数为例,来看看递归如何实现。


递归

把range函数从遍历改成递归形式,可以保持调用的api保持不变。代码如下:

// Generate an array of numbers in a given range.
// Recursive implementation
function range(s, e) {  
  var res = [];

  res.push(s);
  return s == e ? res : res.concat(range(s<e ? ++s : --s, e));
}

range(1,4); // [1,2,3,4]  

在执行上下文栈中,每一个递归的调用都在栈顶新建了一个执行上下文,直到满足终止条件。之后,栈开始层层解绑,每一级返回相应的结果。


尾部调用和递归(tail calls and recursion)

在之前的例子中,我们是这么利用递归的:

res.concat(range(s<e ? ++s : --s, e)) 

从解释器的角度来看,这意味着必须要保持当前的执行上下文,因为我们要依赖于自我调用的结果来修改当前本地的res数组。如果作如下修改,则可以简化这个环节,不再需要保持当前的执行域了。

function range(s, e, res) {  
  res = res || [];
  res.push(s);
  return s == e ? res : range(s<e ? ++s : --s, e, res);
}

基本上,如果我们在最终返回了函数调用结果(无论递归与否),并且没有哪个函数的当前环境是计算所需要的话,则称为该函数调用处于尾部(tail position)。

重点在于,由于将调用写在了尾部,这允许解释器通过重复利用已经存在的栈结构来优化函数的调用。如改写后的例子,去除了对当前环境的依赖,在这种情况下,对res数组,把当前的结果传给了下一次函数调用。这样,在递归时所有函数需要继续运行的参数都被传入了,不需要再保留当前的状态。

由于目前,javascript解释器并没有对在尾部的递归调用作优化,因此,现存的栈结构在执行新函数的时候没有得到重复利用,而堆栈空间是有限的。所以不管是不是递归调用,随着更多的嵌套调用函数进行,占用的栈空间就会越大,内存不断被耗用,在range一个较大的数值时,最后可能导致堆栈溢出爆错。

ES6中已经将尾调用优化放在了计划之中。但不是所有的浏览器都支持。那么,现在应该怎么做呢?


尾递归和蹦床函数(trampoline)

利用蹦床函数可以弥补诸如Javascript这类没有尾调用优化的语言的缺憾。蹦床函数会返回一个thunk函数,这是一个内部有循环的函数。使用蹦床函数辅助递归时,每次执行都会保留上一次递归的活动对象的引用,递归完成后返回另一个待执行的递归,引用也就结束了。

那么,什么叫thunk呢?它基本上就是一个函数,其中包裹了一个对内部函数的调用和其他所需要的参数。这是一种惰性计算的模仿。

在我们之前的range函数中,在尾部直接返回了对函数的调用本身。而利用thunk,则是返回一个包裹着这个调用的函数,从而不断执行。

function trampoline(fn){
    return function(){
        var res = fn.apply(this,arguments)
        while (res.instanceof Function){
            res = res()
        }
    }
}

蹦床函数是高一级的函数,相当于一个组合器。新的函数被调用的时候,只要结果是函数,就会反复执行(the thunk),一旦得到了一个非函数的确定的值,就停止执行,最终返回结果。

range可以被改写如下:

function range(s, e, res){
    res = res || []
    res.push(s)
    // return a result or a thunk if we need to do more
    return s==e ? res: function(){

        return range(s<e ? ++s : --s, e, res)
    }
}

这样就可以在es5中做到优化。比如,用一个大范围的数来执行。

range(1,100);  
// => [1,2,3,4,...,98,99,100]
ranger(1,32768);  
// => [1,2,3,4,...,32766,32767,32768]  WORKS!!

下图可见,栈的空间大小是一个常量,一个新的栈只会在每次遍历(即while部分)执行递归调用时被创建出来。

使用递归时的思考

不正确或在不恰当的时候使用递归会引起这些问题:

  • 如果没有明确的终止条件,可能会死循环,并导致浏览器崩溃。
  • 如果数据太多、操作太频繁,会导致堆栈溢出。
  • 由于javascript使用了饥饿计算(而不是惰性计算),而且没有对递归或尾调用做优化,递归的形式会损失一部分性能。
// Call ourselves repeatedly until some other async process
// loads our app's local storage we need.
(function waitForLocalStorage(key) {
   if (localStorage.getItem(key)) {
      // do something with loaded data
   }
   else {
      setTimeout(waitForLocalStorage.bind(null, key), 100);
  }
})('mydata');

使用递归而不是遍历,对某些命令式的算法尤为方便,如二分查找和树搜索。而且,ES6 已经实现了尾递归优化,我们可以利用蹦床函数或babel之类的ES6翻译器来让我们轻松地写递归。


性能对比

这是上述一系列不同版本的range函数的性能表现。

有趣的是,直接的遍历方法是远远超出的其他方法的赢家。因此,如果对性能的需求是第一位的,遍历显然是最好的选择。


es6中的尾调用优化

在结束之前,让我们来一窥ES6中的尾调用优化是怎么工作的,又是怎么改变了执行中的栈。仍以range为例:

function range(s, e, res) {  
  res = res || [];
  res.push(s);
  return s == e ? res : range(s<e ? ++s : --s, e, res);
}

在适当的javascript编译器优化后,其执行栈是这样的:

尾调用被识别出来,重复利用了已存在的栈结构来进行递归,移除了之前函数调用的本地变量和状态。

如果使用了BABEL,它会直接、自递归地处理尾调用。

// Generated using Babel 5.x
"use strict";

function range(_x, _x2, _x3) {  
  var _again = true;

  _function: while (_again) {
    var s = _x,
        e = _x2,
        res = _x3;
    _again = false;

    res = res || [];
    res.push(s);
    if (s == e) {
      return res;
    } else {
      _x = s < e ? ++s : --s;
      _x2 = e;
      _x3 = res;
      _again = true;
      continue _function;
    }
  }
}

这里,BABEL将函数放在了一个while循环中,使用了continue来重复执行循环,直到最终条件为真。这比使用 trampoline 来包裹递归函数要好得多,主要有如下原因:

  • 函数被完全重写,没有嵌套的函数调用,上下文栈保持了一个常量。
  • 函数执行的任务是一个循环loop,性能会更佳。

使用蹦床函数,仍然需要在每次遍历的时候引发一个函数调用,利用返回的thunks,所以重复了一个创建和销毁栈空间的过程。(就像蹦床一样,进进出出。)它在使用频繁的尾递归函数时很有用处,但考虑到性能,也许把你的代码重写成循环或者利用Babel转译器是最好的选择。


挑拣着翻译的,有删改~~

这篇写的也不错,可以参考哦:

ES6中的尾调优化及其他相关的优化算法

尾注:


  1. Haskell中对表达式的计算使用的是Lazy evaluation(也称之为nonstrict evaluation):即只有表达式的值真正需要时,才会被计算。Haskell中把用来跟踪还没有计算的表达式的记录称之为trunk
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值