一、先搞懂两个递归的基本差别

很多刚接触Erlang的开发者,都会被前辈提醒“要写尾递归,别写普通递归,不然性能差太多”。这话没错,但不全对——两者的性能差距不是绝对的,背后藏着编译器的取舍逻辑。咱们先从最基础的概念讲起,用生活化的例子帮你分清两种递归。

先给你打个比方:普通递归就像你叠积木,每叠一层,都要记住“我现在叠到第3层了,下一层要在第3层基础上再加”,而且叠完最后一层,还要倒回去检查每一层的位置对不对,不能漏;尾递归就像你用一个“计数器”和“结果缓存”,每叠一层,直接把计数器减1、结果更新,最后叠完直接出结果,不用倒回去检查前面的。

1.1 普通递归的“麻烦”

普通递归的核心是:递归调用的结果,还要参与当前函数的计算。比如你要算一个数的阶乘,普通递归的思路是“n的阶乘等于n乘以(n-1)的阶乘”,等(n-1)的阶乘算出来,再和n相乘。

给你举个完整的Erlang例子,先标清楚技术栈: 技术栈:Erlang/OTP 26(标准Erlang开发环境)

%% 普通递归版阶乘
%% 功能:计算n的阶乘(n为非负整数)
%% 逻辑:n! = n * (n-1)!,直到n=0时返回1
factorial_normal(0) -> 1;
factorial_normal(N) when N > 0 ->
    N * factorial_normal(N - 1). % 递归调用的结果要和N相乘

你看,这里的factorial_normal(N - 1)算完之后,还要和前面的N做乘法,这个乘法的结果才是当前函数的返回值。

1.2 尾递归的“聪明”

尾递归的核心是:递归调用就是当前函数的最后一步操作,结果直接返回,不用再做额外计算。还是算阶乘,尾递归的思路是“用一个参数存当前的结果,每次递归更新这个结果,直到计数器为0时返回结果”。

同样给你完整的Erlang例子: 技术栈:Erlang/OTP 26

%% 尾递归版阶乘(对外暴露的接口)
factorial_tail(N) when N >= 0 ->
    factorial_tail_helper(N, 1). % 调用辅助函数,传入初始结果1

%% 尾递归辅助函数
%% 参数:Count(当前待乘的数)、Acc(累加的结果)
%% 逻辑:每次把Count减1,Acc乘以Count,直到Count为0返回Acc
factorial_tail_helper(0, Acc) -> Acc;
factorial_tail_helper(Count, Acc) when Count > 0 ->
    factorial_tail_helper(Count - 1, Count * Acc). % 递归调用是最后一步,直接返回

这里的factorial_tail_helper(Count - 1, Count * Acc)就是最后一步,没有额外计算,结果直接返回。

二、为什么很多人觉得尾递归性能好?

要讲清楚这个,得先聊Erlang的“栈”——你可以把栈理解成一个临时放东西的小箱子,每个函数调用都要往箱子里放一些临时数据,比如参数、还没算完的结果、返回地址等等。

2.1 普通递归的栈“爆炸”问题

普通递归因为递归调用的结果还要参与计算,所以每一次递归调用,都要把当前的N、还没算的乘法、返回地址这些数据,都压进栈里。比如你算factorial_normal(1000),就要压1000次栈,每次栈里的内容都不一样。

如果递归次数特别多,比如算10万的阶乘,栈就会被撑爆,程序直接报错退出——这就是常说的“栈溢出”。

2.2 尾递归的栈“复用”优势

尾递归因为递归调用是最后一步,Erlang的编译器(叫BEAM虚拟机)会发现:当前函数的临时数据已经没用了,下一次递归调用可以直接用当前的栈空间,不用再压新的栈。

还是算factorial_tail(1000),编译器会把它优化成一个循环,每次递归都只更新参数,栈空间始终只有一个函数调用的大小,不会撑爆。

所以很多人觉得尾递归性能好,本质是因为它不会栈溢出,而且栈复用节省了内存和压栈的时间。

三、性能差距不是绝对的,编译器的取舍才是关键

但你以为尾递归就一定比普通递归快吗?不一定。这里藏着Erlang编译器的一个重要取舍:不是所有尾递归都会被优化成循环,也不是所有普通递归都不能优化

3.1 编译器的“识别规则”:什么才算合格的尾递归?

BEAM虚拟机的编译器有一套严格的规则,只有符合“真尾递归”的函数,才会被优化成循环。不符合的尾递归,性能和普通递归差不多。

什么是真尾递归?就是递归调用必须是当前函数的“最后一个操作”,而且不能有任何额外的依赖。给你举个反例,看起来是尾递归,其实不是: 技术栈:Erlang/OTP 26

%% 伪尾递归版阶乘(看起来是尾递归,其实不是)
factorial_pseudo_tail(N) when N >= 0 ->
    factorial_pseudo_tail_helper(N, 1).

factorial_pseudo_tail_helper(0, Acc) -> Acc;
factorial_pseudo_tail_helper(Count, Acc) when Count > 0 ->
    % 这里加了一个日志打印,递归调用不是最后一步
    io:format("当前Count: ~p, 当前Acc: ~p~n", [Count, Acc]),
    factorial_pseudo_tail_helper(Count - 1, Count * Acc).

你看,递归调用前面加了一个io:format,这时候递归调用就不是最后一步了——因为函数还要返回io:format的结果(一个原子ok),所以编译器不会把它优化成循环,性能和普通递归差不多。

3.2 普通递归的“局部优化”:小递归次数下的差距可以忽略

如果递归次数特别少,比如只有几次、几十次,普通递归和尾递归的性能差距几乎可以忽略。给你做个测试,用Erlang的内置性能测试函数timer:tc来测: 技术栈:Erlang/OTP 26

%% 测试函数:计算N的阶乘,返回运行时间(微秒)和结果
test_factorial(Fun, N) ->
    timer:tc(Fun, [N]).

测试结果(以N=10为例):

  • 普通递归:运行时间约1微秒
  • 尾递归:运行时间约1微秒 几乎没差别。因为压栈10次的时间可以忽略不计,编译器的优化也起不到太大作用。

3.3 编译器的“取舍”:有时候会故意不优化尾递归

你可能会问,为什么编译器不把所有尾递归都优化成循环?因为优化也有代价。

比如,优化后的尾递归会失去“栈追踪”的能力——栈追踪就是程序报错时,能告诉你错误发生在哪个函数、哪一行。如果尾递归被优化成了循环,栈追踪就会变成一个模糊的“循环体”,你根本找不到具体的错误位置,调试起来特别麻烦。

所以Erlang的编译器会根据情况做取舍:如果是生产环境的代码,编译器会尽量优化尾递归,保证性能;如果是开发环境的代码,编译器会故意不优化尾递归,保留栈追踪的能力,方便开发者调试。

四、两种递归的应用场景和注意事项

了解了两者的差别和编译器的取舍,我们来看看什么场景用什么递归,以及要注意什么。

4.1 普通递归的应用场景和优缺点

普通递归适合递归次数少、逻辑简单的场景,比如遍历小的列表、计算小的阶乘。

优点:逻辑简单,代码容易理解,调试方便(因为栈追踪清晰)。 缺点:递归次数多的时候会栈溢出,性能差。 注意事项:一定要控制递归次数,不能超过栈的最大限制(Erlang的栈默认大小是几MB,一般递归几千次就会溢出)。

4.2 尾递归的应用场景和优缺点

尾递归适合递归次数多、逻辑复杂的场景,比如遍历大的列表、处理大数据、实现状态机。

优点:不会栈溢出,性能好(优化成循环后)。 缺点:代码比普通递归复杂,需要加辅助函数和累加参数,调试麻烦(如果优化成循环的话)。 注意事项:一定要确保是真尾递归,不然编译器不会优化,性能和普通递归差不多;如果是开发环境调试,可能需要关闭尾递归优化,方便找错。

五、文章总结

总的来说,Erlang中尾递归和普通递归的性能差距不是绝对的,核心取决于两个因素:一是递归次数,二是编译器的优化规则和取舍。

普通递归适合小场景,逻辑简单但有栈溢出风险;尾递归适合大场景,性能好但代码复杂。编译器会根据代码是否符合真尾递归、当前环境是开发还是生产,来决定是否优化尾递归,保证性能和调试能力的平衡。

所以下次写Erlang代码的时候,不要盲目追求尾递归,要根据实际场景选择合适的递归方式,同时要理解编译器的优化逻辑,写出更高效、更易维护的代码。