尾调用优化

尾调用优化

最近遇到一个堆栈溢出的问题,分析后发现可收敛为递归边界问题。结合“红宝书”中相关内容和ES6规范中的一些优化机制,整理记录如下。

前言

程序运行时,计算机会为应用程序分配一定的内存空间。应用程序会自行分配所获得的内存空间,一部分用于记录程序中调用的各个函数的运行情况,称为函数的调用栈(call stack)。函数的调用会在调用栈的最上层添加一个新的栈帧(stack frame),这个过程被称为入栈/压栈(push)。当函数的调用层数非常多时,调用栈会消耗掉大量内存,甚至会导致爆栈或溢出、导致程序卡顿或崩溃。

递归

通俗的说,递归是指方法自己调用自己。递归可以将一个复杂问题转化为一个与原问题相似且规模较小的问题来求解,如常见的深拷贝、斐波那契数列、阶乘、求和等。
优点:代码简洁,符合一般思维习惯,易于理解。
缺点:重复计算,耗内存,效率低,调用栈溢出等。
简单示例如下。可以发现,当n达到一定数值时,程序基本处于假死状态,需要等待很长时间才能返回结果。

// 递归实现 斐波那契数列
function fib(n) { 
    if (n < 2) { 
        return n; 
    } 
    return fib(n - 1) + fib(n - 2); 
} 

当然我们可以通过一些方法对递归进行优化,如:

  • 改为非递归实现(大部门递归都可以使用循环实现)

    function fib(n) {
        let arr = [0, 1, 1]
        for(let i=3; i<=n; i++) {
            arr[i] = arr[i-1] + arr[i-2]
        }
        return arr[n]
    }
    
  • 使用缓存

    function fib(n) {
        let cache = [0, 1, 1]
        function _fib(n) {
            if(cache[n]){
                return cache[n]
            }
            cache[n] = _fib(n-1) + _fib(n-2)
            return cache[n]
        }
        return _fib(n)
    }
    

可以看到,上面两种方式,要么代码不够简洁,要么不能直观看出实现的是什么功能。于是我们可以采用尾递归来改写:

function fib(n, n1 = 1, n2 = 1) {
    if (n <= 2) {
        return n2;
    }
    return fib(n - 1, n2, n1 + n2);
}
尾递归(tail recursion)

尾递归是递归的一个特例:函数在尾位置直接调用自身。
以阶乘函数为例
一般递归:每次递归调用,都会产生新的调用栈帧,用于保存当前函数上下文的信息(当函数返回时,会弹出这个栈帧)。随着递归的复杂度和深度的增加,栈帧数会以指数级增长,容易导致程序运行缓慢甚至爆栈。
在这里插入图片描述
尾递归:将递归方法中需要的状态数据通过参数的形式传给下一次调用,过程中只有一个栈帧被不断更新。当函数返回时,直接返回结果,无需其它操作,可以节省存储空间,提高运行效率。
在这里插入图片描述
常规递归与尾递归的主要区别:函数的调用、返回顺序和对栈空间利用方式不同。

尾调用(tail call)

尾递归是尾调用的一个特例。尾调用:外部函数的返回值是一个内部函数的返回值。

// 尾调用一般形式
function outerFunction() { 
 	return innerFunction();
}

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。也就是说,形式正确的尾调用(Proper Tail CallPTC)是可以被优化的,即尾调用优化(Tail Call OptimizationTCO)。

在传统的程序调用过程中,计算机必须记住调用函数的返回位置,才能在调用结束后返回该位置继续执行后续命令,该位置信息(即下一条指令的内存地址)一般被存放在调用栈上。

不同的是,在尾调用中,由于调用下级函数后,其所对应的上级函数也就结束了。所以执行到最后一步,不需要记住尾调用的返回位置,而是带着返回值直接从被调用函数直接跳转到调用函数的返回位置,减少了调用帧的存取次数(即可以用内层函数的栈帧覆盖掉外层函数的栈帧,而不是在外层函数栈帧下再新开一个)。

下面以官方展示的过程为例,说明示例程序的执行过程

  • ES6优化之前

    (1) 执行到outerFunction函数体,第一个栈帧被推到栈上。

    (2) 执行outerFunction函数体,到return语句。计算返回值必须先计算innerFunction

    (3) 执行到innerFunction 函数体,第二个栈帧被推到栈上。

    (4) 执行innerFunction 函数体,计算其返回值。

    (5) 将返回值传回outerFunction,然后outerFunction再返回值。

    (6) 将栈帧弹出栈外。

  • ES6优化之后

    (1) 执行到outerFunction函数体,第一个栈帧被推到栈上。

    (2) 执行outerFunction函数体,到达 return语句。为求值返回语句,必须先求值innerFunction

    (3) 引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction的返回值也是outerFunction的返回值。

    (4) 弹出outerFunction的栈帧。

    (5) 执行到innerFunction函数体,栈帧被推到栈上。

    (6) 执行innerFunction函数体,计算其返回值。

    (7) 将innerFunction的栈帧弹出栈外。

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

尾调用优化的条件:

  • 代码在严格模式下执行
  • 外部函数的返回值是对尾调用函数的调用
  • 尾调用函数返回后不需要执行额外的逻辑
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包

为什么要求严格模式:

在非严格模式下函数调用中允许使用 f.argumentsf.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

那么我们可以对斐波那契数列进行如下改造:

"use strict"; 
// 外层函数
function fib(n) { 
    return fibImpl(0, 1, n); 
} 
// 内层函数
function fibImpl(a, b, n) { 
    if (n === 0) { 
        return a; 
    } 
    return fibImpl(b, a + b, n - 1); 
}

运行程序,发现到达到某个数量级还是会堆栈溢出。
在这里插入图片描述
不是说TCO后只有一个栈帧吗,为什么还是会爆栈呢?

兼容性

调用栈的深度限制不由JS/ES规范控制。一般根据浏览器或设备(版本)不同而有所差异。考虑到递归编程逻辑复杂时,调用栈很容易达到成千上万甚至更多,容易导致爆栈/内存溢出。

因此JavaScript引擎设置了一个限制来防止这种操作引起的浏览器或设备内存耗尽而崩溃,所以当达到这个限制时,我们会看到一个报错:RangeError: Maximum call stack size exceeded

目前浏览器兼容性如下(一般认为只有Safari支持尾调用优化,经测试后确实如此,执行亿次递归也能顺畅执行)。

在这里插入图片描述

我们可以在V8的官方资料中找到如下解释

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

由于一些原因,这个优化方案并没有被所有浏览器厂商接受

  • 优化后实际上会丢失堆栈信息,开发调试过程比较困难

  • 开发者有可能写了死循环,但优化后一直运行不报错,会导致更严重的问题

  • 这个方案实际上是“隐式优化”,可能会带来一些副作用。从开发角度来说,更希望有“显式优化”,如提案中的一种方式:基于语法的尾调用(Syntactic Tail Calls),通过continue来表明应用此项优化

    function factorial(n,acc){
        if(n==1){
            return acc
        }
        return continue factorial(n-1,acc*n)
    }
    

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/20443.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

数组或结构体赋值时memcpy与直接赋值的效率比较

先上结论&#xff1a; 二者不一定谁快通常情况下&#xff0c;数组维度越大&#xff0c;使用memcpy效率更高数组维度越大&#xff0c;直接赋值耗时主体是循环耗时 Note&#xff1a; “等号赋值”被编译器翻译成一连串的MOV指令&#xff0c;而memcpy则是一个循环。“等号赋值”比…

深入解析PyTorch中的模型定义:原理、代码示例及应用

❤️觉得内容不错的话&#xff0c;欢迎点赞收藏加关注&#x1f60a;&#x1f60a;&#x1f60a;&#xff0c;后续会继续输入更多优质内容❤️ &#x1f449;有问题欢迎大家加关注私戳或者评论&#xff08;包括但不限于NLP算法相关&#xff0c;linux学习相关&#xff0c;读研读博…

【一起啃书】《机器学习》第六章 支持向量机

文章目录 第六章 支持向量机6.1 间隔和支持向量6.2 对偶问题6.3 核函数6.4 软间隔与正则化6.5 支持向量回归6.6 核方法6.7 一些问题 第六章 支持向量机 6.1 间隔和支持向量 给定训练样本集 D { ( x 1 , y 1 ) , ( x 2 , y 2 ) , . . . , ( x m , y m ) } , y i ∈ { − 1 , …

Day 1 认识软件测试——(软件测试定义、目的、原则)

Day 1 认识软件测试——(软件测试定义、目的、原则) 文章目录 Day 1 认识软件测试——(软件测试定义、目的、原则)软件测试的定义软件测试的目的软件测试的经济学问题黑盒测试白盒测试软件测试原则小结所谓软件测试,就是一个过程或一系列过程,用来确定计算机代码完成了其…

《我命由我不由天》蔡志忠——笔记一

目录 简介 经典摘录 三岁决定一生 父母该什么时候放手 确定将来要成为什么 积极主动为目标而努力 叛逆是最伟大的创意 父亲给蔡志忠最大的影响是教会他两件事 价值观缺陷导致的后果 人有三个阶段 简介 作者 蔡志忠&#xff0c;李虹。 蔡志忠&#xff1a;漫画家、哲…

Vue加SpringBoot实现项目前后端分离

首先需要搭建一个Vue的脚手架项目&#xff08;已经放在gitee里面了&#xff0c;下面是gitee网址&#xff0c;可以直接拉&#xff09; (vue-web: 这个是Vue项目模板&#xff0c;没有后台数据) 那么接下来就是实现前后端分离的步骤 首先我们需要有一个登录页面 登录的点击事件利用…

图神经网络:(节点分类)在KarateClub数据集上动手实现图神经网络

文章说明&#xff1a; 1)参考资料&#xff1a;PYG官方文档。超链。 2)博主水平不高&#xff0c;如有错误还望批评指正。 3)我在百度网盘上传了这篇文章的jupyter notebook。超链。提取码8888。 文章目录 文献阅读&#xff1a;代码实操&#xff1a; 文献阅读&#xff1a; 参考文…

【Hello Algorithm】归并排序及其面试题

作者&#xff1a;小萌新 专栏&#xff1a;算法 作者简介&#xff1a;大二学生 希望能和大家一起进步 本篇博客简介&#xff1a;介绍归并排序和几道面试题 归并排序及其面试题 归并排序归并排序是什么归并排序的实际运用归并排序的迭代写法归并排序的时间复杂度 归并排序算法题小…

(十一)地理数据库创建——创建新的地理数据库

地理数据库创建——创建新的地理数据库 目录 地理数据库创建——创建新的地理数据库 1.地理数据库概述2.地理数据库建立一般过程2.1地理数据库设计2.2地理数据库建立2.2.1从头开始建立一个新的地理数据库2.2.2移植已经存在数据到地理数据库2.2.3用CASE工具建立地理数据库 2.3建…

Python 科研绘图可视化(后处理)Matplotlib - 2D彩图

Introduction 科研可视化是将数据和信息转化为可视化形式的过程&#xff0c;旨在通过图形化展示数据和信息&#xff0c;使得科研工作者能够更好地理解和分析数据&#xff0c;并从中发现新的知识和洞见。科研可视化可以应用于各种领域&#xff0c;如生物学、物理学、计算机科学…

C++类和对象再探

文章目录 const成员再谈构造函数成员变量的定义函数体内赋值初始化列表 隐式类型转换explicitstatic成员 const成员 我们知道在调用类的成员函数时,会有一个默认的this指针且这个this指针时不可以被修改的,例如在日期类中,会有隐式的Date * const this;注意这里默认会在this前…

一五一、web+小程序骨架屏整理

骨架屏介绍 请点击查看智能小程序骨架屏 车载小程序骨架屏 车载小程序为方便开发者设置骨架屏&#xff0c;在智能小程序的基础上抽取出骨架屏模板&#xff0c;开发者只需要在 skeleton 文件夹下配置config.json&#xff08;page 和骨架屏的映射关系文件&#xff09;即可生效骨…

第十四届蓝桥杯青少组模拟赛Python真题 (2022年11月8日)

第十四届蓝桥杯青少组模拟赛Python真题 (2022年11月8日) 编程题 第 1 题 问答题 二进制位数 十进制整数2在十进制中是1位数,在二进制中对应10,是2位数。 十进制整数22在十进制中是2位数,在二进制中对应10110,是5位数。 请问十进制整数2022在二进制中是几位数? 第2题问…

Pr 拍立得风格图片展示

哈喽&#xff0c;各位小伙伴&#xff01;今天我们来学习一下如何制作拍立得风格的照片展示效果&#xff1f; 新建三个序列 在开始之前&#xff0c;我们需要新建三个序列 序列1&#xff1a;总合成-尺寸1902*1080序列2&#xff1a;照片合成-尺寸1920*1080序列3&#xff1a;照片…

自动驾驶TPM技术杂谈 ———— I-vista验收标准(试验规程)

文章目录 术语介绍试验准备场地要求环境要求精度要求边界车辆&路沿石 试验方法能力试验双边界车辆平行车位白色标线平行车位双边界车辆垂直车位白色标线垂直车位方柱垂直车位双边界车辆斜向车位白色标线斜向车位 新功能评价平行车位远程操控泊入泊出试验垂直车位远程操控泊…

能伸展脖子的机器人?东京大学最新研究成果:基于鸵鸟肌肉骨骼结构和行为,具有高度灵活性的新型机械臂—RobOstrich(附论文)

原创 | 文 BFT机器人 得益于高度灵活的颈部&#xff0c;鸟类可以做很多事情&#xff0c;无论是转过头梳理自己的后背&#xff0c;在飞行过程中“眼观六路”&#xff0c;还是在地面或树上难以触及的角落和缝隙寻找食物。而在所有鸟类中&#xff0c;鸵鸟以其结实灵巧的颈部脱颖而…

​ NISP一级备考知识总结之信息安全概述、信息安全基础

参加每年的大学生网络安全精英赛通过初赛就可以嫖一张 nisp&#xff08;国家信息安全水平考试&#xff09; 一级证书&#xff0c;nisp 一级本身没啥考的价值&#xff0c;能白嫖自然很香 1.信息安全概述 信息与信息技术 信息概述 信息奠基人香农认为&#xff1a;信息是用来消…

【Linux】如何实现单机版QQ,来看进程间通信之管道

学会了管道&#xff0c;就可以实现简单的qq哦~ 文章目录 前言一、匿名管道总结 前言 为什么要进行进程间通信呢&#xff1f;因为需要以下这些事&#xff1a; 数据传输&#xff1a;一个进程需要将它的数据发送给另一个进程 资源共享&#xff1a;多个进程之间共享同样的资源。 …

ChatGPT实现旅行安排

工作之余&#xff0c;出门旅行一趟放松放松身心&#xff0c;是对自己辛勤工作最好的犒劳方式之一。旅行可以近郊游、可以远游&#xff0c;可以穷游&#xff0c;可以自驾游&#xff0c;可以一言不合打飞的喂鸽子&#xff0c;方式多种多样。但是多数情况&#xff0c;我们是到一个…

论文解析-基于 Unity3D 游戏人工智能的研究与应用

1.重写 AgentAction 方法 1.1 重写 AgentAction 方法 这段代码是一个重写了 AgentAction 方法的方法。以下是对每行代码解释&#xff1a; ①public override void AgentAction(float[] vectorAction) 这行代码声明了一个公共的、重写了父类的 AgentAction 方法的方法。它接受…