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

fasterjs一个Babel插件可以将惯用的Javascript编译为更快更优化的代码 faster.js一个Babel插件,可以将惯用的Javascript编译为更快,更优化的代码 立即下载

递归,尾调用和蹦床函数(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 ↩
深入理解JavaScript中的尾调用(Tail Call) 尾调用(Tail Call)是函数式编程的一个重要概念,下面这篇文章主要给大家深入的介绍了关于JavaScript中尾调用的相关资料,文中介绍的非常详细,相信对大家具有一定的参考价值,有需要的朋友们下面来一起看看吧。 立即下载

相关推荐

Abaqus CEL分析必备:Hypermesh网格导出与inp文件合并技巧

本文详细介绍了在Abaqus CEL分析中使用Hypermesh生成六面体网格并合并多组件inp文件的完整流程。从单元类型设置(EC3D8R)、网格质量优化到inp文件合并技巧,提供了一套解决流固耦合仿真前处理痛点的实战方案,帮助工程师高效完成复杂CEL分析任务。

weixin_29279101的博客 212

啥是蹦床函数

(一)蹦床函数解决的问题 使用es6的蹦床函数解决递归造成的堆栈溢出 (二)举个例子 <script> function test () { test() } test() </script> 函数里面自己调用自己,也就是 类似递归 浏览器报错: 当然,这个也会报错 function factorial(n) { if (n <= 1) return 1; return n * factori

Luckyzhoufangbing的博客 1633

用Navicat轻松管理魔兽世界私服数据库:修改IP、添加装备的实用技巧

本文详细介绍了如何使用Navicat管理魔兽世界私服数据库,包括修改服务器IP、添加自定义装备和调整角色属性等实用技巧。通过专业的SQL操作指南,帮助玩家轻松实现私服的高级定制,提升游戏体验。

fanta的博客 937

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

标签: JS进阶 JS函数进阶相关,为 尾调用 尾回调 蹦床函数 做个笔记 具体的可以看 阮一峰 ECMAScript 6 入门 函数的扩展 文章目录尾调用尾调用通俗解释尾调用的意义尾递归蹦床函数(trampoline)尾递归优化实现 尾调用 尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。 最简单的尾调用实现...

Jioho的博客 2482

批处理脚本手动双击可以执行,但计划任务中执行失败

问题现象: 使用批处理脚本从linux端(samba server) 备份一些数据,设置计划任务每天凌晨两点定时备份。 1)、windows系统中映射共享文件夹为S:盘,需要输入共享用户名和密码 2)、脚本中使用xcopy命令复制共享目录中的文件到windows本地目录中。 脚本如下: set Name=%date:~0,4%%date:~5,2%%date:~8,2% chcp 65001 REM chcp防止中文乱码 mkdir "E:\脚本备份\bakscripts%Name%" xco

独孤清扬玩DB 4312

别再死记硬背了!用Python+Matlab/Simulink手把手带你理解PID控制器的‘稳、快、准’

本文通过Python编程和Matlab/Simulink仿真,深入解析PID控制器的‘稳、快、准’三大核心指标,帮助读者直观理解PID控制的实现原理与参数整定技巧。内容涵盖经典控制理论和自动控制原理,提供实战案例和高级调参技巧,适合工业自动化领域的学习者和工程师。

weixin_42551227的博客 367

阮一峰ES6入门读书笔记(四):函数

阮一峰ES6入门读书笔记(四):函数 rest 参数 ES6 引入了 rest 参数(形式为 …变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。 function add(...values) { let sum = 0; for (var val of values) { sum += val; } return sum; } add(2, 5, 3) // 10 下面是一个

黄黄黄一边敲代码一边写出来的博客 3055

(25.08)Ubuntu20.04+ROS1复现FAST-LIO2

请确保在。

m0_69023437的博客 3989

trampoline蹦床函数解决递归调用栈问题

递归函数的调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。 下面是一个正常的递归函数。 function sum(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x; } } sum(1, 100000) // Un...

dengwei2939的博客 955

华为OD机试新系统真题【仓库盘点】

华为OD机试真题 新系统【仓库盘点】,提供C语言、Java、C++、Python、Go、JavaScript源码实现,并提供完整思路讲解,以及在线OJ模拟做题。华为OD机试真题【仓库盘点】

qq_45776114的博客 566

告别 DEFINE VIEW:CDS View Entity 迁移全景实战(报表迁移 + ADT 向导 + 避坑清单)

CDS迁移的关键要点与策略 CDS DDIC-based View已被标记为废弃,迁移到CDS View Entity成为必要。迁移面临的主要挑战包括激活成本、命名约束和依赖风险。ABAP 7.57及以上版本必须优先处理,否则将积累技术债务。 迁移方式主要有两种: 报表路线:适合渐进式迁移的On-Premise项目 ADT向导:适合云环境批量迁移 关键注意事项: 必须提前检查SQL View依赖关系 金额/数量字段需要显式引用单位/货币字段 建议按使用场景分批迁移 最佳实践是结合报表进行系统盘点,再通过AD

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 208

Javascript进阶之尾调用优化

尾调用(Tail Call)指的是在一个函数的最后执行的函数调用,也就是说这个函数调用之后没有任何其他的计算需要执行。由于尾调用是函数的最后一个动作,所以不需要保留当前函数的上下文,这使得编译器或解释器有机会优化内存使用,释放掉当前函数的栈帧,只保留尾调用函数的栈帧,或者复用当前函数的栈帧。这种优化策略就是尾调用优化(Tail Call Optimization, TCO)。更特别的,如果尾调用的对象是自身,就称为尾递归,相应的优化称为尾递归优化。本文通过实验直观演示了node,js的尾递归优化。

tangmeng1988的博客 1254

《YOLOv12魔术师专栏》专栏介绍 & 专栏目录

《YOLOv12魔术师专栏》专栏介绍 & 专栏目录

①答疑群聊服务;②YOLO大模型知识问答系统;③计算机视觉论文生成智能体; 5058

ES6之主要知识点(五)函数

函数参数的默认值 作用域 var x = 1; function f(x, y = x) { console.log(y); } f(2) // 2 let x = 1; function f(y = x) { let x = 2; console.log(y); } f() // 1 var x = 1; functio...

weixin_34194087的博客 186

Probability and Statistics - 4th edition

Probability and Statistics - 4th edition

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

webpack 调试_我如何通过试错,一些外部帮助来解决和调试Webpack问题。

webpack 调试by Margarita Obraztsova 玛格丽塔(Margarita Obraztsova) 我如何通过试错,一些外部帮助来解决和调试Webpack问题。 (How I solved and debugged my Webpack issue through trial, error, and a little outside help.) I would say ...

cumian9828的博客 1619

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

ES6 javascript 尾调用-尾递归(优化)-柯里化-蹦床函数-严格模式

1 什么是尾调用? 尾调用( Tail Call )是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。 function f(x){ return g(x); } 上面代码中,函数 f 的最后一步是调用函数 g ,这就叫尾调用。 以下三种情况,都不属于尾调用。 // 情况一 function f(x){ let y = g(x); return y; } // 情况二 function f(x){ return g(x) + 1.

m0_60237095的博客 443

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

js-读书笔记-函数式编程-蹦床原理

【代码】js-读书笔记-函数式编程-蹦床原理。

junjiahuang的博客 235

蹦床函数,可以将递归执行转化为循环执行

递归函数: function sum(x, y) { if (y > 0) { return sum(x + 1, y - 1) } else { return x } } sum(1, 10000) 蹦床函数优化: function trampoline(f) { while (f && f ins...

scorpio的世界 448

sum函数两个同行的元素相加_理解递归、尾调用优化和蹦床函数优化

想要理解递归,您必须先理解递归。开个玩笑罢了, 递归 是一种编程技巧,它可以让函数在不使用 for 或 while 的情况下,使用一个调用自身的函数来实现循环。例子 1:整数总和例如,假设我们想要求从 1 到 i 的整数的和,目标是得到以下结果:sumIntegers(1); // 1sumIntegers(3); // 1 + 2 + 3 = 6sumIntegers(5); // 1 + 2 ...

weixin_36303283的博客 352

js函数式编程——蹦床函数

概述 这是我在学习函数式编程的时候,关于递归,尾递归,相互递归和蹦床函数的一些心得,记下来供以后开发时参考,相信对其他人也有用。 参考资料:JavaScript玩转Clojure大法之 - Trampoline 递归 我们知道,es5是没有尾递归优化的,所以在递归的时候,如果层数太多,就会报“Maximum call stack size exceeded”的错误。就连下面这个及其简单的递归函数都...

weixin_30614109的博客 295

蹦床(trampoline)原理

摘录自 Functional JavaScript。 来看一个相互递归的例子 function evenSteven(n) { if (n === 0) return true else return oddJohn(Math.abs(n) - 1); } function oddJohn(n) { if (n === 0) return false el...

weixin_34375251的博客 3476

嵌套函数的蹦床

由于看到OPENGL ES中有这么一段代码: #if defined(ARM) && defined(__gnu_linux__) #define CLEAR_INSN_CACHE(BEG, END)                                  \ {

1677

使用es6的蹦床函数解决递归造成的堆栈溢出

首先,我们先定义一个函数,使用递归的思想写求和的方法: function sum(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x; } }   当我们执行的时候 sum(1, 100000000000)   这个时候会出现一个堆栈溢出的错误,在es6里面,有一个为递归优化的方法可以解决,即在最后一步调用函数,且实现函数的柯里化(多参函数转换成单参数函..

u011927449的博客 276

ES6--函数扩展

一:函数参数的默认值 ES6允许为函数的参数设置默认值,即直接写在参数定义的后面: function log(x,y='world') { console.log(x,y); } log('hello');//hello world log('hello','sky');//hello sky log('hello','');//hello` 二:与解构赋值默认值结合使用f

喜欢你就像站在灿烂的晴空下,玻璃心碎一地,每片渣却都在发光。 1151

webapck优化-减少Babel的体积(19)

Babel为编译的每个文件都插入辅助代码,对一些公用的方法使用了非常小的辅助代码,比如_extend,默认情况下会被插入每个需要的文件,使代码体积越来越大。我们可以将babel runtime作为成为单独的模块,需要的文件按需引入即可,避免重复引用。【babel runtime是Babel提供的工具库,提供一些常用的函数和对象,帮助开发者兼容性问题等】实现babel runtime成为独立模块。

weixin_45750073的博客 403

Javascript尾递归编程

递归是编程中必不可少的一环,在算法和工程上会经常使用,但是随着计算量的增大,函数堆栈会大量堆积上一函数上下文中的变量和方法,会导致主线程栈的空间不足而造成栈溢出错误,由于新的函数压入堆栈后,上一函数仍然在堆栈中未被释放,因此内存资源消耗会十分大,对性能也会有很大影响。我们知道递归写起来确实方便,逻辑也容易理解,最简单的斐波那契数列问题,跳楼梯,一次只能1步或2步,跳n格有多少种方法很明显上述递归没有任何优化,利用函数堆栈来实现对上一结果的保存作为下一结果的支撑,函数开销大。...

发果叁 368
上一篇: 通过闭包和setTimeout让背景颜色从黄变白再变紫红
下一篇: 用align-self: baseline 来解决✘和文字对不齐的问题
十方魔
博客等级 码龄10年 202粉丝 93原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值