@@ -13,17 +13,17 @@ If you are encountering **maximum recursion depth errors** or **out-of-memory cr
13
13
### Example
14
14
15
15
``` python
16
- import tail_recursive from tail_recursive
16
+ from tail_recursive import tail_recursive
17
17
18
18
19
19
# Pick a larger value if n is below your system's recursion limit.
20
20
x = 5000
21
21
22
22
23
- def factorial_without_tail_recursion (n , accumulator = 1 ):
24
- if n = = 1 :
25
- return accumulator
26
- return factorial_without_tail_recursion(n - 1 , n * accumulator )
23
+ def factorial_without_tail_recursion (n ):
24
+ if n < = 1 :
25
+ return n
26
+ return n * factorial_without_tail_recursion(n - 1 )
27
27
28
28
29
29
try :
@@ -34,32 +34,131 @@ except RecursionError:
34
34
35
35
36
36
@tail_recursive
37
- def factorial (n , accumulator = 1 ):
38
- if n = = 1 :
39
- return accumulator
37
+ def factorial (n ):
38
+ if n < = 1 :
39
+ return n
40
40
# It is important that you return the return value of the `tail_call`
41
41
# method for tail recursion to take effect!
42
- return factorial.tail_call(n - 1 , n * accumulator)
42
+ # Note tail calls work with dunder methods, such as __mul__ and __rmul__,
43
+ # by default.
44
+ return n * factorial.tail_call(n - 1 )
43
45
44
46
45
47
# Implementation with tail recursion succeeds because the function is
46
48
# called sequentially under the hood.
47
49
factorial(x)
48
50
```
49
51
50
- The ` tail_call ` method returns an object which stores a function (e.g. ` factorial ` ) and
51
- its arguments. The function is then lazily evaluated once the object has been returned
52
- from the caller function (in this case also ` factorial ` ). This means that the
53
- resources in the caller function's scope are free to be garbage collected and that its
54
- frame is popped from the call stack before we push the returned function on.
52
+ ### How it works
53
+
54
+ When a function (in this case ` factorial ` ) is decorated by ` @tail_recursive ` , it returns
55
+ an object implementing the ` tail_call ` method. This object also overrides the ` __call__ `
56
+ method, meaning that it can be called just like the original function (e.g. ` factorial(x) ` ).
57
+
58
+ Decorated functions test whether they return a call to ` tail_call(...) ` . If this is the case
59
+ then the return value is pushed on a call stack implemented as a list. ` tail_call ` returns
60
+ an object storing the function it was called on (e.g. ` factorial ` ) and the (keyword)
61
+ arguments (e.g. ` n - 1 ` ) it was called with. If the arguments contain a nested call to ` tail_call ` then this
62
+ call is also pushed onto the call stack. On the other hand if ` tail_call ` is passed no nested
63
+ ` tail_call ` s then the function that it stores is called with the stored (keyword) arguments. The
64
+ return value of this lazy call then (a) replaces the argument it was passed as or (b)
65
+ returns another ` tail_call ` which is pushed to the stack or (c) is the final return value of
66
+ the call to the decorated function (e.g. ` factorial(x) ` ).
67
+
68
+ But how can ` factorial.tail_call(n - 1) ` be multiplied by ` n ` ? Well, the object returned by
69
+ ` tail_call ` overrides most dunder methods, such as ` __mul__ ` and ` __rmul__ ` , pushing the
70
+ equivalent of ` tail_recursive(int.__rmul__).tail_call(n, factorial.tail_call(n - 1) ` to the
71
+ call stack.
72
+
73
+ The call stack for ` factorial(3) ` would looks something like this.
74
+
75
+ 1 . Because ` factorial(3) ` is called, ` <lazy_call_obj>(func=factorial, args=(3,), kwargs={}) `
76
+ is ** pushed** on the stack.
77
+
78
+ ``` python
79
+ [
80
+ < lazy_call_obj> (func=factorial, args=(3 ,), kwargs={}),
81
+ ]
82
+ ```
83
+
84
+ 2 . Because ` <lazy_call_obj>(func=factorial, args=(3,), kwargs={}) ` contains no nested arguments,
85
+ it is ** popped** off the stack. It is then lazily evaluated, returning another ` <lazy_call_obj> ` , which is ** pushed** to the stack.
86
+
87
+ ``` python
88
+ [
89
+ < lazy_call_obj> (func=int .__rmul__ , args(< lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}), 3 ), kwargs={}),
90
+ ]
91
+ ```
92
+
93
+ 3 . The lazy call to ` __rmul__ ` has a nested call as an argument. Consequentially, this
94
+ argument is ** pushed** on the call stack.
95
+
96
+ ``` python
97
+ [
98
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}), 3 ), kwargs={}),
99
+ < lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}),
100
+ ]
101
+ ```
102
+
103
+ 4 . As in step _ 2_ the lazy call to ` factorial(2) ` is ** pop** off the stack and its return
104
+ value is ** pushed** on.
55
105
56
- ## Nested Calls
106
+ ``` python
107
+ [
108
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}), 3 ), kwargs={}),
109
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(1 ,), kwargs={}), 2 ), kwargs={}),
110
+ ]
111
+ ```
57
112
58
- In the previous example the whole concept of an accumulator my not fit your mental model
59
- that well (it doesn't for me at least).
60
- Luckily calls to ` tail_call ` support nested calls (i.e. another ` tail_call ` passed as an
61
- argument).
62
- Taking this functionality into consideration we can refactor the previous example.
113
+ 5 . Similarly to step _ 3_ , because the lazy call to ` __rmul__ ` has a nested call as an
114
+ argument, this argument is ** pushed** on the stack.
115
+
116
+ ``` python
117
+ [
118
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}), 3 ), kwargs={}),
119
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(1 ,), kwargs={}), 2 ), kwargs={}),
120
+ < lazy_call_obj> (func=factorial, args=(1 ,), kwargs={}),
121
+ ]
122
+ ```
123
+
124
+ 6 . ` <lazy_call_obj>(func=int.__rmul__, args=(1,), kwargs={}) ` has no nested lazy calls
125
+ as arguments, so it is ** popped** off the stack and its return value replaces
126
+ the argument of ` __rmul__ ` that it was originally passed as.
127
+
128
+ ``` python
129
+ [
130
+ < lazy_call_obj> (func=int .__rmul__ , args=(< lazy_call_obj> (func=factorial, args=(2 ,), kwargs={}), 3 ), kwargs={}),
131
+ < lazy_call_obj> (func=int .__rmul__ , args=(1 , 2 ), kwargs={}),
132
+ ]
133
+ ```
134
+
135
+ 7 . The same process as in _ 6_ is repeated, where
136
+ ` <lazy_call_obj>(func=int.__rmul__, args=(2, 1), kwargs={}) ` is ** popped** off the
137
+ stack and its return value replaces the second argument of the lazy call to
138
+ ` int.__rmul__(3, ...) ` .
139
+
140
+ ``` python
141
+ [
142
+ < lazy_call_obj> (func=int .__rmul__ , args=(2 , 3 ), kwargs={}),
143
+ ]
144
+ ```
145
+
146
+ 8 . Finally, because the lazy call to ` __rmul__ ` no longer has any nested calls as
147
+ arguments, it can be ** popped** off the stack. Furthermore, it was not passed
148
+ as an argument of a previous call on the stack and, for that reason, is returned
149
+ from our decorated function (i.e. ` factorial(3) = int.__rmul__(2, 3) = 6 ` ).
150
+
151
+ ``` python
152
+ []
153
+ ```
154
+
155
+ ## Features
156
+
157
+ ### Nested Tail Calls
158
+
159
+ (only works for ` feature_set="full"|FeatureSet.FULL ` )
160
+
161
+ As mentioned above nested tail calls are sequentially evaluated by creating a call stack.
63
162
64
163
``` python
65
164
...
@@ -77,10 +176,10 @@ def factorial(n):
77
176
...
78
177
```
79
178
80
- This , however, comes a performance cost and can be disabled as follows.
179
+ Nested calls , however, comes a performance cost and can be disabled as follows.
81
180
82
181
``` python
83
- @tail_recursive (nested_call_mode = " do_not_resolve_nested_calls " )
182
+ @tail_recursive (feature_set = " base " )
84
183
def factorial (n , accumulator = 1 ):
85
184
if n == 1 :
86
185
return accumulator
@@ -90,24 +189,94 @@ def factorial(n, accumulator=1):
90
189
or
91
190
92
191
``` python
93
- from tail_recursive import tail_recursive, NestedCallMode
192
+ from tail_recursive import tail_recursive, FeatureSet
94
193
95
194
...
96
195
97
- @tail_recursive (nested_call_mode = NestedCallMode. DO_NOT_RESOLVE_NESTED_CALLS )
196
+ @tail_recursive (nested_call_mode = FeatureSet. BASE )
98
197
def factorial (n , accumulator = 1 ):
99
198
...
100
199
```
101
200
102
- Similarly, use ` nested_call_mode="resolve_nested_calls "` or ` nested_call_mode=NestedCallMode.RESOLVE_NESTED_CALLS `
201
+ Similarly, use ` feature_set="full "` or ` feature_set=FeatureSet.FULL `
103
202
to explicitly enable this feature.
104
203
204
+ ### Dunder Method Overrides
205
+
206
+ (only works for ` feature_set="full"|FeatureSet.FULL ` )
207
+
208
+ ` n * factorial.tail_call(n - 1) ` shows that numeric operations
209
+ can be done on tail calls and so long as the result of the expression
210
+ is returned by the function. These expression will ultimately
211
+ evaluate to the same value that they would have if ` tail_call ` had been omitted.
212
+ This is also true for comparison and bitwise
213
+ operations, attribute and index access (i.e. ` <func>.tail_call(...)[...] ` )
214
+ and much more functionality provided by dunder methods.
215
+
216
+ That being said, attribute assignment (i.e. ` <func>.tail_call(...).<attr> = val ` )
217
+ and the functionality provided by the following dunder methods are not currently
218
+ supported with ` tail_call ` .
219
+
220
+ - ` __del__ `
221
+ - ` __getattribute__ `
222
+ - ` __setattr__ `
223
+ - ` __get__ `
224
+ - ` __set__ `
225
+ - ` __delete__ `
226
+ - ` __set_name__ `
227
+ - ` __init_subclass__ `
228
+ - ` __prepare__ `
229
+
230
+ Note that also ` __init__ ` and ` __new__ ` cannot be called directly on a tail call
231
+ (e.g. ` <func>.tail_call(...).__init__(...) ` ) and are instead implicitly lazily evaluated
232
+ with the arguments passed to ` tail_call ` while popping off/unwinding the tail call stack.
233
+
234
+ Futhermore, dunder methods added after 3.8 and in standard library or third-party packages/modules may also not be supported.
235
+
236
+ Another important note is that dunder attributes will currently not be lazily evaluated.
237
+ e.g.
238
+
239
+ - ` __doc__ `
240
+ - ` __name__ `
241
+ - ` __qualname__ `
242
+ - ` __module__ `
243
+ - ` __defaults__ `
244
+ - ` __defaults__ `
245
+ - ` __code__ `
246
+ - ` __globals__ `
247
+ - ` __dict__ `
248
+ - ` __closure__ `
249
+ - ` __annotations__ `
250
+ - ` __kwdefaults__ `
251
+
252
+ Finally, since ` __repr__ ` and ` __str__ ` are overridden use
253
+ ` <func>.tail_call(...)._to_string() ` to pretty print tail calls.
254
+
255
+ ## Usage with other Decorators
256
+
257
+ Especially in recursive algorithms it can significantly increase performance
258
+ to use memoization. In this use case it is best to place the decorator enabling
259
+ memoization after ` @tail_recursive ` . e.g.
260
+
261
+ ``` python
262
+ import functools
263
+
264
+ @tail_recursive (feature_set = " full" )
265
+ @functools.lru_cache
266
+ def fibonacci (n ):
267
+ if n <= 1 :
268
+ return n
269
+ return fibonacci.tail_call(n - 1 ) + fibonacci.tail_call(n - 2 )
270
+ ```
271
+
272
+ For properties place the ` @property ` decorator before ` @tail_recursive ` .
273
+
105
274
## Current Limitations
106
275
107
276
### Return Values
108
277
109
- Currently tail calls that are returned as an item in a tuple or other
110
- data structure are not evaluated.
278
+ Currently tail calls that are returned as item/member in a tuple or other
279
+ data structures are not evaluated.
111
280
112
281
The following will not evaluate the tail call.
113
282
@@ -138,6 +307,36 @@ def func(...):
138
307
)
139
308
```
140
309
310
+ Or pass the container object's type directly to ` tail_recursive ` .
311
+
312
+ ``` python
313
+ from tail_recursive import tail_recursive
314
+
315
+ @tail_recursive
316
+ def func (...):
317
+ ...
318
+ return tail_recursive(tuple ).tail_call((
319
+ return_val1,
320
+ func.tail_call(... )
321
+ ))
322
+ ```
323
+
324
+ ### Method Decorators
325
+
326
+ Currently, when calling ` tail_call ` on a decorated method, you need to explicitly pass
327
+ self (the current objects instance) as the first argument. e.g.
328
+
329
+ ``` python
330
+ class MathStuff :
331
+
332
+ @tail_recursive (feature_set = " full" )
333
+ def fibonacci (self , n ):
334
+ if n <= 1 :
335
+ return n
336
+ return self .fibonacci.tail_call(self , n - 1 ) + self .fibonacci.tail_call(self , n - 2 )
337
+ ^^^^ ^^^^
338
+ ```
339
+
141
340
## Other Packages
142
341
143
342
Check out [ tco] ( https://github.com/baruchel/tco ) for an alternative api with extra functionality.
0 commit comments