在 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
这里 g(x) 返回之后,f 不需要再处理结果,整个调用链可以直接把结果交回给 f 的调用者。这才是尾调用成立的前提。
Lua 里的尾调用消除,到底优化了什么
尾调用之所以重要,不只是写法简洁,更关键的是它可能触发尾调用消除。当一个函数在尾部直接调用另一个函数时,理论上当前函数的栈帧就没有继续保留的必要了,因为被调函数结束后,不必再回到当前函数做后续工作。

原文中的第一组代码,调用关系是 main -> imitate -> action -> eat,但每一层都先接住返回值,再把结果返回出去:
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
这种情况下,每一层函数都还保留着自己的局部变量和返回位置,因此调用栈会一层层压上去。
如果把它改写成完全尾调用的形式:
function eat()
return 5;
end
function action()
return eat();
end
function imitate()
return action();
end
function main()
imitate();
end
那么 action 调用完 eat 后不再做额外处理,imitate 对 action 也是同样逻辑。对于支持这一机制的实现来说,外层栈帧就可以被复用或直接省掉,调用链不会机械地一层层累积下去。
这也是尾调用最核心的优点:节省栈空间,减少不必要的栈帧保留。在调用层级深、函数转发多的场景里,这个特性能直接影响内存占用和程序稳定性。
但这里还有一个经常被忽略的前提:代码写成尾调用,只是满足了优化条件,不代表所有语言运行时都会真的优化。原文也提到,像 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:...
这段代码的问题很典型:factorial(num - 1) 返回后,当前层还要继续做一次乘法 num *,所以它不是尾调用,更不是尾递归。于是每深入一层递归,当前调用现场都必须保留下来,等子问题返回后继续计算,栈空间就会持续增长。
当参数变成 500000 时,递归深度过大,就可能出现 stack overflow stack traceback:...。
改成尾递归后,写法会变成把“中间结果”提前保存到参数里:
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 数值越界
这一版里,乘法不再留到递归返回之后处理,而是提前累积到 total 参数中。这样函数最后只剩下对 factorial 自身的一次调用,从形式上看就满足了尾递归条件。
原文想表达的重点也正在这里:尾递归的价值,是把原本依赖“回溯计算”的递归,改写成“沿途累计结果”的递归。只要语言实现支持尾调用消除,理论上就能把大量递归层压缩到极少的栈占用,避免普通递归那种明显的栈膨胀。
不过,从示例结果也能看出另一层限制:即便栈问题得到缓解,也不代表所有大数计算都安全。print(factorial(500000,1)); -- 0 数值越界 说明这里又碰到了数值范围本身的边界。也就是说,尾递归主要解决的是调用栈压力,并不自动解决数值溢出这类计算层面的问题。
尾调用和尾递归怎么区分,各自优缺点是什么
把前面的内容收束起来,可以得到一组更适合开发中使用的判断:
区别
- 尾调用关注的是调用位置:最后一步是不是函数调用。
- 尾递归关注的是递归形式:最后一步是不是调用自己。
- 尾递归是尾调用的子集,范围更小。
尾调用的优点与限制
优点: 如果语言实现支持尾调用消除,那么外层函数的栈帧不必长期保留,能明显节省内存空间,特别适合函数转发链较长的场景。
限制: 这类优化是否真实发生,不只取决于代码写法,还取决于具体语言和运行时实现。写法满足条件,不等于一定优化成功。
尾递归的优点与限制
优点: 它把递归改造成可被尾调用消除的形式,在支持该优化的语言中,能有效降低递归造成的栈压力,避免深层递归带来的栈溢出风险。
缺点: 和普通递归相比,尾递归往往需要额外引入累加参数,例如示例中的 total。这样虽然更利于优化,但语义会变得没那么直观,阅读门槛也会更高。
所以在实际使用中,不必把尾递归理解成“永远更高级”的写法。更准确的判断应该是:
- 如果你关心的是概念边界,就先分清“最后调用别人”和“最后调用自己”。
- 如果你关心的是性能和稳定性,就继续确认当前语言是否支持尾调用消除。
- 如果语言本身不支持,那么把代码机械改成尾递归,收益可能非常有限。
对 Lua 来说,尾调用和尾递归都值得掌握,但真正有价值的不是记住术语,而是知道一段代码为何能省栈、为何不能省栈,以及问题到底出在写法本身还是运行时实现上。







