Skip to content

Commit 3165726

Browse files
committed
Update readme
1 parent 3e31fdf commit 3165726

File tree

1 file changed

+226
-27
lines changed

1 file changed

+226
-27
lines changed

README.md

Lines changed: 226 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ If you are encountering **maximum recursion depth errors** or **out-of-memory cr
1313
### Example
1414

1515
```python
16-
import tail_recursive from tail_recursive
16+
from tail_recursive import tail_recursive
1717

1818

1919
# Pick a larger value if n is below your system's recursion limit.
2020
x = 5000
2121

2222

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)
2727

2828

2929
try:
@@ -34,32 +34,131 @@ except RecursionError:
3434

3535

3636
@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
4040
# It is important that you return the return value of the `tail_call`
4141
# 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)
4345

4446

4547
# Implementation with tail recursion succeeds because the function is
4648
# called sequentially under the hood.
4749
factorial(x)
4850
```
4951

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.
55105

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+
```
57112

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.
63162

64163
```python
65164
...
@@ -77,10 +176,10 @@ def factorial(n):
77176
...
78177
```
79178

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.
81180

82181
```python
83-
@tail_recursive(nested_call_mode="do_not_resolve_nested_calls")
182+
@tail_recursive(feature_set="base")
84183
def factorial(n, accumulator=1):
85184
if n == 1:
86185
return accumulator
@@ -90,24 +189,94 @@ def factorial(n, accumulator=1):
90189
or
91190

92191
```python
93-
from tail_recursive import tail_recursive, NestedCallMode
192+
from tail_recursive import tail_recursive, FeatureSet
94193

95194
...
96195

97-
@tail_recursive(nested_call_mode=NestedCallMode.DO_NOT_RESOLVE_NESTED_CALLS)
196+
@tail_recursive(nested_call_mode=FeatureSet.BASE)
98197
def factorial(n, accumulator=1):
99198
...
100199
```
101200

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`
103202
to explicitly enable this feature.
104203

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+
105274
## Current Limitations
106275

107276
### Return Values
108277

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.
111280

112281
The following will not evaluate the tail call.
113282

@@ -138,6 +307,36 @@ def func(...):
138307
)
139308
```
140309

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+
141340
## Other Packages
142341

143342
Check out [tco](https://github.com/baruchel/tco) for an alternative api with extra functionality.

0 commit comments

Comments
 (0)