Skip to content

Commit 00cb015

Browse files
author
oscar.butler
committed
Use flags for feature sets
1 parent d62bc60 commit 00cb015

File tree

4 files changed

+278
-192
lines changed

4 files changed

+278
-192
lines changed

README.md

Lines changed: 145 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -49,116 +49,40 @@ def factorial(n):
4949
factorial(x)
5050
```
5151

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.
105-
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-
```
52+
## Features
11253

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.
54+
### Feature Sets
11555

56+
Three feature sets are currently supported:
57+
`FeatureSet.BASE`, `FeatureSet.NESTED_CALLS` and `FeatureSet.FULL`.
58+
Where the "base" feature set provides a subset of the functionality of the "nested_calls" feature set which provides
59+
a subset of the "full" functionality.
60+
As a result the following python code is valid:
11661
```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.
62+
from tail_recursive import FeatureSet
12763

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-
]
64+
assert FeatureSet.FULL == FeatureSet.FULL | FeatureSet.NESTED_CALLS == FeatureSet.FULL | FeatureSet.NESTED_CALLS | FeatureSet.BASE
13365
```
13466

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, ...)`.
67+
The default feature set is "full".
68+
Choosing a feature set with less functionality will provide some performance increase.
13969

70+
The desired feature set can be set be passing a value to the `feature_set` parameter, e.g.:
14071
```python
141-
[
142-
<lazy_call_obj>(func=int.__rmul__, args=(2, 3), kwargs={}),
143-
]
72+
@tail_recursive(feature_set=FeatureSet.NESTED_CALLS)
73+
def func(...):
74+
...
14475
```
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-
76+
You can also pass a lowercase string as a shorthand, e.g.:
15177
```python
152-
[]
78+
@tail_recursive(feature_set="nested_calls")
79+
def func(...):
80+
...
15381
```
15482

155-
## Features
156-
157-
### Nested Tail Calls
83+
### Nested Calls (`feature_set="nested_calls"/FeatureSet.NESTED_CALLS | "full"/FeatureSet.FULL`)
15884

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.
85+
This feature will resolve tail calls passed as parameters to other tail calls.
16286

16387
```python
16488
...
@@ -176,38 +100,24 @@ def factorial(n):
176100
...
177101
```
178102

179-
Nested calls, however, comes a performance cost and can be disabled as follows.
103+
As mentioned this comes at a performance cost and can be disabled by using the "base" feature set.
180104

181-
```python
182-
@tail_recursive(feature_set="base")
183-
def factorial(n, accumulator=1):
184-
if n == 1:
185-
return accumulator
186-
return factorial.tail_call(n - 1, n * accumulator)
187-
```
188-
189-
or
105+
### Method Calls (`feature_set="full"/FeatureSet.Full`)
190106

107+
Method calls on tail calls are supported, e.g.:
191108
```python
192-
from tail_recursive import tail_recursive, FeatureSet
193-
194-
...
195-
196-
@tail_recursive(nested_call_mode=FeatureSet.BASE)
197-
def factorial(n, accumulator=1):
198-
...
109+
@tail_recursive(feature_set=feature_set)
110+
def func(n):
111+
if n == 0:
112+
return set()
113+
return func.tail_call(n - 1).union({n})
199114
```
200115

201-
Similarly, use `feature_set="full"` or `feature_set=FeatureSet.FULL`
202-
to explicitly enable this feature.
203-
204-
### Dunder Method Overrides
205-
206-
(only works for `feature_set="full"|FeatureSet.FULL`)
116+
### Operator Overloading and Dunder Method Overriding (`feature_set="full"/FeatureSet.Full`)
207117

208118
`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
119+
can be done on tail calls, so long as the result of the expression
120+
is returned by the function. These expressions will ultimately
211121
evaluate to the same value that they would have if `tail_call` had been omitted.
212122
This is also true for comparison and bitwise
213123
operations, attribute and index access (i.e. `<func>.tail_call(...)[...]`)
@@ -275,7 +185,7 @@ For properties place the `@property` decorator before `@tail_recursive`.
275185

276186
### Return Values
277187

278-
Currently tail calls that are returned as item/member in a tuple or other
188+
Currently, tail calls that are returned as item/member in a tuple or other
279189
data structures are not evaluated.
280190

281191
The following will not evaluate the tail call.
@@ -337,6 +247,118 @@ class MathStuff:
337247
^^^^ ^^^^
338248
```
339249

250+
251+
### How it works
252+
253+
```python
254+
@tail_recursive
255+
def factorial(n):
256+
if n <= 1:
257+
return n
258+
return n * factorial.tail_call(n - 1)
259+
```
260+
261+
When a function (in this case `factorial`) is decorated by `@tail_recursive`, it returns
262+
an object implementing the `tail_call` method. This object also overrides the `__call__`
263+
method, meaning that it can be called just like the original function (e.g. `factorial(x)`).
264+
265+
Decorated functions test whether they return a call to `tail_call(...)`. If this is the case
266+
then the return value is pushed on a call stack implemented as a list. `tail_call` returns
267+
an object storing the function it was called on (e.g. `factorial`) and the (keyword)
268+
arguments (e.g. `n - 1`) it was called with. If the arguments contain a nested call to `tail_call` then this
269+
call is also pushed onto the call stack. On the other hand if `tail_call` is passed no nested
270+
`tail_call`s then the function that it stores is called with the stored (keyword) arguments. The
271+
return value of this lazy call then (a) replaces the argument it was passed as or (b)
272+
returns another `tail_call` which is pushed to the stack or (c) is the final return value of
273+
the call to the decorated function (e.g. `factorial(x)`).
274+
275+
But how can `factorial.tail_call(n - 1)` be multiplied by `n`? Well, the object returned by
276+
`tail_call` overrides most dunder methods, such as `__mul__` and `__rmul__`, pushing the
277+
equivalent of `tail_recursive(int.__rmul__).tail_call(n, factorial.tail_call(n - 1)` to the
278+
call stack.
279+
280+
The call stack for `factorial(3)` would looks something like this.
281+
282+
1. Because `factorial(3)` is called, `<lazy_call_obj>(func=factorial, args=(3,), kwargs={})`
283+
is **pushed** on the stack.
284+
285+
```python
286+
[
287+
<lazy_call_obj>(func=factorial, args=(3,), kwargs={}),
288+
]
289+
```
290+
291+
2. Because `<lazy_call_obj>(func=factorial, args=(3,), kwargs={})` contains no nested arguments,
292+
it is **popped** off the stack. It is then lazily evaluated, returning another `<lazy_call_obj>`, which is **pushed** to the stack.
293+
294+
```python
295+
[
296+
<lazy_call_obj>(func=int.__rmul__, args(<lazy_call_obj>(func=factorial, args=(2,), kwargs={}), 3), kwargs={}),
297+
]
298+
```
299+
300+
3. The lazy call to `__rmul__` has a nested call as an argument. Consequentially, this
301+
argument is **pushed** on the call stack.
302+
303+
```python
304+
[
305+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(2,), kwargs={}), 3), kwargs={}),
306+
<lazy_call_obj>(func=factorial, args=(2,), kwargs={}),
307+
]
308+
```
309+
310+
4. As in step _2_ the lazy call to `factorial(2)` is **pop** off the stack and its return
311+
value is **pushed** on.
312+
313+
```python
314+
[
315+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(2,), kwargs={}), 3), kwargs={}),
316+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(1,), kwargs={}), 2), kwargs={}),
317+
]
318+
```
319+
320+
5. Similarly to step _3_, because the lazy call to `__rmul__` has a nested call as an
321+
argument, this argument is **pushed** on the stack.
322+
323+
```python
324+
[
325+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(2,), kwargs={}), 3), kwargs={}),
326+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(1,), kwargs={}), 2), kwargs={}),
327+
<lazy_call_obj>(func=factorial, args=(1,), kwargs={}),
328+
]
329+
```
330+
331+
6. `<lazy_call_obj>(func=int.__rmul__, args=(1,), kwargs={})` has no nested lazy calls
332+
as arguments, so it is **popped** off the stack and its return value replaces
333+
the argument of `__rmul__` that it was originally passed as.
334+
335+
```python
336+
[
337+
<lazy_call_obj>(func=int.__rmul__, args=(<lazy_call_obj>(func=factorial, args=(2,), kwargs={}), 3), kwargs={}),
338+
<lazy_call_obj>(func=int.__rmul__, args=(1, 2), kwargs={}),
339+
]
340+
```
341+
342+
7. The same process as in _6_ is repeated, where
343+
`<lazy_call_obj>(func=int.__rmul__, args=(2, 1), kwargs={})` is **popped** off the
344+
stack and its return value replaces the second argument of the lazy call to
345+
`int.__rmul__(3, ...)`.
346+
347+
```python
348+
[
349+
<lazy_call_obj>(func=int.__rmul__, args=(2, 3), kwargs={}),
350+
]
351+
```
352+
353+
8. Finally, because the lazy call to `__rmul__` no longer has any nested calls as
354+
arguments, it can be **popped** off the stack. Furthermore, it was not passed
355+
as an argument of a previous call on the stack and, for that reason, is returned
356+
from our decorated function (i.e. `factorial(3) = int.__rmul__(2, 3) = 6`).
357+
358+
```python
359+
[]
360+
```
361+
340362
## Other Packages
341363

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "tail-recursive"
3-
version = "2.0.0"
3+
version = "2.1.0"
44
description = "Tail recursion with a simple decorator api."
55
authors = ["0scarB <oscarb@protonmail.com>"]
66
license = "MIT"

tail_recursive/__init__.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ def _resolve(self):
281281
return resolution
282282

283283

284-
class TailCallWithDunderOverloads(TailCallBase):
284+
class TailCallWithNestedCallResolutionAndDunderOverloads(TailCallWithNestedCallResolution):
285285

286286
@staticmethod
287287
def _tail_call_dunder_meth_factory(dunder_meth_name: str):
@@ -337,25 +337,18 @@ def __delattr__(self, name):
337337
)
338338

339339

340-
class TailCallWithNestedCallResolutionAndDunderOverloads(TailCallWithNestedCallResolution, TailCallWithDunderOverloads):
341-
pass
342-
343-
344340
@enum.unique
345341
class FeatureSet(enum.IntFlag):
346342
"""Different ways of resolving nested tail calls."""
347343

348344
BASE = 0
349-
NESTED_CALLS = 1
350-
OVERLOADING = 2
351-
FULL = NESTED_CALLS | OVERLOADING
345+
NESTED_CALLS = 0b1
346+
FULL = 0b11
352347

353348

354349
FEATURE_SET_TAILCALL_SUBCLASS_MAP: Dict[FeatureSet, Type[TailCall]] = {
355350
FeatureSet.BASE: TailCallBase,
356351
FeatureSet.NESTED_CALLS: TailCallWithNestedCallResolution,
357-
FeatureSet.OVERLOADING: TailCallWithDunderOverloads,
358-
FeatureSet.NESTED_CALLS | FeatureSet.OVERLOADING: TailCallWithNestedCallResolutionAndDunderOverloads,
359352
FeatureSet.FULL: TailCallWithNestedCallResolutionAndDunderOverloads,
360353
}
361354

0 commit comments

Comments
 (0)