6

尾递归为啥能优化?

 1 year ago
source link: https://zhuanlan.zhihu.com/p/36587160
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

尾递归为啥能优化?

字节前端内推加V, bramblesX

正在招人,走过路过不要错过

这篇文章其实是我两年以前写的,文尾给自己开了一个坑,说是要手写一个自动将尾递归的函数优化成一个用循环迭代的函数,结果这坑一拖就是两年,我也差不多忘了有这回事了。今天刚好在其他文章里面看到了谈到尾递归优化我才又想起来,就顺手花了半天时间补坑。

下面这个项目就是将本文中的手动将尾递归函数优化成循环迭代函数的过程写成代码进行自动化处理的实例:

Tail Call​bramblex.github.io

废话不多说,进入我们的正题吧。


  1. 尾递归函数
  2. 函数栈的作用
  3. 尾递归为什么可以优化
  4. 手动优化尾递归
  5. 用代码自动优化尾递归

尾递归函数

要将尾递归,我们要先从递归讲起。首先选择一个最简单的例子——阶乘。以下是一个用普通递归形式写的用来计算 n 的阶乘的函数:

function fact(n) {
    if (n <= 0) {
        return 1;
    } else {
        return n * fact(n - 1);
    }
}

当我们计算 fact(6) 的时候,会产生如下展开:

6 * fact(5)
6 * (5 * fact(4))
6 * (5 * (4 * fact(3))))
// two thousand years later...
6 * (5 * (4 * (3 * (2 * (1 * 1)))))) // <= 最终的展开

注意了,到这里为止,程序做的仅仅还只是展开而已,并没有运算真正运运算,接下来才是运算:

6 * (5 * (4 * (3 * (2 * 1)))))
6 * (5 * (4 * (3 * 2))))
6 * (5 * (4 * 6)))
// two thousand years later...
720 // <= 最终的结果

我们普通递归的问题在于展开的时候会产生非常大的中间缓存,而每一层的中间缓存都会占用我们宝贵的栈上空间,所有导致了当这个 n 很大的时候,栈上空间不足则会产生“爆栈”的情况。

那有没有一种方法能够避免这样的情况呢?那当然是有的,那就是我们这篇文章的主角——尾递归了。

尾递归:
若函数在尾位置调用自身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。对尾递归的优化也是关注尾调用的主要原因。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

特点:
尾递归在普通尾调用的基础上,多出了2个特征:
1. 在尾部调用的是函数自身 (Self-called);
2. 可通过优化,使得计算仅占用常量栈空间 (Stack Space)。

——维基百科尾调用词条

我们以上面的阶乘函数为例,写成尾递归的形式:

function fact(n, r) {
    if (n <= 0) {
        return 1 * r;
    } else {
        return fact(n - 1, r * n);
    }
}

我们像上面一个普通递归函数一样来展开和运算 fact(6):

fact(6, 1) // 1 是 fact(0) 的值,我们需要手动写一下
fact(5, 6)
fact(4, 30)
fact(3, 120)
fact(2, 360)
fact(1, 720)
720 // <= 最终的结果

跟上面的普通递归函数比起来,貌似尾递归函数因为在展开的过程中计算并且缓存了结果,使得并不会像普通递归函数那样展开出非常庞大的中间结果,所以不会爆栈是吗?

当然不是!我看到过很多博客和或者教程都犯有这样的错误。尾递归函数依然还是递归函数,如果不优化依然跟普通递归函数一样会爆栈,该展开多少层依旧是展开多少层。不会爆栈是因为语言的编译器或者解释器所做了“尾递归优化”,才让它不会爆栈的。


函数栈的作用

栈这种数据结构怎么定义的以及怎么用大家都非常了解了,也就是后入先出。当一个函数被调用的时候,我们会把函数扔进一个叫做“函数栈“的地方,但是我们为什么要用栈而不用其他的呢?

栈的意义其实非常简单,五个字——保持入口环境。我们结合一段简单代码来展示一下:

function main() {
    //...
    foo1();
    //...
    foo2();
    //...
    return;
}

main();

上面是一个简单的示例代码,我们现在简单在大脑里面模拟一下这个 main 函数调用的整个过程,$ 字符用于表示占地:

  1. 首先我们建立一个函数栈。 $
  2. main 函数调用,将 main 函数压进函数栈里面。$ main
  3. 做完了一些操作以后,调用 foo1 函数,foo1 函数入栈。$ main foo1
  4. foo1 函数返回并出栈。$ main
  5. 做完一些操作以后,调用 foo2 函数,foo2 函数入栈。$ main foo2
  6. foo2 函数返回并出栈。$ main
  7. 做完余下的操作以后,main函数返回并出栈。$

上面这个过程说明了函数栈的作用是什么?就是第 4 和第 6 步的作用,让 foo1 和 foo2 函数执行完了以后能够在回到 main 函数调用 foo1 和 foo2 原来的地方。这就是栈,这种”后入先出“的数据结构的意义所在。


尾递归为什么可以优化

上面说了,函数栈的目的是啥?是保持入口环境。那么在什么情况下可以把这个入口环境给优化掉?答案不言而喻,入口环境没意义的情况下为啥要保持入口环境?尾递归,就恰好是这种情况。

因为尾递归的情况下,我们保持这个函数的入口环境没意义,所以我们就可以把这个函数的调用栈给优化掉。比如还是那个阶乘函数把它写成尾递归的形式。

function fact(n, r) {
    if (n <= 0) {
        return 1 * r;
    } else {
        return fact(n - 1, r * n); // <= 这里的入口环境没有必要保留。
    }
}

这时,当里面这个 fact(n - 1, r * n) 返回的时候,外面的 fact(n, r) 就马上要返回了,所以保存栈是没有任何意义的,既然没意义我们毫无疑问就要优化掉。


手动优化尾递归

好了,现在我们有一个尾递归函数了。假设我们的语言没有原生支持尾递归优化,那么要怎么在语言层面上手动实现一个尾递归优化呢?这其实就是一个把递归变成循环的过程嘛。那么,只需要执行以下步骤,就能手动将尾递归优化成迭代循环:

  1. 首先,把上面尾递归代码抄过来。
  2. 将参数提取出来,成为迭代变量。原来的参数则用来初始化迭代变量。
  3. 创建一个迭代函数,迭代函数只用来只用来更新迭代变量。
  4. 将原来函数的里面所代码(不包括我们上面的迭代函数和迭代变量初始化)包在一个 while (true) 迭代循环里面。Tip:加一个 label 用于标识循环。
  5. 递归终止的 return 不变,尾递归的 return 替换成迭代函数,并且 continue 掉上面的迭代循环。Tip:上面的 label 在这里用。

尾递归代码:

function fact(n, r) { // <= 这里把 n, r 作为迭代变量提出来
    if (n <= 0) {
        return 1 * r; // <= 递归终止
    } else {
        return fact(n - 1, r * n); // <= 用迭代函数替代 fact。
    }
}

转换后得到的代码:

function fact(_n, _r) { // <= _n, _r 用作初始化变量
    var n = _n;
    var r = _r; // <= 将原来的 n, r 变量提出来编程迭代变量
    function _fact(_n, _r) { // <= 迭代函数非常简单,就是更新迭代变量而已
        n = _n;
        r = _r;
    }
    _fact_loop: while (true) { // <= 生成一个迭代循环
        if (n <= 0) {
            return r;
        } else {
            _fact(n - 1, r * n); continue _fact_loop; // <= 执行迭代函数,并且进入下一次迭代
        }
    }
}

到这里,我们就已经将一个尾递归函数转换成循环迭代函数了。


用代码自动优化尾递归

上面,我们已经可以通过简单的规则手动优化尾递归了,那么作为一个程序员,把这种简单的重复操作交给计算机那当然是理所当然的了。我这里可以给出一个简易的通过正则的和字符串拼接的实现。

function transform(name, args, body) {
    // 将参数提取出来,成为迭代变量
    var args = args.split(',').map(function (arg) { return arg.trim(); });
    
    //原来的参数则用来初始化迭代变量
    var init_var = args.map(function (arg) { return 'var ' + arg + ' = ' + underscore(arg) + ';\n' }).join('');
    
    // 创建一个迭代函数,迭代函数只用来只用来更新迭代变量
    var iterate_name = underscore(name);
    var loop_name = iterate_name + '_loop';
    var init_iterate
        = 'function ' + iterate_name + '(' + args.map(underscore).join(', ') + ') {\n'
        + alignRight(args.map(function (arg) { return arg + ' = ' + underscore(arg) + ';'; }).join('\n'))
        + '\n}\n';

    var new_body
        = init_var
        + init_iterate
        
        // 将原来函数的里面所代码(不包括我们上面的迭代函数和迭代变量初始化)包在一个 while (true) 迭代循环里面
        + loop_name + ': while (true) {\n'

        // 递归终止的 return 不变,尾递归的 return 替换成迭代函数,并且 continue 掉上面的迭代循环
        + alignRight(
          body.replace(
            new RegExp('return\\s+' + name + '(.*?)(;|\n)', 'g')
            , iterate_name + '$1' + '$2' + ' continue ' + loop_name + ';'
          )
        )
        + '\n}';

    // 将函数包装起来
    var code
        = 'function ' + name + '(' + args.map(underscore).join(', ') + ') {\n'
        + alignRight(new_body)
        + '\n}';
      return code;
}
Tail Call​bramblex.github.io

这个项目只是一个简易 demo,在分析语法的时候只用了简单的正则表达式,所以智能覆盖小部分的情况。如果希望进阶,可以使用 acron 解析,转换AST,并且用 escodegen 生成代码来实现更好的版本,我这里可以提供另外一个项目进行参考 acron 使用和操作 AST。


最后,夹点私活,我个人不是很喜欢尾递归这样东西。尾递归本身就可以完全等效于一个无栈的循环,写成尾递归除了强行炫技以外没有任何实际意义,除了是在一些没有循环的纯函数式语言。所以这是一篇基本上毫无卵用的文章,有的只是对知识的渴望和好奇。

Hello world!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK