10.1.1 avoid stack overflow of tail recursion
For each function call, the runtime allocates a stack frame. These frames are stored in a stack maintained by the system, the call is completed, the stack frame is deleted, and if the function calls other functions, a new frame is added to the top of the stack. The size of the stack is limited, so too many nested function calls will consume space to the other stack frames, and the next function can no longer be called. When this occurs in. NET, a StackOverflowException error is raised, and in. NET 2.0 and later, this exception cannot be captured, which destroys the entire process.
Recursion is based on recursive nesting calls, so it is not surprising that you often encounter such errors when writing complex recursive computations. (This is not necessarily true.) In C #, the most common reason might be that a property accidentally refers to itself rather than the field it should point to. We ignore this kind of typos caused by accidents, only consider intentional recursion. Just to show the situation we're talking about, we use the code summarized in chapter three, but use a large list.
Listing 10.1 summary list and Stack overflow (F # Interactive)
> Let test1 = [1.. 10000] | Create a test list
Let test2 = [1.. 100000];; |
Val Test1:int List
Val Test2:int List
> Let rec sumlist (LST) =
Match LST with
| [] 0 [1]
| HD::TL, hd+ sumlist (TL); [2]
Val sumlist:int list–> int
> sumlist (test1) [3]
Val It:int = 50005000
> sumlist (TEST2) [4]
Process is terminated due tostackoverflowexception.
Like every recursive function, Sumlist contains a branch that terminates recursion [1], and recursively calls its own branch [2]. The function completes a certain amount of work before performing a recursive call (performs a pattern match on the list, reads the tail), and then executes a recursive call (summing the numbers in the tail). Finally, the result is calculated by adding the value stored in the header and the sum returned from the recursion. The details of the final step are especially important and can be seen in a moment.
As we predicted, there is a point where the code stops working, if the list has tens of thousands of elements [3], can run normally, the list has 100,000 elements, and because recursion is too deep, F # Interactive reports an exception [4]. Figure 10.1 shows what happens: The arrows above the graph indicate the first part of the run, before and during the recursive call, and the arrows below the graph indicate the result of the recursive return.
Figure 10.1 The stack frame condition in the calculation of the number and time in the list. In the first case, the stack frame is within the limit, so the operation succeeds; in the second case, the calculation reaches the limit and throws an exception.
We use the symbol [1 ...] to represent a list that contains a series starting from 1. In the first case, F # Interactive takes a list from 1 to 10000 as the parameter value of Sumlist and begins execution. The figure shows how the stack frame is added to the stack each time it is called. In this process, each step takes the tail of the list and uses it as the parameter value, recursively calling Sumlist. In the first case, the stack is large enough that it will eventually get to the empty list of arguments, and in the second case, after about 64,000 calls, all the space is exhausted, the runtime reaches the limit of the stack, and causes the StackOverflowException exception.
Whether from left to right arrows, or back arrows, have done some work. The first part of the operation, decomposition of the list into the head and tail two parts, before the recursive call execution; the second part of the operation, the value in the head is added to the total, after the recursive call is completed.
Now that we know the reason for the failure, how do we do it? The basic idea is this: just keep the stack frame, because you need to do some work after the recursive call is complete. In the example, you still need the value of the header element, so you can add it to the result of the recursive call. If the function does not need to do anything after the recursive call is complete, you can jump directly back to the caller from the last recursive call and do not use any values between the stack frames. We use the following small function to demonstrate:
Let rec foo (arg) =
if (arg = +) then true
else Foo (arg + 1)
As you can see, the last action of the Foo function in the Else branch is a recursive call, which does not require any processing of the result and returns the result directly. This recursive call is called tail recursion (tail recursion). In fact, the deepest result of recursion is to call Foo (1000), which can be returned directly to the caller.
Figure 10.2 Recursive function Foo after a recursive call, do nothing. The run can jump directly to the caller (F # Interactive), from the last recursive call, that is, foo (1000).
In Figure 10.2, you can see that the stack frames created during the calculation (jumps from left to right) are no longer used on the returned jumps. This way, the stack frame is only required before recursive invocation, but when Foo (2) is called recursively from Foo (1), the stack frame of foo (1) is not needed, and the runtime simply throws it away, saving space. Figure 10.3 shows the actual running tail-recursive function foo.
Figure 10.3 Operation of the tail recursive function. The stack frame can be discarded during a recursive call, so it is sufficient to have only one frame at any point during the run.
Figure 10.3 shows how F # runs the tail-recursive function. The function is a tail recursion, and only one position is required on the stack, which makes the recursive version as effective as the iterative solution.
So, can each recursive function be rewritten as a tail recursion? The answer is yes, but the usual approach is a little complicated and we'll discuss it in section 10.3. The rule of thumb is this: if a function executes only one recursive call in each branch, it should be able to use relatively simple techniques.
Tail recursion in the. NET Ecosystem
The F # compiler uses two methods when compiling a function that uses tail recursion. When the function calls itself (as in the previous example Foo), the recursive code is translated into equivalent code using the imperative loop. A tail call can also occur when several functions are called recursively. In this case, the compiler cannot simply rewrite the code and use the special Tailcall directive, which is supported directly by the intermediate language (intermediate Language,il).
In a debug configuration, the second optimization is turned off by default, because it complicates debugging. In particular, stack frames are discarded during tail calls, so that they are not visible in the Stack trace window. You can turn on this feature, and in the project properties, select Generate Tail call.
Because the tail call is supported directly by the intermediate language, the C # compiler can also recognize the tail recursive invocation and take advantage of this optimization. Currently, this is not the case because C # developers often design code in an imperative style, which is not required for tail recursion.
This is not to say that the runtime cannot use tail-call optimizations for code written in C #. Even if the intermediate language does not contain explicit hints that you want to use a tail call, the appropriate compiler (JUST-IN-TIME,JIT) will notice that it can be done safely and continue. The rules that occur are complex, and the x86 and x64 are different between the time compilers, and they change at any moment. In. In NET4.0, there are improvements in the timing compiler in many ways, so the tail recursion is often used, and the Tailcall directive is never ignored, in. NET 2.0, which is an occasional case, especially the x64 version.
10.1.1 avoid stack overflow of tail recursion