尾调用优化

news2024/11/19 4:30:26

尾调用优化

最近遇到一个堆栈溢出的问题,分析后发现可收敛为递归边界问题。结合“红宝书”中相关内容和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)
    }
    

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SpringBoot @JsonProperty + @JsonMixin注解 实现返回json数据key的转换

参考资料 Springboot 一个注解搞定返回参数key转换 【实用】Spring Boot 2.7新特性&#xff1a;JsonMixin 目录 一. 需求二. 前期准备三. 解决方式一: JsonProperty注解三. 解决方式二: JsonProperty JsonMixin注解3.1 方式1 混入单个类3.1.1 创建一个被JsonMixin注解修饰的抽…

ChatGPT初学者最佳实践

2022年11月底&#xff0c;ChatGPT引爆了新一轮AI的革命&#xff0c;也让人们意识到AI真的能够大幅度提高人们的工作效率&#xff0c;甚至有人担心自己的工作会因为AI不保。这种居安思危的意识是正确的&#xff0c;但是正如锛凿斧锯的出现&#xff0c;并没有让木匠这个行业消失&…

音频格式及转换代码

音频信号的读写、播放及录音 python已经支持WAV格式的书写&#xff0c;而实时的声音输入输出需要安装pyAudio(http://people.csail.mit.edu/hubert/pyaudio)。最后我们还将使用pyMedia(http://pymedia.org)进行Mp3的解码和播放。 音频信号是模拟信号&#xff0c;我们需要将其…

纯前端绘制的下雨效果

先上效果&#xff1a; 再上代码&#xff1a; <!--黏糊糊的菜单--> <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title><meta name"viewport" content"widt…

Golang笔记:使用os.Args和flag包编写命令行界面(CLIs)

文章目录 目的os.ArgsflagFlagSet总结 目的 命令行界面&#xff08;Command-line Interfaces&#xff09;是比较常用的一种软件形式。对于大部分开发运维人员来说很多时候CLIs可能比图形界面更加方便。软件开发时也经常会有需要开发命令行界面形式软件的情况&#xff0c;使用G…

Maven基础篇

Maven基本概念 Maven是什么 maven的本质是一个项目管理工程&#xff0c;将项目开发和管理过程抽象成一个项目对象模型&#xff08;POM&#xff09; POM&#xff08;Project Object Model&#xff09;&#xff1a;项目对象模型 作用 项目构建&#xff1a;提供标准的、跨平台…

什么是Selenium?如何使用Selenium进行自动化测试

目录 什么是 Selenium&#xff1f; Selenium 的优势是什么? 软件测试的需要 手动测试的挑战 自动化测试胜过手动测试 Selenium 对比 QTP 和 RFT Selenium 工具套件 Selenium 有哪些组件? Selenium RC &#xff08;远程控制&#xff09; Selenium IDE&#xff08;集成…

MIT6824——lab2(实现一个Raft库)的一些实现,问题,和思考

MIT 6824 关于lab2的实现&#xff0c;由于开源许可的问题&#xff0c;代码暂时不开源&#xff0c;下面是自己在实现过程中的思路&#xff0c;遇到的问题&#xff0c;以及总结 1 总结 1.1 raft整个流程 应用程序&#xff1a;kv数据库启动raft库&#xff0c;选举leader&#xf…

跳槽前,把自己逼成卷王...

前段时间席卷全互联网行业的内卷现象&#xff0c;想必有不少人都深陷其中。其实刚开始测试行业人才往往供不应求&#xff0c;而在发展了十几年后&#xff0c;很多人涌入这个行业开始面对存量竞争。红利期过去了&#xff0c;仅剩内部争夺。 即便如此&#xff0c;测试行业仍有许…

AspNetCore中的配置文件详解

1 配置文件 程序开发中&#xff0c;有些信息是要根据环境改变的&#xff0c;比如开发环境的数据库可能是本地数据&#xff0c;而生产环境下需要连接生产数据库&#xff0c;我们需要把这些信息放到程序外面&#xff0c;在程序运行时通过读取这些外部信息实现不改变程序代码适应…

计算机图形学-GAMES101-8

引言 着色是针对某一个点(片段)的应用&#xff0c;这里需要考虑着色的频率。  漫反射项代表光向四面八方均匀的反射出去&#xff0c;和观察方向无关。  Blinn-Phong反射模型结构如下&#xff1a; ) 一、Blinn-Phong模型 &#xff08;1&#xff09;Specular 什么时候才能看到…

SpringBoot实操篇1

一、工程打包与运行&#xff08;windows版&#xff09; 在浏览器中就可以访问到了&#xff0c;此时IDEA并没有启动。服务器就是命令行窗口。 跳过测试&#xff1a;可以看到多了很多数据&#xff0c;是因打包的时候将功能测试了一遍。在IDEA中可以关掉。 注意&#xff1a;必须…

nginx+php+mysql安装以及环境的搭建

目录 一、nginx的安装 二、php的下载安装 1.进入到/usr/local/下&#xff0c;下载php的安装包 2.解压 3.进入到php-8.2.6下&#xff0c;安装需要的依赖包 4.预编译php 5.编译 6.为php提供配置文件 7.为php-fpm提供配置文件 8.添加用户和用户组 9.修改php-fpm.conf配置…

JavaScript全解析-this指向

this指向&#xff08;掌握&#xff09; ●this 是一个关键字&#xff0c;是一个使用在作用域内的关键字 ●作用域分为全局作用域和局部作用域&#xff08;私有作用域或者函数作用域&#xff09; 全局作用域 ●全局作用域中this指向window 局部作用域 ●函数内的 this, 和 函…

OS之作业调度算法

目录 一、基本概念 二、先来先服务算法(FCFS) 三、短作业算法(SJF/SPF) 四、轮转调度算法(RR) 五、优先级调度算法 六、多级反馈队列调度算法 一、基本概念 T(周转)T(完成)-T(到达) 二、先来先服务算法(FCFS) 不利于短作业&#xff0c;非抢占式算法 算法思想&#xff…

Linux日志文件服务器搭建

文章目录 Linux日志文件服务器搭建节点规划案例实施(1)修改主机名(2)配置日志服务器(3)重新启动查看rsyslogd(4)配置客户端(5)测试 Linux日志文件服务器搭建 节点规划 IP主机名节点192.168.100.10serverlog日志服务器192.168.100.20clientlog日志客户端 必须两台机器可以ping…

IPv6之组播地址分类

本文目录 1、IPv6组播地址的结构2、特殊的预留地址和预留组播地址 1、IPv6组播地址的结构 IPv6组播地址是由固定的8bit地址前缀FF::/8&#xff0c;4bit的标志位&#xff0c;4bit组播范围和112bit多播组标识符&#xff08;组ID&#xff09;组成 FF::/8 IPv6的组播地址的最高8bi…

linux环境下设置python定时任务

linux环境下设置python定时任务 Linux 系统提供了使用者控制计划任务的命令 :crontab 命令 1、在linux环境执行命令,进入编辑界面 crontab -e2、按键盘 i 键&#xff0c;进入编辑模式&#xff0c;输入以下内容&#xff0c;设置2个定时任务 定时任务1&#xff1a;每隔10分钟执…

MindFusion.JavaScript Pack 2023.R1 Crack

图表控件添加了径向树布局和套索缩放工具。 2023年5月17日-10:53新版 特征 JavaScript图表中的新增功能 径向树布局-添加了新的类&#xff0c;它将树级别排列在围绕根的同心圆中。 套索缩放工具-控件现在支持使用套索工具进行缩放的几种方法&#xff1a; 可以将行为属性设置为…

单点登录协议

认证和授权 认证&#xff1a;确认该用户的身份是他所声明的那个人 授权&#xff1a;根据用户身份授予他访问特定资源的权限 当用户登录应用系统时&#xff0c;系统需要先认证用户身份&#xff0c;然后依据用户身份再进行授权。认证与授权需要联合使用&#xff0c;才能让用户真…