关于什么是尾部递归以及如何避免调用栈溢出的例子。
到目前为止,作为一个iOS开发者,除了在大学里学到的知识,我很少接触到调用堆栈。我所做的大多数应用都不是非常密集的资源。我曾在复杂的应用程序上工作,但这种复杂性主要是为了提供良好的用户体验。
在过去的两年里,作为我博士研究的一部分,我一直在研究一个分析源代码历史的Swift应用程序。对于非常大的应用程序和长的源代码历史,我有时会遇到这样的问题:我的工具因 分段故障而崩溃。当时我没有弄清楚问题出在哪里,但知道当要分析的数据变得太大,就会发生这种情况。我以后会再来讨论这个问题。
在为C和汇编编程课程做项目时,我偶然发现了尾部递归的优化,并决定在swift中尝试一下。
什么是尾部递归?
递归函数 是一个会自我调用的函数,例如下面这个函数会打印出从n到0的所有数字。
尾部递归函数是一个递归函数,它对自身的调用是在函数的末尾,如下面的例子。
这些函数以相反的顺序打印出数字,但除此之外具有相同的功能:打印出n和0之间的所有数字。
这有什么关系呢?
让我们来编译这些程序并运行它们。这些程序可以通过调用来编译。
xcrun swiftc -O tail.swift -oresult
然后可以通过调用以下命令来运行这些程序
./result
让我们试试一个小的n(n=3)。
现在让我们尝试一个大的n(n=10000)。
这里我们再来看看分段故障。 这种情况只发生在无尾巴递归的程序中。为什么会发生这种情况?什么是分段故障,为什么只有一个程序会崩溃?为了回答这些问题,我将从调用栈开始。
什么是调用堆栈?
当程序中的一个方法被调用时,它会被添加到调用栈中。当另一个方法在这个方法内被调用时,它也会被添加到调用栈中。当一个方法返回时,它将从调用栈中删除。
因此,来自递归函数的重复方法调用都被添加到调用栈中,直到停止条件得到满足,最后一个方法调用返回。
什么是分段故障?
调用堆栈被存储在堆栈内存段中。内存布局如左图所示。
当太多的方法调用被添加到调用堆栈中时,分配给调用堆栈的有限内存就会变满,调用堆栈之外的内存位置就会被覆盖。首先是堆,然后是未初始化的内存,初始化的内存,最后是文本段。
试图写入文本段的行为会导致分段故障,程序执行被停止。这是一种安全机制。
尾部递归优化。
Swift使用尾部递归优化,这意味着如果递归函数中的方法调用位于函数的末尾,编译器能够使用跳转语句来代替方法调用。这意味着在递归中对自身的调用不会增加到调用栈中。
当我们用Hopper反编译前面的两个程序时,我们看到每个程序的汇编代码都很不同。
带有递归函数的程序所产生的汇编代码,如下图所示。我们可以看到,在_$s4main4test2nySi_tF的方法中,有一个对iself的方法调用。
另一方面,带有尾部递归的程序得到了优化。我们可以看到,这里有一条跳转(jg)指令,而不是方法调用。
我一直很喜欢写递归函数,但我从来没有考虑过使用它们可能会有缺点。当然,上面的例子应该用循环来写,但仍然是一个很好的例子,说明不小心溢出调用栈是多么容易。
我现在知道为什么我的源代码分析工具在处理非常大的应用程序时崩溃了,我将努力优化代码,以防止它在未来发生。
经验之谈
- 如果可能的话,最好使用循环而不是递归。
- 在编写递归函数时,如果可能的话,把对自身的调用写在方法的最后。