详解JavaScript调用栈、尾递归和手动优化

调用栈(Call Stack)

调用栈(Call Stack)是一个基本的计算机概念,这里引入一个概念:栈帧。

栈帧是指为一个函数调用单独分配的那部分栈空间。

当运行的程序从当前函数调用另外一个函数时,就会为下一个函数建立一个新的栈帧,并且进入这个栈帧,这个栈帧称为当前帧。而原来的函数也有一个对应的栈帧,被称为调用帧。每一个栈帧里面都会存入当前函数的局部变量。

当函数被调用时,就会被加入到调用栈顶部,执行结束之后,就会从调用栈顶部移除该函数。并将程序运行权利(帧指针)交给此时栈顶的栈帧。这种后进后出的结构也就是函数的调用栈。

而在JavaScript里,可以很方便的通过console.trace()这个方法查看当前函数的调用帧

尾调用

说尾递归之前必须先了解一下什么是尾调用。简单的说,就是一个函数执行的最后一步是将另外一个函数调用并返回。

以下是正确示范:

// 尾调用正确示范1.0
function f(x){
return g(x);
}
// 尾调用正确示范2.0
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}

1.0程序的最后一步即是执行函数g,同时将其返回值返回。2.0中,尾调用并不是非得写在最后一行中,只要执行时,是最后一步操作就可以了。

以下是错误示范:

// 尾调用错误示范1.0
function f(x){
let y = g(x);
return y;
}
// 尾调用错误示范2.0
function f(x){
return g(x) + 1;
}
// 尾调用错误示范3.0
function f(x) {
g(x); // 这一步相当于g(x) return undefined
}

1.0最后一步为赋值操作,2.0最后一步为加法运算操作,3.0隐式的有一句return undefined

尾调用优化

在调用栈的部分我们知道,当一个函数A调用另外一个函数B时,就会形成栈帧,在调用栈内同时存在调用帧A和当前帧B,这是因为当函数B执行完成后,还需要将执行权返回A,那么函数A内部的变量,调用函数B的位置等信息都必须保存在调用帧A中。不然,当函数B执行完继续执行函数A时,就会乱套。

那么现在,我们将函数B放到了函数A的最后一步调用(即尾调用),那还有必要保留函数A的栈帧么?当然不用,因为之后并不会再用到其调用位置、内部变量。因此直接用函数B的栈帧取代A的栈帧即可。当然,如果内层函数使用了外层函数的变量,那么就仍然需要保留函数A的栈帧,典型例子即是闭包。

在网上有很多关于讲解尾调用的博客文章,其中流传广泛的一篇中有这样一段。我不是很认同。

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) 的调用记录。

但我认为第一种中,也是先执行m+n这步操作,再调用函数g同时返回。这应当是一次尾调用。同时m+n的值也通过参数传入函数g内部,并没有直接引用,因此也不能说需要保存f内部的变量的值。

总得来说,如果所有函数的调用都是尾调用,那么调用栈的长度就会小很多,这样需要占用的内存也会大大减少。这就是尾调用优化的含义。

尾递归

递归,是指在函数的定义中使用函数自身的一种方法。函数调用自身即称为递归,那么函数在尾调用自身,即称为尾递归。

最常见的递归,斐波拉契数列,普通递归的写法:

function f(n) {
if (n === 0 || n === 1) return n
else return f(n - 1) + f(n - 2)
}

这种写法,简单粗暴,但是有个很严重的问题。调用栈随着n的增加而线性增加,当n为一个大数(我测了一下,当n为100的时候,浏览器窗口就会卡死。。)时,就会爆栈了(栈溢出,stack overflow)。这是因为这种递归操作中,同时保存了大量的栈帧,调用栈非常长,消耗了巨大的内存。

接下来,将普通递归升级为尾递归看看。

function fTail(n, a = 0, b = 1) {
if (n === 0) return a
return fTail(n - 1, b, a + b)
}

很明显,其调用栈为

复制代码 代码如下: 
fTail(5) => fTail(4, 1, 1) => fTail(3, 1, 2) => fTail(2, 2, 3) => fTail(1, 3, 5) => fTail(0, 5, 8) => return 5

被尾递归改写之后的调用栈永远都是更新当前的栈帧而已,这样就完全避免了爆栈的危险。

但是,想法是好的,从尾调用优化到尾递归优化的出发点也没错,然并卵:),让我们看看V8引擎官方团队的解释

Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.

意思就是人家已经做好了,但是就是还不能不给你用:)嗨呀,好气喔。

当然,人家肯定是有他的正当理由的:

  1. 在引擎层面消除尾递归是一个隐式的行为,程序员写代码时可能意识不到自己写了死循环的尾递归,而出现死循环后又不会报出stack overflow的错误,难以辨别。
  2. 堆栈信息会在优化的过程中丢失,开发者调试非常困难。

道理我都懂,但是不信邪的我拿nodeJs(v6.9.5)手动测试了一下:

好的,我服了

手动优化

虽然我们暂时用不上ES6的尾递归高端优化,但递归优化的本质还是为了减少调用栈,避免内存占用过多,爆栈的危险。而俗话说的好,一切能用递归写的函数,都能用循环写――尼克拉斯?夏,如果将递归改成循环的话,不就解决了这种调用栈的问题么。

方案一:直接改函数内部,循环执行

function fLoop(n, a = 0, b = 1) {
while (n--) {
[a, b] = [b, a + b]
}
return a
}

这种方案简单粗暴,缺点就是没有递归的那种写法比较容易理解。

方案二:Trampolining(蹦床函数)

function trampoline(f) {
while (f && f instanceof Function) {
f = f()
}
return f
}
function f(n, a = 0, b = 1) {
if (n > 0) {
[a, b] = [b, a + b]
return f.bind(null, n - 1, a, b)
} else {
return a
}
}
trampoline(f(5)) // return 5

这种写法算是容易理解一些了,就是蹦床函数的作用需要仔细看看。缺点还有就是需要修改原函数内部的写法。

方案三:尾递归函数转循环方法

function tailCallOptimize(f) {
let value
let active = false
const accumulated = []
return function accumulator() {
accumulated.push(arguments)
if (!active) {
active = true
while (accumulated.length) {
value = f.apply(this, accumulated.shift())
}
active = false
return value
}
}
}
const f = tailCallOptimize(function(n, a = 0, b = 1) {
if (n === 0) return a
return f(n - 1, b, a + b)
})
f(5) // return 5

经过 tailCallOptimize 包装后返回的是一个新函数 accumulator,执行 f时实际执行的是这个函数。这种方法可以不用修改原递归函数,当调用递归时只用使用该方法转置一下便可解决递归调用的问题。

总结

尾递归优化是个好东西,但既然暂时用不上,那我们就该在平时编码的过程中,对使用到了递归的地方特别敏感,时刻避免出现死循环,爆栈等危险。毕竟,好的工具不如好的习惯。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。

您可能感兴趣的文章:

文章同步发布: https://www.geek-share.com/detail/2707475570.html

原文地址:https://www.cnblogs.com/xiaoqifeng/p/11744062.html

时间: 2024-10-14 18:07:07

详解JavaScript调用栈、尾递归和手动优化的相关文章

详解javascript闭包特性

闭包(closure)是Javascript语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现. 闭包有三个特性: 1.函数嵌套函数 2.函数内部可以引用外部的参数和变量 3.参数和变量不会被垃圾回收机制回收 闭包是指有权访问另一个函数作用域中的变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,通过另一个函数访问这个函数的局部变量 使用闭包有一个优点,也是它的缺点,就是可以把局部变量驻留在内存中,可以避免使用全局变量.全局变量在每个模块都可调用,这势必将是灾难性的. 所以

详解javascript中的this对象

详解javascript中的this对象 前言 Javascript是一门基于对象的动态语言,也就是说,所有东西都是对象,一个很典型的例子就是函数也被视为普通的对象.Javascript可以通过一定的设计模式来实现面向对象的编程,其中this "指针"就是实现面向对象的一个很重要的特性.但是this也是Javascript中一个非常容易理解错,进而用错的特性.特别是对于接触静态语言比较久了的同志来说更是如此. 示例说明 我们先来看一个最简单的示例: <script type=&q

(转载)C++内存分配方式详解——堆、栈、自由存储区、全局/静态存储区和常量存储区

C++内存分配方式详解--堆.栈.自由存储区.全局/静态存储区和常量存储区       栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区.里面的变量通常是局部变量.函数参数等.在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用.和堆一样,用户栈在程序执行期间可以动态地扩展和收缩. 堆,就是那些由 new 分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个 new 就要对应一个 delete.如果程序员没有释放掉,那么在程序结

Android学习Scroller(五)——详解Scroller调用过程以及View的重绘

MainActivity如下: package cc.ww; import android.os.Bundle; import android.widget.ImageView; import android.widget.ImageView.ScaleType; import android.widget.RelativeLayout; import android.widget.RelativeLayout.LayoutParams; import android.app.Activity;

【转】详解JavaScript中的异常处理方法

有三种类型的编程错误:(1)语法错误和(2)运行时错误(3)逻辑错误:语法错误: 语法错误,也被称为解析错误,在编译时进行传统的编程语言,并出现在JavaScript解释时. 例如,下面一行将导致一个语法错误,因为它缺少一个右括号: <script type="text/javascript"> <!-- window.print(; //--> </script> 当一个语法错误在JavaScript中出现,只有在同一个线程中包含的语法错误的影响,

详解JavaScript中的replace()函数

Javascript中字符串对象有一个方法replace(),它的作用非常强大.这里把它的用法整理一下.  一.方法简介 该方法的签名是:replace([RegExp|String],[String|Function]). 该方法 返回一个新的字符串,但并不改变字符串本身. 该方法接收2个参数,第一个参数可以是字符串,也可以是一个正则表达式:第二个参数可以是一个字符串,也可以是一个函数.其中第2个参数如果是函数,那么用起来是十分强大而且灵活的,不过相对来说也比较难掌握.下面就其用法进行详细说明

详解JavaScript中的arc的方法

今天说说JavaScript在网页中画圆的函数arc! 一.arc所需要的参数设置 1 arc(x, y, radius, startAngle, endAngle, counterclockwise); 其中x,y,radius都很容易理解,那么重点说说startAngle,endAngle和counterclockwise三个参数!    二.arc参数详解 1,startAngle和endAngle分别指圆开始的角度和结束的角度,手册上面说的是开始的角度为0,结束的角度为Math.PI*2

Nginx中文详解、配置部署及高并发优化

  一.Nginx常用命令: 1. 启动 Nginx          /usr/local/nginx/sbin/nginx [email protected]:sudo ./sbin/nginx 2. 停止 Nginx [email protected]:sudo ./sbin/nginx -s stop[email protected]:sudo ./sbin/nginx -s quit -s都是采用向 Nginx 发送信号的方式.3. Nginx 重载配置 [email protecte

详解Javascript的继承实现

1. 混合方式实现及问题 了解问题之前,先看看它的具体实现: //父类构造函数 function Employee(name, salary) { //实例属性:姓名 this.name = name; //实例属性:薪资 this.salary = salary; } //通过字面量对象设置父类的原型,给父类添加实例方法 Employee.prototype = { //由于此处添加实例方法时也是通过修改父类原型处理的, //所以必须修改父类原型的constructor指向,避免父类实例的co