Lua—函数的尾递归与尾调用的区别,各自优缺点

1,541 阅读3分钟

前言

前面我们学习到了lua函数中的尾调用,那么这节我们再学习一下什么是尾递归,尾递归和尾调用的区别以及各自的优缺点。


尾调用

尾调用: 函数最后一条执行语句是调用一个函数,这种形式我们称之为尾调用。

尾调用常见误区:

function f1(x)
    --不是尾调用,最后一个动作其实是return nil
    g(x)  
end

function f2(x)
    --不是尾调用,最后一个动作是加法不是调用一个函数
    return g(x) + 1
end

function f3(x)
    --不是尾调用,最后一个动作是or,作用是把返回值限制为1个
    return x or g(x)
end

function f4(x)
    --不是尾调用,最后一个动作是(),作用是把返回值限制为1个
    return (g(x))
end

尾调用消除:

function f(x) x = x + 1; return g(x) end

当函数f调用完函数g之后, 不再需要进行其他的工作。这样,当被调用的函数执行结束后,程序就不再需要返回最初的调用者。因此,在尾调用之后,程序也就不需要在调用栈中保存有关调用函数的任何信息。当g返回时,程序的执行径会直接返回到调用f位置。在一些语言的实现中,例如Lua语言解释器,就利用了这个特点,使得在进行尾调用时不使用任何额外的栈空间,我们就将这种实现称为尾调用消除

例如:

function eat()
    return 5;
end

function action()
    local x = eat()
    return x;
end

function imitate()
    local x = action();
    return x;
end

function main()
    imitate();
end

上诉代码对应栈的情况如下:

18.png 但是假如我们对上诉实例代码改写成如下:

function eat()
    return 5;
end

function action()
    return eat();
end

function imitate()
    return action();
end

function main()
    imitate();
end

那么栈对应的状态会变成如下:

19.png 优点: 我们可以看到,由于尾调用是函数的最后一条执行语句,无需再保留外层函数的栈帧来存储它的局部变量以及调用前地址等信息,所以栈从始至终就只保留着一个栈帧。这就是尾调用优化,节省了很大一部分的内存空间。

缺点: 但上面只是理论上的理想情况,把代码改写成尾调用的形式只是一个前提条件,栈是否真的如我们所愿从始至终只保留着一个栈桢还得取决于语言是否支持。例如python就不支持,即使写成了尾递归的形式,栈该爆还是会爆。


尾递归

尾递归: 函数尾调用自身,这个形式称为尾递归

例如阶乘

function factorial(num)
    if num == 1 then
        return 1;
    end
    return num * factorial(num -1);
end

print(factorial(5));  -- 120
print(factorial(500000)); -- stack overflow stack traceback:...

我们可以看到,当我们传入的num是500000时,超出了内存最大范围,所以出现了栈溢出错误。那么如果我们尾递归来计算阶乘呢?

function factorial(num, total)
    if num == 1 then
        return 1;
    end
    return factorial(num -1, num * total);
end

print(factorial(5,1));  -- 120
print(factorial(500000,1)); -- 0 数值越界

优点: 我们知道,递归对于空间消耗很大,容易造成栈溢出。如果我们将其改成尾递归,那么能做到只保存1个栈帧,有效避免了栈溢出。

缺点: 语义不明显,阅读性较差


引用参考:

# 尾调用和尾递归1

# 尾调用和尾递归2