10.1.1避免尾递归的堆栈溢出

10.1.1避免尾递归的堆栈溢出

 

    对于每一个函数调用,运行时分配一个栈帧(stack frame)。这些帧存储在一个由系统维护的栈中。调用完成,栈帧被删除;如果
函数调用另一个函数,那么,一个新的帧添加到这个栈的顶部。栈的大小是有限的,所以,太多的嵌套函数调用会耗光了给其他栈帧的空间,下一个函数不能调用。在 .NET 中发生这种情况时,引起 StackOverflowException。在 .NET 2.0 以及更高版本中,此异常不能被捕获,这将破坏整个过程。

    递归是基于递归嵌套调用的,所以,当你写复杂的递归计算时,经常遇到这样的错误,并不奇怪。(这未必是真实的。在 C# 中,最常见的原因可能是,写的属性意外引用了自身,而不是其指向的字段。我们忽略这样的错别字引起的意外,只考虑故意递归。)只是为了显示我们讨论的这种情况,让我们使用第三章中列表汇总的代码,但给它一个真正的大列表。

 

Listing 10.1 Summing list and stack overflow (F# Interactive)

 

> let test1 = [ 1 .. 10000 ]
   let test2 = [ 1 .. 100000 ];;
val test1 : int list
val test2 : int list

> let rec sumList(lst) =
     match lst with
     | [] -> 0
     | hd::tl -> hd + sumList(tl);;
val sumList : int list �C> int

> sumList(test1)
val it : int = 50005000

> sumList(test2)
Process is terminated due to StackOverflowException.

 

    就像每一个递归函数一样,sumList 包含终止递归的情况,和递归调用自己的情况。 这个函数在执行递归调用前,完成一定数量的工作,(对列表执行模式匹配,读取尾),然后,执行递归调用(对尾中的数字求和)。最后,用这个结果进行计算:把存储在头中的值和从递归返回的总和相加。最后一步的细节尤其重要,一会儿,你就看到了。

    正如我们可以预测的,有一个点,这个代码会停止工作。如果一个列表有几万个元素,它工作正常。对于一个有十万个元素的列表,递归太深,F# Interactive 报告异常。图10.1显示发生了什么:图形上面的箭头表示执行的第一部分,在递归调用之前和中间。图形下面的箭头表示递归返回的结果。

10-1

图10.1 在计算列表中数字和时的栈帧情况。第一种情况,栈帧在限制之内,因此,操作成功。第二种情况,计算达到极限,并抛出异常。

 

    我们使用了一个符号 [1..] 表示一个列表,包含从 1 开始的一个系列。第一种情况,F# Interactive 把从 1 到 10000 的列表作为参数值,开始执行 sumList。该图显示了每次调用时,栈帧是如何增加堆栈中的。在这个过程中的每一步取列表的尾,并用它作为参数值,递归
调用 sumList。第一种情况,堆栈是足够大的,所以,我们最终会到达参数是一个空列表的情况。在第二种情况下,在大约 64000 次调用以后,我们使用了所有的空间。运行时达到堆栈极限,并引起 StackOverflowException 异常。

    无论从左到右的箭头,还是回来的箭头,都做了一些工作。第一部分操作在递归调用之前执行,并把列表分解成头和尾两个组件。第二部分,在递归调用完成后执行,把头中的值加到总计中。

    现在我们知道了,它失败的原因,我们能做些什么呢?基本思想是,我们只需要保持栈帧,因为我们需要在递归调用完成后,做一些工作。在我们的例子中,我们仍然需要头元素的值,所以,我们可以将它加到递归调用的结果中。如果函数在递归调用完成后,不需要做任何事情,可以直接从最后的递归调用,直接跳回到调用者,不使用栈帧之间的任何值。让我们演示一下,使用下面的很小的函数:

 

let rec foo(arg) =
  if (arg = 1000) then true
  else foo(arg + 1)

 

    正如你看到的,foo 函数在 else 分支执行的最后一次操作就是递归调用。它并不需要对结果做任何处理,直接返回结果。这种递归调用被称为尾递归(tail recursion)。实际上,递归最深层的结果是调用 foo(1000),可直接返回给调用者。

10-2 

图10.2 递归函数 foo 在递归调用后,不执行任何操作。运行可以直接跳转到调用者(F# Interactive),从最后的递归调用,即 foo(1000)。

 

    图 10.2 中,你可以看到,计算过程中创建的栈帧(从左边到右的跳转)在返回的路上,再也没有使用过。这意味着,栈帧只在递归调用前需要,但是,当我们递归地从 foo(1) 调用 foo(2) 时,对于 foo(1),我们不需要栈帧。运行时可以简单地把它扔掉,以节省空间。图 10.3 显示了实际运行的尾递归函数 foo。

10-3

图 10.3 尾递归函数的运行。在递归调用期间,栈帧可以丢弃,所以,唯一需要的帧只在运行期间的某一点才需要。

 

    图 10.3 显示了 F# 中如何运行尾递归函数。当一个函数是尾递归时,我们在栈上只需要一个位置。这就使得这个递归版本迭代求解一样有效。

    你可能想知道,每一个递归函数是否可以用尾递归改写。答案是肯定的,但是全面的技巧,是有点复杂,我们将在 10.3 节讨论。经验法则是,如果一个函数在每个分支中执行单独的递归调用,我们应该能够使用一个相对简单的技巧。

 

.NET 生态中的尾递归

 

    在编译使用尾递归的函数时,F# 编译器使用两种技术。在当函数调用自己(如前面的例子中 foo)的情况下,把递归代码转换为等价的使用命令式循环代码的。尾调用也会发生在几个函数彼此递归调用时。在这种情况下,编译器无法轻松地重写代码,并使用一个特殊的 tailcall 指令,是直接由中间语言(IL)支持的。

    在调试配置中,第二个优化默认情况下是关闭的,因为,它使调试复杂化。尤其是,栈帧在一个尾调用期间被丢弃,所以,在堆栈跟踪窗口,你看不到它们。可以开启此功能,在项目属性中,选中“生成尾调用”。

    由于尾调用是直接由 IL 支持的,C# 编译器也可以识别尾递归调用,并使用这种优化。目前,它不这样做,因为,C# 开发人员通常以命令式风格设计代码,尾递归并不需要。

    这并不是说,对于用 C# 写的代码,运行时将不能使用尾调用优化。即使 IL 不包含明确的暗示,它希望使用尾调用,just-in-time 编译器(JIT)也会注意到,它可以安全地这样做,并继续进行。当发生这种情况的规则是复杂的,x86 和 x64 的 JIT 编译器之间是不同的。它们随时更改。在 .NET4.0 中的 JIT 在许多方面都有改进,因此,它更经常地使用尾递归。也从来没有忽略 tailcall 指令,这是 .NET 2.0 中的偶尔情况,特别是 x64 版本。

 

使用累加器参数

 

    让我们想一下,如何使 sumList 函数成为尾递归。它仅执行一次递归调用,在参数值是 cons cell (非空的列表)的分支上。我们的经验法则表明,它不应该很难,但目前,它做的事情,不止是返回递归调用的结果:把头中的值加到总和中。

    要把这个函数变成尾递归函数,我们可以使用一种技术,提供累加器参数(accumulator argument)。计算结果时,不再从右到左跳转(在前面的图中,我们回到原来的函数调用),可以计算出的结果,作为运行递归调用前的操作的一部分。我们需要为函数添加另一个参数,提供当前的结果。清单 10.2 显示了这种技术。

 

Listing 10.2 Tail-recursive version of the sumList function (F# Interactive)

 

> let rnd = new System.Random()
   let test1 = List.init 10000 (fun _ -> rnd.Next(-50, 51))
   let test2 = List.init 100000 (fun _ -> rnd.Next(�C 50, 51);;
val rnd : Random
val test1 : int list = [1; -14; -35; 34; -1; -39; ...]
val test2 : int list = [29; -44; -1; 25; -33; 36; ...]

> let sumList(lst) =
     let rec sumListUtil(lst, total) =
     match lst with
     | [] -> total
     | hd::tl �C>
       let ntotal = hd + total
       sumListUtil(tl, ntotal)
     sumListUtil(lst, 0);;
val sumList : int list �C> int

> sumList(test1);;
val it : int = �C2120

> sumList(test2);;
val it : int = 8736

    清单 10.2 首先生成两个包含随机数的列表。我们使用 List.init 函数,取列表所需的长度作为第一个参数值,调用所提供的函数来计算指定索引处的元素的值。在计算中,我们没有使用这个索引,所以,我们用 “_” 忽略它。究其原因,我们需要更好的测试输入,如果我们计算 1 到10 万所有数字的和,会得到不正确的结果,因为,这个结果会超过一个 32 位整数。我们产生随机数在 �C50 到 50 之间,因此,在原则上,和应非常接近于零。

    这个清单最有趣的部分是 sumList 函数。当我们使用累加器参数时,需要写另一个函数,有一个额外的参数。我们通常不希望调用者可以看到,所以,我们把它写为局部函数,累加器参数(在我们的例子中,total)存储当前的结果。当我们到达列表的末尾时,我们已经有结果了,所以,我们可以只返回它。否则,我们头中的值加到这个结果中,执行递归调用,把累加器设置成新的值。图 10.4 显示了这个新的计算模型如何工作的。无论是调用工具函数,还是递归调用,都在函数里面,结果立即返回,因此,它们可以使用尾部调用运行。

10-4 

图 10.4 运行尾递归函数 sumList。第一次调用工具函数,跟踪当前的结果,总计前面的元素,使用累加器参数(total)。

 

    sumList 例子并不难,但它演示了使用累加器的想法。我们为个函数添加另一个参数,用它来计算一个临时结果,在进行递归调用之前。当你想使一个函数成为尾递归,要看一下,你目前的信息,必须在递归调用后使用,并尝试找到一种方法,把它传递到递归调用。

    当我们讨论有关列表处理时,将看到一些棘手的例子,但我们将采取先走过一段弯路,通过另一个重要的优化技术:记忆化(memoization)。

你可能感兴趣的:(职场,尾递归,休闲,堆栈溢出)