We have a Python codebase containing quite a lot recursive functions, they may run into deep recursions and break the recursion limit with default Python interpreter settings. We have used sys.setrecursionlimit() to make them work well in the past. But now these functions need to be put into a shared module and run in other user's environment. We realized that calling sys.setrecursionlimit() within the shared module's code may not be a good idea, because that will change our user's global setting. Even if we always change the setting back before the functions return, users may be running a muti-threaded program and call our functions just in one of the threads, they may not want the other threads to be affected by a setting change. (If I'm wrong with this understanding, please correct me)
So, we are expecting for some approaches that can make our recursive functions work, while keeping the system recursion limit unchanged.
Before asking this question, I've searched the Internet and found a lot of information. They are really useful, I can get two conclusions from those resourses by now:
If possible, use
sys.setrecursionlimit()to change recursion limit, or use tricks like context manager to change this setting and then revert back. -- As said before, we don't think this is the best choice for a shared module.Change resursive functions' code into iterative. Most of the posts suggest changing code manually, by studying the functions first, and then refactor them case by case. This is possible but it needs substantial manual work, as our codebase is not small, and some recursive functions are quite complex. This post shows an example of how tough it can be to change a complex recursive function into iterative. But of course, we will rely on this last resort if we have to, but now we are finding some other generic way.
When saying "generic", I mean that only simple changes are needed, without deep understanding of the functions' logic and designing iterative code thoroughly by hand. "Generic" here does not imply small code change, it means more about less manual work, or, "automatic".
In some posts, I found that tail recursions can be eliminated by using a library named TCO. Thus all tail recursion functions can be changed just by adding a decorator and doing some generic text replacement. This is very close to the solution we are looking for. I also learned trampoline which applys to tail recursions as well. However, our codebase includes not only tail recursions.
Here is the question: Without calling sys.setrecursionlimit(), is there a way to make deep recursive functions (not only tail recusive functions) work, with minimum manual effort to change the legacy code? Is there any automatic refactoring tool that can do this?
If you're using Python3, and the recursive functions contain no
yieldexpression for now, there is a generic way by just changing every recursive function call withyieldadded, and using an executor function as client code's invoking entry point. After modification, the orginal call stack of interpreter will be replace by an explicit list (only for the functions modified), and recursion limit only depends on the maximum size of a list.Pre-Knowledge about yield expression
Let's take an example to see the function of
yieldfirst:When a function contains a yield expression, the return value of the function automatically becomes a generator. Note the
f = func()line, which looks like it's executing the code in thefuncfunction, but it doesn't, it just returns a generator that contains the parameters and states of thefuncfunction, but doesn't execute any line insidefunc.This is important, and this feature will be used to eliminate recursive calls.
After
next(f)is executed in the subsequenttryblock, the code infuncis actually executed.nextcausesfuncto run from the entry until the yield expression is encountered. In this case, the value followingyieldis assigned toxas the return value ofnext(f), and the execution offuncis suspended atyield 1.f.send(x + 1)passes the value ofx + 1to wherefuncfunction was last paused and letsfunccontinue execution from that place. Insidefunc,x + 1is assigned toyas the value ofyield 1, thenreturn yis executed.Instead of returning a value,
return yinfuncthrows aStopIterationexception. Thevalueattribute in the exception object contains the value followingreturn. Therefore, after the return statement is executed, execution will enter intoexcept StopIteration as e:code block. At last, the return value2is printed.Modification of recursive functions
Suppose that we have a recursive function like this:
through using
yield, we modify the previous recursive function as follows (with an executor function and calling example also added):The only difference between modified
recursive_addand the original one is that ayieldkeyword and a pair of parentheses are added to the recursive call (note that the parentheses here cannot be omitted). This general method applies to any other recursive function: change the recursive calls to yield expression, and leave all the other code as it is.run_to_finishis used to execute the "recursive function" (strictly speaking, it's no long "recursive" as seen by interpreter) after modification. This executor is universal. The code ofrun_to_finishcan be used to invoke any recursive function after modification. You can putrun_to_finish's definition into a library and import it when you want to use it.Let's analyze the execution process of the modified code:
run_to_finish(recursive_add(10000))passes a generator torun_to_finishexecutor. Recall that whenrecursive_addhas a yield expression inside, callingrecursive_addonly returns a generator and does not execute the code inside the function.run_to_finishstarts to run, which places the generator in thecall_stacklist.call_stackis a data structure that records the logical call chain of functions (with each of their states on the stack, including local variables, etc). It replaces the common call stack of Python interpreter.Next, as long as
call_stackis not empty, always fetch the last generator incall_stackand let it continue to execute. The generators incall_stackare stored in the logical invocation sequence of functions. The last generator represents the current "innermost" executing function.Because
recursive_addhas been transformed into a generator, each timeyield recursive_add(x - 1)is executed, the function body ofrecursive_addis not entered right away, instead, a new generator is returned. This generator is assigned toinner_calland saved tocall_stack.return_valuerecords the "return value" of the function exited just previously. (For the generator, it's actually thevaluecontained inStopIterationexception object.) If the return value exists, usesendmethod to pass it to the outer-level generator in the call chain.If the
StopIterationexception is captured, the current innermost function exits. The corresponding generator incall_stackis deleted and the return value is transferred to the "caller function".When the input parameter of
recursive_addis0, the internal code ofrecursive_addwill not execute the yield expression. In this case,recursive_addstill returns a generator. However, this generator throws an exception (the meaning of returning 0) when itsnextis invoked for the first time.When all the generators in
call_stackare finished,return_valueis the final result of the original recursive function.Another example of traversing binary tree
Here's another example. Notice that the original function of traversing bin-tree is not a tail recursive function, but it can still be modified and run using this method. The
run_to_finishexecutor is exactly the same with the previous one, so the definition is not shown here.