尾调用
本文将以lua语言来描述。
尾调用是函数式编程的一个概念,它是指某个函数的最后一步是调用另一个函数,例如:
function f(x) return g(x) -- 尾调用 end
尾调用不一定出现在函数尾部,只要是最后一步操作即可,例如:
function f(x) if (x > 0) then return m(x) end return n(x); end
上面代码中,函数m和n都属于尾调用,因为它们都是函数f 的最后一步操作。
但以下情况均不属于尾调用:
function f(x) return g(x)+1 -- must do the addition end function f(x) return x or g(x) -- must adjust to 1 result end function f(x) ret = g(x) return ret end
我们知道,函数调用会在内存形成一个调用栈(call stack),调用函数(caller)与被调函数(callee)的关系如下图:
可见,被调函数有一个压栈和出栈的过程。
而尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。
如下里面的例子:
function g(x, y) return x + y end function f() m = 1; n = 2; return g(m, n); end f()
上面代码中,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、g的调用位置等信息。
但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除 f() 的调用记录,只保留 g() 的调用记录,这就叫做"尾调用优化"(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,不使用额外的调用栈空间,这将大大节省内存。这就是"尾调用优化"的意义。
利用这个特性在处理尾调用时不使用额外的栈,那么尾调用递归的层次是可以无限制的。
例如:
function factorial(n) if n == 1 then return 1 end return n * factorial(n - 1); end print(factorial(5)) -- 120
上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,空间复杂度 O(n) 。
如果改写成尾递归,只保留一个调用记录,复杂度 O(1)
function factorial(n, total) if n == 1 then return total end return factorial(n - 1, n * total); end print(factorial(5, 1)) -- 120
由此可见,"尾调用优化"对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署"尾调用优化"。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total ,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5的阶乘,需要传入两个参数5和1?
解决这个问题的方法一是在尾递归函数之外,再提供一个正常形式的函数。
function tailfactorial(n, total) if n == 1 then return total end return tailfactorial(n - 1, n * total); end function factorial(n) return tailfactorial(n, 1) end print(factorial(5)) -- 120
上面代码通过一个正常形式的阶乘函数 factorial ,调用尾递归函数 tailFactorial ,看起来就正常多了。