Package: tail-recursive
Installationpip install tail-recursiveBasic UsageUse thetail_recursivedecorator to simply define tail recursive functions.If you are encounteringmaximum recursion depth errorsorout-of-memory crashestail recursion can be a helpful strategy.Examplefromtail_recursiveimporttail_recursive# Pick a larger value if n is below your system's recursion limit.x=5000deffactorial_without_tail_recursion(n):ifn<=1:returnnreturnn*factorial_without_tail_recursion(n-1)try:# This will exceed the maximum recursion depth.factorial_without_tail_recursion(x)exceptRecursionError:pass@tail_recursivedeffactorial(n):ifn<=1:returnn# It is important that you return the return value of the `tail_call`# method for tail recursion to take effect!# Note tail calls work with dunder methods, such as __mul__ and __rmul__,# by default.returnn*factorial.tail_call(n-1)# Implementation with tail recursion succeeds because the function is# called sequentially under the hood.factorial(x)FeaturesFeature SetsThree feature sets are currently supported:FeatureSet.BASE,FeatureSet.NESTED_CALLSandFeatureSet.FULL.
Where the "base" feature set provides a subset of the functionality of the "nested_calls" feature set which provides
a subset of the "full" functionality.
As a result the following python code is valid:fromtail_recursiveimportFeatureSetassertFeatureSet.FULL==FeatureSet.FULL|FeatureSet.NESTED_CALLS==FeatureSet.FULL|FeatureSet.NESTED_CALLS|FeatureSet.BASEThe default feature set is "full".
Choosing a feature set with less functionality will provide some performance increase.The desired feature set can be set be passing a value to thefeature_setparameter, e.g.:@tail_recursive(feature_set=FeatureSet.NESTED_CALLS)deffunc(...):...You can also pass a lowercase string as a shorthand, e.g.:@tail_recursive(feature_set="nested_calls")deffunc(...):...Nested Calls (feature_set="nested_calls"/FeatureSet.NESTED_CALLS | "full"/FeatureSet.FULL)This feature will resolve tail calls passed as parameters to other tail calls....@tail_recursivedefmul(a,b):returna*b@tail_recursivedeffactorial(n):ifn==1:returnnreturnmul.tail_call(n,factorial.tail_call(n-1))...As mentioned this comes at a performance cost and can be disabled by using the "base" feature set.Method Calls (feature_set="full"/FeatureSet.Full)Method calls on tail calls are supported, e.g.:@tail_recursive(feature_set=feature_set)deffunc(n):ifn==0:returnset()returnfunc.tail_call(n-1).union({n})Operator Overloading and Dunder Method Overriding (feature_set="full"/FeatureSet.Full)n * factorial.tail_call(n - 1)shows that numeric operations
can be done on tail calls, so long as the result of the expression
is returned by the function. These expressions will ultimately
evaluate to the same value that they would have iftail_callhad been omitted.
This is also true for comparison and bitwise
operations, attribute and index access (i.e.<func>.tail_call(...)[...])
and much more functionality provided by dunder methods.That being said, attribute assignment (i.e.<func>.tail_call(...).<attr> = val)
and the functionality provided by the following dunder methods are not currently
supported withtail_call.__del____getattribute____setattr____get____set____delete____set_name____init_subclass____prepare__Note that also__init__and__new__cannot be called directly on a tail call
(e.g.<func>.tail_call(...).__init__(...)) and are instead implicitly lazily evaluated
with the arguments passed totail_callwhile popping off/unwinding the tail call stack.Futhermore, dunder methods added after 3.8 and in standard library or third-party packages/modules may also not be supported.Another important note is that dunder attributes will currently not be lazily evaluated.
e.g.__doc____name____qualname____module____defaults____defaults____code____globals____dict____closure____annotations____kwdefaults__Finally, since__repr__and__str__are overridden use<func>.tail_call(...)._to_string()to pretty print tail calls.Usage with other DecoratorsEspecially in recursive algorithms it can significantly increase performance
to use memoization. In this use case it is best to place the decorator enabling
memoization after@tail_recursive. e.g.importfunctools@tail_recursive(feature_set="full")@functools.lru_cachedeffibonacci(n):ifn<=1:returnnreturnfibonacci.tail_call(n-1)+fibonacci.tail_call(n-2)For properties place the@propertydecorator before@tail_recursive.Current LimitationsReturn ValuesCurrently, tail calls that are returned as item/member in a tuple or other
data structures are not evaluated.The following will not evaluate the tail call.fromtail_recursiveimporttail_recursive@tail_recursivedeffunc(...):...returnreturn_val1,func.tail_call(...)A workaround is to use factory functions.fromtail_recursiveimporttail_recursive@tail_recursivedeftuple_factory(*args):returntuple(args)@tail_recursivedeffunc(...):...returntuple_factory.tail_call(return_val1,func.tail_call(...))Or pass the container object's type directly totail_recursive.fromtail_recursiveimporttail_recursive@tail_recursivedeffunc(...):...returntail_recursive(tuple).tail_call((return_val1,func.tail_call(...)))Method DecoratorsCurrently, when callingtail_callon a decorated method, you need to explicitly pass
self (the current objects instance) as the first argument. e.g.classMathStuff:@tail_recursive(feature_set="full")deffibonacci(self,n):ifn<=1:returnnreturnself.fibonacci.tail_call(self,n-1)+self.fibonacci.tail_call(self,n-2)^^^^^^^^How it works@tail_recursivedeffactorial(n):ifn<=1:returnnreturnn*factorial.tail_call(n-1)When a function (in this casefactorial) is decorated by@tail_recursive, it returns
an object implementing thetail_callmethod. This object also overrides the__call__method, meaning that it can be called just like the original function (e.g.factorial(x)).Decorated functions test whether they return a call totail_call(...). If this is the case
then the return value is pushed on a call stack implemented as a list.tail_callreturns
an object storing the function it was called on (e.g.factorial) and the (keyword)
arguments (e.g.n - 1) it was called with. If the arguments contain a nested call totail_callthen this
call is also pushed onto the call stack. On the other hand iftail_callis passed no nestedtail_calls then the function that it stores is called with the stored (keyword) arguments. The
return value of this lazy call then (a) replaces the argument it was passed as or (b)
returns anothertail_callwhich is pushed to the stack or (c) is the final return value of
the call to the decorated function (e.g.factorial(x)).But how canfactorial.tail_call(n - 1)be multiplied byn? Well, the object returned bytail_calloverrides most dunder methods, such as__mul__and__rmul__, pushing the
equivalent oftail_recursive(int.__rmul__).tail_call(n, factorial.tail_call(n - 1)to the
call stack.The call stack forfactorial(3)would looks something like this.Becausefactorial(3)is called,<lazy_call_obj>(func=factorial, args=(3,), kwargs={})ispushedon the stack.[<lazy_call_obj>(func=factorial,args=(3,),kwargs={}),]Because<lazy_call_obj>(func=factorial, args=(3,), kwargs={})contains no nested arguments,
it ispoppedoff the stack. It is then lazily evaluated, returning another<lazy_call_obj>, which ispushedto the stack.[<lazy_call_obj>(func=int.__rmul__,args(<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),3),kwargs={}),]The lazy call to__rmul__has a nested call as an argument. Consequentially, this
argument ispushedon the call stack.[<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),3),kwargs={}),<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),]As in step2the lazy call tofactorial(2)ispopoff the stack and its return
value ispushedon.[<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),3),kwargs={}),<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(1,),kwargs={}),2),kwargs={}),]Similarly to step3, because the lazy call to__rmul__has a nested call as an
argument, this argument ispushedon the stack.[<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),3),kwargs={}),<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(1,),kwargs={}),2),kwargs={}),<lazy_call_obj>(func=factorial,args=(1,),kwargs={}),]<lazy_call_obj>(func=int.__rmul__, args=(1,), kwargs={})has no nested lazy calls
as arguments, so it ispoppedoff the stack and its return value replaces
the argument of__rmul__that it was originally passed as.[<lazy_call_obj>(func=int.__rmul__,args=(<lazy_call_obj>(func=factorial,args=(2,),kwargs={}),3),kwargs={}),<lazy_call_obj>(func=int.__rmul__,args=(1,2),kwargs={}),]The same process as in6is repeated, where<lazy_call_obj>(func=int.__rmul__, args=(2, 1), kwargs={})ispoppedoff the
stack and its return value replaces the second argument of the lazy call toint.__rmul__(3, ...).[<lazy_call_obj>(func=int.__rmul__,args=(2,3),kwargs={}),]Finally, because the lazy call to__rmul__no longer has any nested calls as
arguments, it can bepoppedoff the stack. Furthermore, it was not passed
as an argument of a previous call on the stack and, for that reason, is returned
from our decorated function (i.e.factorial(3) = int.__rmul__(2, 3) = 6).[]Other PackagesCheck outtcofor an alternative api with extra functionality.