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 yield
expression for now, there is a generic way by just changing every recursive function call with yield
added, 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.
Let's take an example to see the function of yield
first:
def func():
y = yield 1
return y
f = func()
try:
x = next(f)
print(x)
f.send(x + 1)
except StopIteration as e:
print(e.value)
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 the func
function, but it doesn't, it just returns a generator that contains the parameters and states of the func
function, but doesn't execute any line inside func
.
This is important, and this feature will be used to eliminate recursive calls.
After next(f)
is executed in the subsequent try
block, the code in func
is actually executed. next
causes func
to run from the entry until the yield expression is encountered. In this case, the value following yield
is assigned to x
as the return value of next(f)
, and the execution of func
is suspended at yield 1
.
f.send(x + 1)
passes the value of x + 1
to where func
function was last paused and lets func
continue execution from that place. Inside func
, x + 1
is assigned to y
as the value of yield 1
, then return y
is executed.
Instead of returning a value, return y
in func
throws a StopIteration
exception. The value
attribute in the exception object contains the value following return
. Therefore, after the return statement is executed, execution will enter into except StopIteration as e:
code block. At last, the return value 2
is printed.
Suppose that we have a recursive function like this:
def recursive_add(x):
return 0 if x == 0 else x + recursive_add(x - 1)
through using yield
, we modify the previous recursive function as follows (with an executor function and calling example also added):
def recursive_add(x):
return 0 if x == 0 else x + (yield recursive_add(x - 1))
def run_to_finish(gen):
call_stack = [gen]
return_value = None # Return value of the function at the innermost level
while call_stack:
try:
if return_value is not None:
inner_call = call_stack[-1].send(return_value) # Transfer the return value of the inner function to the outer
else:
inner_call = next(call_stack[-1]) # The function is just being executed or does not require a return value
call_stack.append(inner_call)
except StopIteration as e: # Inner function exit
del call_stack[-1]
return_value = e.value # Obtains the return value
return return_value
print(run_to_finish(recursive_add(10000)))
The only difference between modified recursive_add
and the original one is that a yield
keyword 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_finish
is 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 of run_to_finish
can be used to invoke any recursive function after modification. You can put run_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 to run_to_finish
executor. Recall that when recursive_add
has a yield expression inside, calling recursive_add
only returns a generator and does not execute the code inside the function.
run_to_finish
starts to run, which places the generator in the call_stack
list. call_stack
is 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_stack
is not empty, always fetch the last generator in call_stack
and let it continue to execute. The generators in call_stack
are stored in the logical invocation sequence of functions. The last generator represents the current "innermost" executing function.
Because recursive_add
has been transformed into a generator, each time yield recursive_add(x - 1)
is executed, the function body of recursive_add
is not entered right away, instead, a new generator is returned. This generator is assigned to inner_call
and saved to call_stack
.
return_value
records the "return value" of the function exited just previously. (For the generator, it's actually the value
contained in StopIteration
exception object.) If the return value exists, use send
method to pass it to the outer-level generator in the call chain.
If the StopIteration
exception is captured, the current innermost function exits. The corresponding generator in call_stack
is deleted and the return value is transferred to the "caller function".
When the input parameter of recursive_add
is 0
, the internal code of recursive_add
will not execute the yield expression. In this case, recursive_add
still returns a generator. However, this generator throws an exception (the meaning of returning 0) when its next
is invoked for the first time.
When all the generators in call_stack
are finished, return_value
is the final result of the original recursive function.
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_finish
executor is exactly the same with the previous one, so the definition is not shown here.
class Node:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def dfs(root):
if root is None:
return
print(root.value) # print value of this node
yield dfs(root.left) # traverse left child subtree, only yield is added for modification
yield dfs(root.right) # traverse right child subtree, only yield is added for modification
bin_tree = Node(1, Node(2, Node(3), Node(4)), Node(5))
run_to_finish(dfs(bin_tree))