Tail Call
一、什么是尾调用
尾调用(Tail Call)是函数式编程的一个重要概念。
一个函数里的最后一个动作是返回一个函数的调用结果,用简单的一句话描述就是“在函数的最后一步调用函数”。
function f(x){
let y = x + 1;
return g(y);
}
函数 f 的最后一步是调用函数 g,这就是尾调用。
以下几种情况,都不属于尾调用:
function f(x) {
return g(x) + 1;
}
function f(x) {
var ret = g(x);
return (ret === 0) ? 1 : ret;
}
这是因为程序必须返回 g(x) 函数的调用以检查、更动 g(x) 的返回值。
二、尾调用优化
传统模式的编译器对于尾调用的处理方式就像处理其他普通函数调用一样,总会在调用时在内存中形成一个“调用记录”,又称“调用帧”(call frame),并将其推入调用栈顶部,用于表示该次函数调用,保存调用位置和内部变量等信息。
当一个函数调用发生时,计算机必须 “记住”调用函数的返回位置,才可以在调用结束时带着返回值回到该位置,返回位置一般存在调用栈上。在尾调用这种特殊情形中,计算机理论上可以不需要记住尾调用的位置而从被调用的函数直接带着返回值返回调用函数的返回位置(相当于直接连续返回两次)。
如果在函数 A 的内部调用函数 B,那么在 A 的调用记录上方,还会形成一条 B 的调用记录,等到 B 运行结束,将结果返回 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那在 B 的调用记录上方还有一个 C 的调用记录栈,以此类推,所有的调用记录,就形成一个调用栈。这有可能会出现函数调用栈过大甚至溢出的情况。
尾调用由于是函数的最后一步,所以当前函数帧上包含调用位置、局部变量等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到被尾调用的函数。
尾调用消除:
在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形式参数与局部变量的信息)。
尾调用优化:
只保留内层函数的调用记录,如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。
产生这种函数帧更动代码与 “jump”(而不是一般常规函数调用的代码)的过程称作尾调用消除(Tail Call Elimination)或尾调用优化(Tail Call Optimization, TCO)。尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高,也因此使得高效的结构编程成为现实。
function f() {
let a = 1, b = 2;
return g(a+b);
}
f(); // 等价于 function f(){ return g(3); } // 等价于 g(3);
上面代码中,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 a 和 b 的值、函数 f 的调用位置等信息。但由于调用 g 后,函数 f 就结束了,所以执行到最后一步,完全可以删除函数 f 的调用记录,只保留 g(3) 的调用记录。
然而,对于 C++ 等语言来说,在函数最后 return g(x); 并不一定是尾递归,因为在返回之前很可能涉及到对象的析构函数,使得 g(x) 不是最后执行的那个。这可以通过返回值优化来解决。
三、尾递归
如果尾调用自身,则称为尾递归。
递归非常耗费内存,因为需要同时保存成百上千条调用记录,很容易出现“栈溢出”的错误。但对于尾递归而言,由于只存在一个调用记录,所以不会发生“栈溢出”的错误。
int factorial(n) {
if(n == 1) {
return 1;
}
return n * factorial(n-1);
}
factorial(5); // 120
上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,空间复杂度 O(n),当 n 足够大时,则会发生调用栈溢出。如果改写成尾递归,则只保留一个调用记录,空间复杂度 O(1)。
int factorial(n, total) {
if(n == 1) {
return total;
}
return factorial(n-1, n*total);
}
factorial(5,1);
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。在 ES6 中,严格模式下,宣称支持尾调用优化这个新特性。
但目前 V8 引擎并没有优化尾递归,V8 团队认为做尾递归优化存在一系列问题,因此倾向于支持用显示的语法来实现,而非做优化。在 node 环境和浏览器环境都做了测试,当尾递归函数中传入 n 过大时,同样会出现栈溢出的情况,不管是否开启严格模式,所以似乎尾递归优化并没有起作用。
四、递归函数的改写
尾递归的实现往往需要改写递归函数,确保最后一步只调用自身。就是把所有用到的内部变量改写成函数的参数。
比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点是使函数看起来不太直观,为什么计算 5 的阶乘,需要传入两个参数 5 和 1。两个方法可以解决这个问题。
在尾递归函数之外,再提供一个正常形式的函数
int tailFactorial(n, total) {
if(n == 1) {
return total;
}
return tailFactorial(n - 1, n * total);
}
int factorial(n) {
return tailFactorial(n, 1);
}
factorial(5); // 120
上面代码通过一个正常形式的阶乘函数 factorial,调用尾递归函数 tailFactorial,看起来就正常多了。
函数式编程中有一个概念叫做柯里化(currying),简单来说就是将多参数的函数转换为单参数函数的形式,这里也可以使用柯里化。
function currying(fn, n) {
return function (m) {
return fn.call(this, m, n);
}
}
function tailFactorial(n, total) {
if(n===1) {
return total;
}
return tailFactorial(n - 1, n * total);
}
const factorial = currying(tailFactorial, 1)
factorial(5) // 120
上面代码通过柯里化,将尾递归函数 tailFactorial 变为只接受一个参数的 factorial 函数。
使用 ES6 的默认值特性。
function factorial(n, total=1) {
if(n===1) {
return total;
}
return factorial(n-1, n * total);
}
factorial(5);
上面代码中参数 total 有默认值 1,调用时可以不提供这个值。
总结,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作的命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言,我们需要知道循环可以用递归来代替,而一旦使用递归,就最好使用尾递归。
五、文章
百度百科
尾调用优化(Tail Call Optimization)
深入理解JavaScript中的尾调用(Tail Call)
Tail Call的更多相关文章
- head/tail实现
只实现了head/tail的基本功能,默认显示十行及-n参数. 一.使用带缓冲的系统调用. write/read等系统调用是不带缓冲的,可以包装一层,使其带缓冲. t ...
- REDHAT一总复习1 输出重定向及head tail的用法
1.使用bash命令,在server机上完成以下任务.(考点是:head tail的使用) .显示/usr/bin/clean-binary-files文件的前12行,并将其输出到/home/stu ...
- tail命令详解
搜索 纠正错误 添加实例 tail 在屏幕上显示指定文件的末尾若干行 补充说明 tail命令 用于输入文件中的尾部内容.tail命令默认在屏幕上显示指定文件的末尾10行.如果给定的文件不止一个,则在 ...
- Linux命令详解之—tail命令
tail命令也是一个非常常用的文件查看类的命令,今天就为大家介绍下Linux tail命令的用法. 更多Linux命令详情请看:Linux命令速查手册 Linux tail命令主要用来从指定点开始将文 ...
- linux命令之tail
tail用于输出文件末尾部分.一个比较有用的功能是tail + grep实现类似于安卓开发时调试使用的logcat,具体操作是: 一般我是用SecureCRT连接linux,然后使用SecureCRT ...
- PHP实现linux命令tail -f
PHP实现linux命令tail -f 今天突然想到之前有人问过我的一个问题,如何通过PHP实现linux中的命令tail -f,这里就来分析实现下. 这个想一想也挺简单,通过一个循环检测文件,看文件 ...
- tail -f 和 -F 的用法
tail -f 和 -F 的用法 Tai 2010-08-16 16:03:18 -f 是--follow[=HOW]的缩写, 可以一直读文件末尾的字符并打印出来."[=HOW]" ...
- scala tail recursive优化,复用函数栈
在scala中如果一个函数在最后一步调用自己(必须完全调用自己,不能加其他额外运算子),那么在scala中会复用函数栈,这样递归调用就转化成了线性的调用,效率大大的提高.If a function c ...
- linux head、tail、sed、cut、grep、find
head用法: head 参数 文件名 -cn:显示前n个字节 -n:显示前n行 例子:head -c20 1.txt 显示1.txt文件中前20个字符 ls | head -20:显示前20 ...
- linux命令每日一练习-tail
tail 是查看文件的末尾 tail -n 5*** 显示文件×××的最后5行 tail -n +5 ××× 显示文件×××从第5行开始的内容 tail -f *** 监视文件×××的末尾.循环展示
随机推荐
- 7-6 jmu_python_最大公约数&最小公倍数 (10 分)
本题要求从键盘输入两个整数(以逗号间隔),编程求出这两个数的最大公约数和最小公倍数 提示:求最大公约数可用辗转相除法,最小公倍数用两数的积除以最大公约数 输入格式: 在一行中输入两个整数,以逗号间隔 ...
- 获取的ajax方法return的返回值的问题解析
今天刚上班就偶遇关于获取Ajax方法return的返回值的问题,这里小记一下. 在使用jquery中,如果获取不到ajax返回值,原因有二: 一.ajax未使用同步 ajax未使用同步,导致数据未加载 ...
- Java堆内存是线程共享的!面试官:你确定吗?
Java作为一种面向对象的,跨平台语言,其对象.内存等一直是比较难的知识点,所以,即使是一个Java的初学者,也一定或多或少的对JVM有一些了解.可以说,关于JVM的相关知识,基本是每个Java开发者 ...
- React的路由react-router
意思是:当你写一个web应用时候,应噶install的是react-router-dom,同样的,当你想写一个Native应用时候,需要install的是react-router-native,这两个 ...
- 对两个有序数组重新去重合并排序js实现
这里主要是要利用两个数组有序这个条件,所以只需两个指针分别指向两个数组,当其中一个小于另外一个就移动该指针,反之则移动另外一个指针,如果相等则均向后移动. 结束条件是,当任意一个数组的指针移到末尾则跳 ...
- C#.Net全栈工程师之路-学习路径
C#.Net全栈工程师之路-学习路径 按架构分: C/S架构: B/S架构: Mobile移动开发: 按技术点分: C#编程基础以及OOP面向对象编程: 数据库基础以及高级应用(MYSQL+MSSQL ...
- django 从零开始 3认识url解析
在视图函数中定义一个函数abc 接受得到的参数 并显示在页面上 urls中设置 在页面会显示出错误 找不到该url ,原因是django1版本中使用的是url和re_path ,突然django2变 ...
- 详解分页组件中查count总记录优化
1 背景 研究mybatis-plus(以下简称MBP),使用其分页功能时.发现了一个JsqlParserCountOptimize的分页优化处理类,官方对其未做详细介绍,网上也未找到分析该类逻辑的只 ...
- PDIUSBD12管脚简述
PDIUSBD12管脚简述 PDIUSBD12管脚及简述 PDIUSBD12读写时序图 CS_N是片选信号,当片选信号位低电平时,下面的操作才有效.由于板子上将CS_N接地,所以它一 ...
- 公钥体系(PKI)等密码学技术基础
公钥体系(PKI)等密码学技术基础 公钥体系(Public Key Infrastructure, PKI)的一些概念 对称密码算法, 典型算法:DES, AES 加解密方共用一个密钥 加/解密速度快 ...