如果一个函数执行的_最后_一件事就是调用它自身,那么它就是尾递归的。
每次调用函数时,都会把一个_栈帧_压到函数调用栈的顶部,里面装着这个函数的局部变量和实参。函数返回时,这个栈帧就会从栈中移除。
尾递归函数可以进行尾调用优化(也叫尾调用消除)。这是一项优化:当能保证前一个函数不再需要上一个栈帧时,就让下一次函数调用复用它。这样一来,函数调用栈溢出的问题就能得到缓解。函数调用栈溢出是指栈上的栈帧太多,已经没有任何内存再放下一个栈帧了。
在某些情况下,Elm 编译器在编译到 JavaScript 时能够自动进行尾调用优化。
当递归函数某个分支中的_最后_一个操作,是通过简单的函数应用调用函数自身时,就可以进行这项优化。我们来看几个例子:
factorial : Int -> Int
factorial n =
if n <= 1 then
n
else
n * factorial (n-1)
上面的实现不是尾递归的,因为 else 分支中的最后一个操作是乘法 n *。
factorial : Int -> Int
factorial n =
factorialHelper n n
factorialHelper : Int -> Int -> Int
factorialHelper n resultSoFar =
if n <= 1 then
resultSoFar
else
factorialHelper (n-1) (n * resultSoFar)
上面的实现是尾递归的,并且会被优化,因为 else 分支中的最后一个操作是 factorialHelper 调用它自身。而类型签名为 Int -> Int 的函数就无法做到这一点,实践中,尾调用优化往往是通过定义辅助函数来实现的。
Piper 酷爱烤派。
没人知道她是因为自己的名字才开始烤派,还是为了配合爱好而改了自己的名字。 乍一看,后者似乎不太可能,但你看,Piper 对派简直着迷。 她总是在厨房里捣鼓,调整食谱,提升手艺,让她的朋友们开心极了。
她最近的兴趣? 借助她最喜欢的数字,烤出尽可能圆、达到数学上完美的派。这个数字你猜到了:π。
Piper 发现了一个能迭代计算 π 的漂亮公式,牛顿/欧拉收敛变换:
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
通过计算 π,帮助 Piper 烤出数学上完美的派。
我们先热热身。
阶乘运算符通常写作!,定义如下:
0! = 1
n! = 1 * 2 * 3 * ... * n
定义factorial函数,用尾递归方式计算阶乘。
factorial 4
-- 24
双阶乘运算符通常写作!!,定义如下:
0!! = 1
n!! = 1 * 3 * 5 * ... * n (for odd n)
n!! = 2 * 4 * 6 * ... * n (for even n)
定义doubleFactorial函数,用尾递归方式计算双阶乘。
factorial 5
-- 15
factorial 6
-- 48
定义pipersPi函数,用尾递归方式,根据牛顿/欧拉收敛变换公式取指定数量的项来近似 π。
π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!
我们一起计算第一项。
上限为0(而不是无穷大)时,我们得到:
π / 2 ≈ Sum for k from 0 to 0 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ ( 0! ) / ( 2 * 0 + 1 )!!
π / 2 ≈ 0! / 0!!
π / 2 ≈ 1 / 1
π / 2 ≈ 1
π ≈ 2
每增加一项,近似值都会更精确。
pipersPi 0
-- 2.0
pipersPi 1
-- 2.6666666