调用栈(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

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

  1. 详解javascript的类

    前言 生活有度,人生添寿. 原文地址:详解javascript的类 博主博客地址:Damonare的个人博客 Javascript从当初的一个"弹窗语言",一步步发展成为现在前后端 ...

  2. 详解Javascript的继承实现(二)

    上文<详解Javascript的继承实现>介绍了一个通用的继承库,基于该库,可以快速构建带继承关系和静态成员的javascript类,好使用也好理解,额外的好处是,如果所有类都用这种库来构 ...

  3. 【转】详解JavaScript中的this

    ref:http://blog.jobbole.com/39305/ 来源:foocoder 详解JavaScript中的this JavaScript中的this总是让人迷惑,应该是js众所周知的坑 ...

  4. js对象详解(JavaScript对象深度剖析,深度理解js对象)

    js对象详解(JavaScript对象深度剖析,深度理解js对象) 这算是酝酿很久的一篇文章了. JavaScript作为一个基于对象(没有类的概念)的语言,从入门到精通到放弃一直会被对象这个问题围绕 ...

  5. 详解javascript中的this对象

    详解javascript中的this对象 前言 Javascript是一门基于对象的动态语言,也就是说,所有东西都是对象,一个很典型的例子就是函数也被视为普通的对象.Javascript可以通过一定的 ...

  6. 详解JavaScript的任务、微任务、队列以及代码执行顺序

    摘要: 理解JS的执行顺序. 作者:前端小智 原文:详解JavaScript的任务.微任务.队列以及代码执行顺序 思考下面 JavaScript 代码: console.log("scrip ...

  7. (转载)详解Javascript中prototype属性(推荐)

    在典型的面向对象的语言中,如java,都存在类(class)的概念,类就是对象的模板,对象就是类的实例.但是在Javascript语言体系中,是不存在类(Class)的概念的,javascript中不 ...

  8. 【转载】详解CreateProcess调用内核创建进程的过程

    原文:详解CreateProcess调用内核创建进程的过程 昨天同学接到了腾讯的电面,有一题问到了CreateProcess创建进程的具体实现过程,他答得不怎么好吧应该是, 为了以防万一,也为了深入学 ...

  9. WebService核心文件【server-config.wsdd】详解及调用示例

    WebService核心文件[server-config.wsdd]详解及调用示例 作者:Vashon 一.准备工作 导入需要的jar包: 二.配置web.xml 在web工程的web.xml中添加如 ...

随机推荐

  1. [Advanced Python] 13 - "Decorator": syntactic sugar

    单独一章复习:Python 函数装饰器,廖雪峰讲装饰器 基本概念 函数作为变量 从函数中返回函数 子函数写在外面貌似也可以,可这样就少了“封装性”. def greet(): return " ...

  2. MySQL之增删改查之

    MySQL之增删改查   前言:以下是MySQL最基本的增删改查语句,很多IT工作者都必须要会的命令,也是IT行业面试最常考的知识点,由于是入门级基础命令,所有所有操作都建立在单表上,未涉及多表操作. ...

  3. 后端(spring boot)解决跨区域问题

    一.环境: 前端 vue element-ui 后端:spring boot 工具:IDEA Maven Node 数据库:MySql 二.首先我们需要了解什么叫跨区域访问问题 跨区域访问是指:不同域 ...

  4. CF #579 (Div. 3) C.Common Divisors

    C.Common Divisors time limit per test2 seconds memory limit per test256 megabytes inputstandard inpu ...

  5. 正睿OI DAY3 杂题选讲

    正睿OI DAY3 杂题选讲 CodeChef MSTONES n个点,可以构造7条直线使得每个点都在直线上,找到一条直线使得上面的点最多 随机化算法,check到答案的概率为\(1/49\) \(n ...

  6. java几个常见的基础错误

    1.String 相等 稍微有点经验的程序员都会用equals比较而不是用 ==,但用equals就真的安全了吗,看下面的代码 user.getName().equals("xiaoming ...

  7. python基础(十七)

    今日主要内容 正则表达式 logging模块 一.正则表达式 (一)什么是正则表达式 正则表达式的定义: 是对字符串操作的一种逻辑公式,就是用事先定义好的一些特定字符.及这些特定字符的组合,组成一个& ...

  8. SpringBoot-ElasticJob封装快速上手使用(分布式定时器)

    elastic-job-spring-boot qq交流群:812321371 1 简介 Elastic-Job是一个分布式调度解决方案,由两个相互独立的子项目Elastic-Job-Lite和Ela ...

  9. 设计模式----行为型模式之命令模式(Command Pattern)

    下面来自head first设计模式的命令模式一章节. 定义 将"请求"封装成对象,以便使用不同的请求.队列或者日志来参数化其他对象.命令模式也支持可撤销的操作. 类图 注: 1. ...

  10. 版本控制之Git小结

    一.版本控制 1.1 什么是版本控制 版本控制是一种记录一个或若干个文件内容变化,以便将来查阅特定版本修订情况的系统.可以对任何类型的文件进行版本控制. 1.2 为什么需要版本控制 有了版本控制就可以 ...