一、什么是尾调用

尾调用(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 的调用记录栈,以此类推,所有的调用记录,就形成一个调用栈。这有可能会出现函数调用栈过大甚至溢出的情况。

尾调用由于是函数的最后一步,所以当前函数帧上包含调用位置、局部变量等大部分的东西都不需要了,当前的函数帧经过适当的更动以后可以直接当作被尾调用的函数的帧使用,然后程序即可以跳到被尾调用的函数。

  1. 尾调用消除:

    在不改变当前调用栈(也不添加新的返回位置)的情况下跳到新函数的一种优化(完全不改变调用栈是不可能的,还是需要校正调用栈上形式参数与局部变量的信息)。

  2. 尾调用优化:

    只保留内层函数的调用记录,如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。

产生这种函数帧更动代码与 “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。两个方法可以解决这个问题。

  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 函数。

  2. 使用 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的更多相关文章

  1. head/tail实现

         只实现了head/tail的基本功能,默认显示十行及-n参数.       一.使用带缓冲的系统调用.       write/read等系统调用是不带缓冲的,可以包装一层,使其带缓冲. t ...

  2. REDHAT一总复习1 输出重定向及head tail的用法

    1.使用bash命令,在server机上完成以下任务.(考点是:head  tail的使用) .显示/usr/bin/clean-binary-files文件的前12行,并将其输出到/home/stu ...

  3. tail命令详解

    搜索 纠正错误  添加实例 tail 在屏幕上显示指定文件的末尾若干行 补充说明 tail命令 用于输入文件中的尾部内容.tail命令默认在屏幕上显示指定文件的末尾10行.如果给定的文件不止一个,则在 ...

  4. Linux命令详解之—tail命令

    tail命令也是一个非常常用的文件查看类的命令,今天就为大家介绍下Linux tail命令的用法. 更多Linux命令详情请看:Linux命令速查手册 Linux tail命令主要用来从指定点开始将文 ...

  5. linux命令之tail

    tail用于输出文件末尾部分.一个比较有用的功能是tail + grep实现类似于安卓开发时调试使用的logcat,具体操作是: 一般我是用SecureCRT连接linux,然后使用SecureCRT ...

  6. PHP实现linux命令tail -f

    PHP实现linux命令tail -f 今天突然想到之前有人问过我的一个问题,如何通过PHP实现linux中的命令tail -f,这里就来分析实现下. 这个想一想也挺简单,通过一个循环检测文件,看文件 ...

  7. tail -f 和 -F 的用法

    tail -f 和 -F 的用法  Tai 2010-08-16 16:03:18 -f 是--follow[=HOW]的缩写, 可以一直读文件末尾的字符并打印出来."[=HOW]" ...

  8. scala tail recursive优化,复用函数栈

    在scala中如果一个函数在最后一步调用自己(必须完全调用自己,不能加其他额外运算子),那么在scala中会复用函数栈,这样递归调用就转化成了线性的调用,效率大大的提高.If a function c ...

  9. linux head、tail、sed、cut、grep、find

    head用法: head 参数 文件名 -cn:显示前n个字节    -n:显示前n行 例子:head -c20 1.txt 显示1.txt文件中前20个字符 ls |  head -20:显示前20 ...

  10. linux命令每日一练习-tail

    tail 是查看文件的末尾 tail -n 5*** 显示文件×××的最后5行 tail -n +5 ××× 显示文件×××从第5行开始的内容 tail -f *** 监视文件×××的末尾.循环展示

随机推荐

  1. [面试专题]Web缓存详解

    Web缓存详解 标签(空格分隔): 缓存 缓存之于性能优化 请求更快:通过将内容缓存在本地浏览器或距离最近的缓存服务器(如CDN),在不影响网站交互的前提下可以大大加快网站加载速度. 降低服务器压力: ...

  2. 前端每日实战:119# 视频演示如何用纯 CSS 创作一个接扎啤的动画(内含2个视频)

    效果预览 按下右侧的"点击预览"按钮可以在当前页面预览,点击链接可以全屏预览. https://codepen.io/comehope/pen/rZeOQp 可交互视频 此视频是可 ...

  3. LeetCode 81.Search in Rotated Sorted Array II(M)

    题目: Suppose an array sorted in ascending order is rotated at some pivot unknown to you beforehand. ( ...

  4. 【猫狗数据集】pytorch训练猫狗数据集之创建数据集

    猫狗数据集的分为训练集25000张,在训练集中猫和狗的图像是混在一起的,pytorch读取数据集有两种方式,第一种方式是将不同类别的图片放于其对应的类文件夹中,另一种是实现读取数据集类,该类继承tor ...

  5. 选择结构二switch选择结构

     在上一章节我们讲解了if选择结构  本章我们学习 switch选择结构 还要知道if选择结构和switch结构的区别 为什么学习了if选择结构还要学习switch选择结构  以及 两种选择结构的运用 ...

  6. xcode制作越狱包

    1.将运行目标选为iOS Device 2.Edit Scheme -> 选择 Run [App Name] -> Build Configuration下拉框中选择Release 3.生 ...

  7. python学习-练习题兔子生长问题巩固

    有一对兔子,一个月之后成熟,成熟之后每个月会生出一对兔子,理想状态下兔子不会死,请问n个月后有多少兔子? 分析:第一个月:1 第二个月:1 第三个月:2 第四个月:3 第五个月:5 第六个月:8 从前 ...

  8. golang 学习之路 string转换为其他类型 其他类型转换为string

    将其他值转换为string 一般常用fmt.Sprintf(格式,转换的值) // 使用fmt.Sprintf 转换所有的类型为string 使用 这是第一种 // 注意在sprintf使用中需要注意 ...

  9. redis实现数据库(一)

    转:https://www.cnblogs.com/beiluowuzheng/p/9738159.html 服务器中的数据库 Redis服务器将所有数据库都保存在服务器状态redis.h/redis ...

  10. c# 导出excel的两种常见方法

    1,不是用第三方插件(html直接输出) StringBuilder ssb = new StringBuilder(); StringBuilder sb = new StringBuilder() ...