diff --git a/README.md b/README.md index 752a0055..a3f163cf 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ f2 = lambda x: begin0(42*x, f2(2) # --> 84 ``` -Actually a tuple in disguise. If worried about memory consumption, use `lazy_begin` and `lazy_begin0` instead, which indeed use loops. The price is the need for a lambda wrapper for each expression to delay evaluation, see [`tour.py`](tour.py) for details. Note that the `begin` constructs are only useful for sequencing side effects into a particular order. +Actually a tuple in disguise. If worried about memory consumption, use `lazy_begin` and `lazy_begin0` instead, which indeed use loops. The price is the need for a lambda wrapper for each expression to delay evaluation, see [`tour.py`](tour.py) for details. -There is also `do`, a cousin of `begin` for performing a sequence of operations starting from an initial value and then returning the final value: +Because there is no way to pass results between steps or to declare names for them, the `begin` constructs are only useful for making side effects occur in a given order. We also provide `do`, a cousin of `begin` for performing a sequence of operations starting from an initial value and then returning the final value: ```python double = lambda x: 2 * x @@ -234,7 +234,7 @@ When the `with` block exits, the environment clears itself. The environment inst ### Tail call optimization (TCO) / explicit continuations -Express elegant algorithms without blowing the call stack - with explicit, clear syntax. +Express algorithms elegantly without blowing the call stack - with explicit, clear syntax. *Tail recursion*: @@ -250,17 +250,15 @@ def fact(n, acc=1): print(fact(4)) # 24 ``` -Functions that use TCO **must** be `@trampolined`. Calling a trampolined function normally starts the trampoline. - -Inside a trampolined function, a normal call `f(a, ..., kw=v, ...)` remains a normal call. A tail call with target `f` is denoted `return jump(f, a, ..., kw=v, ...)`. +**CAUTION**: The default implementation is based on exceptions, so catch-all ``except:`` statements will intercept also jumps, breaking the looping mechanism. As you already know, be specific in what you catch! (See also ``fasttco`` below for an alternative that doesn't use exceptions.) -Here `jump` is **a noun, not a verb**. The `jump(f, ...)` part just evaluates to a `jump` instance, which on its own does nothing. Returning it to the trampoline actually performs the tail call. +Functions that use TCO **must** be `@trampolined`. Calling a trampolined function normally starts the trampoline. -The final result is just returned normally. Returning a normal value (anything that is not a ``jump`` instance) to a trampoline shuts down that trampoline, and returns the given value from the initial call (to a ``@trampolined`` function) that originally started that trampoline. +Inside a trampolined function, a normal call `f(a, ..., kw=v, ...)` remains a normal call. A tail call with target `f` is denoted `jump(f, a, ..., kw=v, ...)`. -Trying to ``jump(...)`` without the ``return`` will **usually** print a warning. This is implemented by checking a flag in the ``__del__`` method; any correctly used jump instance should have been claimed by a trampoline at some point in its lifetime. +Optionally, **to make it work also with fasttco**, explained below, a tail call **can** be denoted also `return jump(f, a, ..., kw=v, ...)` (adding a ``return``). In the examples here, we will use this optional syntax to keep the examples compatible with both implementations, and also to explicitly mark that these are indeed tail calls (due to the explicit ``return``). -If you prefer the syntax without the explicit ``return``, see [`tco_exc.py`](unpythonic/tco_exc.py). **Since this is pre-1.0**, the default implementation (and the import in [`fploop.py`](unpythonic/fploop.py)) is still subject to change. +The final result is just returned normally. This shuts down the trampoline, and returns the given value from the initial call (to a ``@trampolined`` function) that originally started that trampoline. *Tail recursion in a lambda*: @@ -270,7 +268,7 @@ t = trampolined(lambda n, acc=1: print(t(4)) # 24 ``` -To denote tail recursion in an anonymous function, use the special jump target `SELF` (all uppercase!). Here it's just `jump` instead of `return jump` since lambda does not use the `return` syntax. +To denote tail recursion in an anonymous function, use the special jump target `SELF` (all uppercase!). Here it's just `jump` instead of `return jump` also with `fasttco`, since lambda does not use the `return` syntax. Technically, `SELF` means *keep current jump target*, so the function that was last explicitly tail-called by name in that particular trampoline remains as the target of the jump. When the trampoline starts, the current target is set to the initial entry point (also for lambdas). @@ -307,6 +305,58 @@ letrec(evenp=lambda e: e.evenp(10000)) ``` + +#### Fasttco + +The default TCO implementation uses exceptions. A do-nothing loop that trampolines with [``tco.py``](unpythonic/tco.py) runs 150-200× slower than the built-in ``for``. + +To improve performance by a factor of approximately 2-5× (i.e. to become only 40-80× slower than ``for``), we provide an alternative TCO implementation, which is faster, but pickier about its syntax. **If you think you know what you're doing**, ``fasttco`` is the recommended implementation. + +To enable it: + +```python +import unpythonic +unpythonic.enable_fasttco() +``` + +This redirects `unpythonic.tco` to actually point to [`fasttco.py`](unpythonic/fasttco.py), reloads `unpythonic.fploop` using `fasttco`, and resets `unpythonic.trampolined`, `unpythonic.jump` and `unpythonic.SELF` to point to those in `fasttco`. Short demonstration: + +```python +import unpythonic +unpythonic.fploop.test() # using default TCO +unpythonic.enable_fasttco() +unpythonic.fploop.test() # using fast TCO +``` + +**CAUTION**: if you from-imported names from `unpythonic.tco` (also implicitly, e.g. by `from unpythonic import *`), this **will not** update your local references, since it cannot have access to your namespace. To update your local references, just from-import the names again. This works because the names *inside the unpythonic module* are refreshed by ``enable_fasttco()``. Or just import them only after calling `enable_fasttco()` in the first place. + +In summary, do something like: + +```python +import unpythonic +unpythonic.enable_fasttco() +from unpythonic import * # do any from-imports **after** enable_fasttco() to get correct local references +``` + +to use ``unpythonic`` with ``fasttco`` enabled. Having the correct references everywhere is important, because the TCO implementations **cannot be mixed and matched**. + +In `fasttco`, unlike in the default implementation, `jump` is **a noun, not a verb**. The `jump(f, ...)` part just evaluates to a `jump` instance, which on its own does nothing. Returning it to the trampoline actually performs the tail call; hence `fasttco` **requires** the syntax `return jump(f, ...)`. **This propagates to fploop**; i.e. ``loop`` also becomes a **noun**, because it is essentially a fancy wrapper on top of ``jump``. + +With `fasttco`, trying to ``jump(...)`` without the ``return`` does nothing useful, and will **usually** print a warning. It does this by checking a flag in the ``__del__`` method of ``jump``; any correctly used jump instance should have been claimed by a trampoline before it gets garbage-collected. + +Using `fasttco` introduces the serious usability trap of forgetting the ``return`` (hence it's not the default implementation), but in exchange, it can detect another kind of error not caught by `tco`, namely a trampoline declared at the wrong level: + +```python +@trampolined +def foo(): + def bar(): + return jump(qux, 23) + bar() # normal call, no TCO +``` + +Here ``bar`` has no trampoline; only ``foo`` does. In `fasttco`, **only** a ``@trampolined`` function, or a function entered via a tail call, may return a jump. The default TCO implementation happily escapes out to the trampoline of ``foo``, performing the tail call as if ``foo`` had requested the ``jump``. + + #### Reinterpreting TCO as explicit continuations TCO from another viewpoint: @@ -324,7 +374,7 @@ def baz(): foo() ``` -Each function in the TCO call chain tells the trampoline where to go next (and with what parameters). All hail lambda, the ultimate GO TO! +Each function in the TCO call chain tells the trampoline where to go next (and with what parameters). All hail [lambda, the ultimate GO TO](http://library.readscheme.org/page1.html)! Each TCO call chain brings its own trampoline, so they nest as expected: @@ -389,9 +439,9 @@ In `@looped`, the function name of the loop body is the name of the final result The first parameter of the loop body is the magic parameter ``loop``. It is *self-ish*, representing a jump back to the loop body itself, starting a new iteration. Just like Python's ``self``, ``loop`` can have any name; it is passed positionally. -Just like ``jump``, here ``loop`` is **a noun, not a verb.** This is because the expression ``loop(...)`` is essentially the same as ``jump(SELF, ...)``. However, it also inserts the magic parameter ``loop``, which can only be set up via this mechanism. +Just like ``jump``, if `fasttco` is used, then ``loop`` is **a noun, not a verb.** This is because the expression ``loop(...)`` is essentially the same as ``jump(SELF, ...)``. However, it also inserts the magic parameter ``loop``, which can only be set up via this mechanism. -Additional arguments can be given to ``return loop(...)``. When the loop body is called, any additional positional arguments are appended to the implicit ones, and can be anything. Additional arguments can also be passed by name. The initial values of any additional arguments **must** be declared as defaults in the formal parameter list of the loop body. The loop is automatically started by `@looped`, by calling the body with the magic ``loop`` as the only argument. +Additional arguments can be given to ``loop(...)``. When the loop body is called, any additional positional arguments are appended to the implicit ones, and can be anything. Additional arguments can also be passed by name. The initial values of any additional arguments **must** be declared as defaults in the formal parameter list of the loop body. The loop is automatically started by `@looped`, by calling the body with the magic ``loop`` as the only argument. Any loop variables such as ``i`` in the above example are **in scope only in the loop body**; there is no ``i`` in the surrounding scope. Moreover, it's a fresh ``i`` at each iteration; nothing is mutated by the looping mechanism. (But be careful if you use a mutable object instance as a loop variable. The loop body is just a function call like any other, so the usual rules apply.) @@ -408,7 +458,7 @@ def _(loop, i=0): assert out == [0, 1, 2, 3] ``` -Keep in mind, though, that this pure-Python FP looping mechanism is 40-80× slower than Python's builtin imperative ``for`` (when benchmarked with a do-nothing loop). +Keep in mind, though, that this pure-Python FP looping mechanism is slow (even with `fasttco`), so it may make sense to use it only when "the FP-ness" (no mutation, scoping) is important. Also be aware that `@looped` is specifically neither a ``for`` loop nor a ``while`` loop; instead, it is a general looping mechanism that can express both kinds of loops. @@ -454,9 +504,9 @@ def s(iterable=range(10)): assert s == 45 ``` -In ``@looped_over``, the loop body takes three magic positional parameters. The first parameter ``loop`` works like in ``@looped``. The second parameter ``x`` is the current element. The third parameter ``acc`` is initialized to the ``acc`` value given to ``@looped_over``, and then (functionally) updated at each iteration, taking as the new value the first positional argument given to ``return loop(...)``, if any positional arguments were given. Otherwise ``acc`` retains its last value. +In ``@looped_over``, the loop body takes three magic positional parameters. The first parameter ``loop`` works like in ``@looped``. The second parameter ``x`` is the current element. The third parameter ``acc`` is initialized to the ``acc`` value given to ``@looped_over``, and then (functionally) updated at each iteration, taking as the new value the first positional argument given to ``loop(...)``, if any positional arguments were given. Otherwise ``acc`` retains its last value. -Additional arguments can be given to ``return loop(...)``. The same notes as above apply. For example, here we have the additional parameters ``fruit`` and ``number``. The first one is passed positionally, and the second one by name: +Additional arguments can be given to ``loop(...)``. The same notes as above apply. For example, here we have the additional parameters ``fruit`` and ``number``. The first one is passed positionally, and the second one by name: ```python @looped_over(range(10), acc=0) @@ -468,7 +518,7 @@ def s(loop, x, acc, fruit="pear", number=23): assert s == 45 ``` -The loop body is called once for each element in the iterable. When the iterable runs out of elements, the last ``acc`` value that was given to ``return loop(...)`` becomes the return value of the loop. If the iterable is empty, the body never runs; then the return value of the loop is the initial value of ``acc``. +The loop body is called once for each element in the iterable. When the iterable runs out of elements, the last ``acc`` value that was given to ``loop(...)`` becomes the return value of the loop. If the iterable is empty, the body never runs; then the return value of the loop is the initial value of ``acc``. To terminate the loop early, just ``return`` your final result normally, like in ``@looped``. (It can be anything, does not need to be ``acc``.) @@ -510,7 +560,7 @@ If you want to exit the function *containing* the loop from inside the loop, see #### ``continue`` -The main way to *continue* an FP loop is, at any time, to ``return loop(...)`` with the appropriate arguments that will make it proceed to the next iteration. Or package the appropriate `loop(...)` expression into your own function ``cont``, and then ``return cont(...)``: +The main way to *continue* an FP loop is, at any time, to ``loop(...)`` with the appropriate arguments that will make it proceed to the next iteration. Or package the appropriate `loop(...)` expression into your own function ``cont``, and then use ``cont(...)``: ```python @looped @@ -891,19 +941,18 @@ If we later choose go this route nevertheless, `<<` is a better choice for the s ### TCO syntax and speed -Benefits and costs of ``return jump(...)``: +Benefits and costs of ``return jump(...)``, the syntax required by `fasttco`: - Explicitly a tail call due to ``return``. - The trampoline can be very simple and (relatively speaking) fast. Just a dumb ``jump`` record, a ``while`` loop, and regular function calls and returns. - The cost is that ``jump`` cannot detect whether the user forgot the ``return``, leaving a possibility for bugs in the client code (causing an FP loop to immediately exit, returning ``None``). Unit tests of client code become very important. + - This is somewhat mitigated by the check in `__del__`, but it can only print a warning, not stop the incorrect program from proceeding. - We could mandate that trampolined functions must not return ``None``, but: - Uniformity is lost between regular and trampolined functions, if only one kind may return ``None``. - This breaks the *don't care about return value* use case, which is rather common when using side effects. - Failing to terminate at the intended point may well fall through into what was intended as another branch of the client code, which may correctly have a ``return``. So this would not even solve the problem. -The other simple-ish solution is to use exceptions, making the jump wrest control from the caller. Then ``jump(...)`` becomes a verb. If you would like to use this approach, we provide [``tco_exc.py``](unpythonic/tco_exc.py), a drop-in replacement. But beware, [``fploop.py``](unpythonic/fploop.py) is currently hardwired to load [``tco.py``](unpythonic/tco.py) (even though it works and has been tested with either one), and the TCO implementations **cannot be mixed and matched**. - -By a quick test, we see that the exception-based approach costs even more performance; a do-nothing loop using [``tco_exc.py``](unpythonic/tco_exc.py) runs 150-200× slower than the built-in ``for``, compared to only 40-80× slower using [``tco.py``](unpythonic/tco.py). In other words, the additional performance hit is somewhere between 2-5×. It's slow enough that the additional overhead of ``@looped`` and ``@looped_over`` no longer matters. +The other simple-ish solution is to use exceptions, making the jump wrest control from the caller. Then ``jump(...)`` becomes a verb. This is the approach taken in the default [``tco.py``](unpythonic/tco.py). For other libraries bringing TCO to Python, see: diff --git a/quick_tour.py b/quick_tour.py index 8444c856..70f00d72 100644 --- a/quick_tour.py +++ b/quick_tour.py @@ -52,12 +52,18 @@ def x(): # tail call optimization (TCO) (w.r.t. stack space, not speed!) @trampolined -def fact(n, acc=1): +def even(n): if n == 0: - return acc + return True + else: + return jump(odd, n - 1) +@trampolined +def odd(n): + if n == 0: + return False else: - return jump(fact, n - 1, n * acc) -fact(10000) # no crash + return jump(even, n - 1) +assert even(10000) is True # no crash # FP loop @looped diff --git a/tour.py b/tour.py index 595c650c..c40488a9 100644 --- a/tour.py +++ b/tour.py @@ -8,7 +8,7 @@ from unpythonic import assignonce, \ dyn, \ let, letrec, dlet, dletrec, blet, bletrec, \ - call, begin, begin0, lazy_begin, lazy_begin0, \ + call, begin, begin0, lazy_begin, lazy_begin0, do, \ trampolined, jump, looped, looped_over, SELF, \ setescape, escape, call_ec @@ -141,6 +141,13 @@ def result(): lambda: print("cheeky side effect")) assert f4(2) == 84 + # sequencing operations starting from an initial value + # + double = lambda x: 2 * x + inc = lambda x: x + 1 + assert do(42, double, inc) == 85 + assert do(42, inc, double) == 86 + # tail recursion with tail call optimization (TCO) @trampolined def fact(n, acc=1): diff --git a/unpythonic/__init__.py b/unpythonic/__init__.py index 4ffe85b8..60343365 100644 --- a/unpythonic/__init__.py +++ b/unpythonic/__init__.py @@ -2,29 +2,18 @@ # -*- coding: utf-8 -* """Lispy missing batteries for Python. -We provide two submodules which implement the ``let`` construct: - - - ``unpythonic.let``: - Pythonic syntax, but no guarantees on evaluation order of the bindings - (until Python 3.6; see https://www.python.org/dev/peps/pep-0468/ ). - Bindings are declared using kwargs. - - - ``unpythonic.lispylet``: - Guaranteed left-to-right evaluation of bindings, but clunky syntax. - Bindings are declared as ``(("name", value), ...)``. - -With ``import unpythonic``, the default ``let`` construct is ``unpythonic.let``. -To override, just import the other one; they define the same names. - See ``dir(unpythonic)`` and submodule docstrings for more. """ +__version__ = '0.5.0' + +from . import rc + from .arity import * from .assignonce import * from .dynscope import * from .ec import * -from .fploop import * -from .let import * # no guarantees on evaluation order (before Python 3.6), nice syntax +from .let import * # no guarantees on evaluation order (before Python 3.6), nice syntax # guaranteed evaluation order, clunky syntax from .lispylet import let as ordered_let, letrec as ordered_letrec, \ @@ -32,6 +21,47 @@ blet as ordered_blet, bletrec as ordered_bletrec from .misc import * -from .tco import * -__version__ = '0.5.0' +# Jump through hoops to get a runtime-switchable TCO implementation. +# +# We manually emulate: +# - making the submodule visible like __init__.py usually does +# - from submod import * +# +def _init_tco(): + global tco + if rc._tco_impl == "exc": + from . import tco + elif rc._tco_impl == "fast": + from . import fasttco as tco + else: + raise ValueError("Unknown TCO implementation '{}'".format(rc._tco_impl)) + g = globals() + for name in tco.__all__: + g[name] = getattr(tco, name) + +def _init_fploop(reload=False): + global fploop + from . import fploop + # We must reload fploop, because its module-level initialization + # performs some imports from the TCO module. + if reload: + from importlib import reload + fploop = reload(fploop) + g = globals() + for name in fploop.__all__: + g[name] = getattr(fploop, name) + +_init_tco() +_init_fploop() + +def enable_fasttco(): + """Switch to the fast TCO implementation. + + It is 2-5x faster, but pickier about its syntax, hence not the default. + See ``unpythonic.fasttco`` for details. + """ + rc._tco_impl = "fast" + + _init_tco() + _init_fploop(reload=True) diff --git a/unpythonic/fasttco.py b/unpythonic/fasttco.py new file mode 100644 index 00000000..8df9aa4a --- /dev/null +++ b/unpythonic/fasttco.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Tail call optimization / explicit continuations. + +Where speed matters, prefer the usual ``for`` and ``while`` constructs. +Where speed *really* matters, add Cython on top of those. And as always, +profile first. + +**API reference**: + + - Functions that use TCO must be ``@trampolined``. They are called just like + any normal function. + + - When inside a ``@trampolined`` function: + + - ``f(a, ..., kw=v, ...)`` is just a normal call, no TCO. + + - ``return jump(f, a, ..., kw=v, ...)`` is a tail call to *target* ``f``. + + - `return` explicitly marks a tail position, naturally enforcing that + the caller finishes immediately after its purported *tail* call. + + - When done (no more tail calls to make), just return the final result normally. + + - In this implementation, **"jump" is a noun, not a verb**. + + - Returning a ``jump`` instance makes the trampoline perform the tail call. + + - ``jump(f, ...)`` by itself just evaluates to a jump instance, **doing nothing**. + + - If you're getting ``None`` instead of the result of your computation, + check for ``jump`` where it should be ``return jump``; and then check + that you're returning your final result normally. + + Most often you'll get "unclaimed jump" warnings printed to stderr if you + run into this. + + - **Lambdas welcome!** For example, ``trampolined(lambda x: ...)``. + + - Just keep in mind how Python expands the decorator syntax; the rest follows. + + - Use the special target ``SELF``, as in ``jump(SELF, ...)``, for tail recursion. + + - Just ``jump``, not ``return jump``, since lambdas do not use ``return``. + + - Or assign the lambda expressions to names; this allows also mutual recursion. + + - **Use only where TCO matters**. Stack traces will be hurt, as usual. + + - Tail recursion is a good use case; so is mutual recursion. Here TCO allows + elegantly expressed algorithms without blowing the stack. + + - Danger zone is if one applies TCO just because some call happens to be + in a tail position. This makes debugging a nightmare, since some entries + will be missing from the call stack. + + This implementation retains the original entry point - due to entering + the decorator ``trampolined`` - and the final one where the uncaught exception + actually occurred. Anything in between will have been zapped by TCO. + +**Notes**: + +Actually it is sufficient that the initial entry point to a computation using +TCO is ``@trampolined``. The trampoline keeps running until a normal value +(i.e. anything that is not a ``jump`` instance) is returned. That normal value +is returned to the original caller. + +The ``jump`` constructor automatically strips the target's trampoline, +if it has one - making sure this remains a one-trampoline party even if +the tail-call target is another ``@trampolined`` function. So just declare +anything using TCO as ``@trampolined`` and don't worry about stacking trampolines. + +SELF actually means "keep current target", so the last function that was +jumped to by name in that trampoline remains as the target. When the trampoline +starts, the current target is set to the initial entry point (also for lambdas). + +Beside TCO, trampolining can also be thought of as *explicit continuations*. +Each trampolined function tells the trampoline where to go next, and with what +parameters. All hail lambda, the ultimate GO TO! + +Based on a quick test, running a do-nothing loop with this is about 40-80x +slower than Python's ``for``. + +**Examples**:: + + # tail recursion + @trampolined + def fact(n, acc=1): + if n == 0: + return acc + else: + return jump(fact, n - 1, n * acc) + assert fact(4) == 24 + + # tail recursion in a lambda + t = trampolined(lambda n, acc=1: + acc if n == 0 else jump(SELF, n - 1, n * acc)) + assert t(4) == 24 + + # mutual recursion + @trampolined + def even(n): + if n == 0: + return True + else: + return jump(odd, n - 1) + @trampolined + def odd(n): + if n == 0: + return False + else: + return jump(even, n - 1) + assert even(42) is True + assert odd(4) is False + + # explicit continuations - DANGER: functional spaghetti code! + @trampolined + def foo(): + return jump(bar) + @trampolined + def bar(): + return jump(baz) + @trampolined + def baz(): + raise RuntimeError("Look at the call stack, where did bar() go?") + try: + foo() + except RuntimeError: + pass +""" + +__all__ = ["SELF", "jump", "trampolined"] + +from functools import wraps +from sys import stderr + +from unpythonic.misc import call + +@call # make a singleton +class SELF: # sentinel, could be any object but we want a nice __repr__. + def __repr__(self): + return "SELF" + +def jump(target, *args, **kwargs): + """A jump (noun, not verb). + + Used in the syntax `return jump(f, ...)` to request the trampoline to + perform a tail call. + + Instances of `jump` are not callable, and do nothing on their own. + This is just passive data. + + Parameters: + target: + The function to be called. The special value SELF means + tail-recursion; useful with a ``lambda``. When the target + has a name, it is legal to explicitly give the name also + for tail-recursion. + *args: + Positional arguments to be passed to `target`. + **kwargs: + Named arguments to be passed to `target`. + """ + return _jump(target, args, kwargs) + +class _jump: + """The actual class representing a jump. + + If you have already packed args and kwargs, you can instantiate this + directly; the public API just performs the packing. + """ + def __init__(self, target, args, kwargs): + # IMPORTANT: don't let target bring along its trampoline if it has one + self.target = target._entrypoint if hasattr(target, "_entrypoint") else target + self.args = args + self.kwargs = kwargs + self._claimed = False # set when the instance is caught by a trampoline + + def __repr__(self): + return "<_jump at 0x{:x}: target={}, args={}, kwargs={}".format(id(self), + self.target, + self.args, + self.kwargs) + + def __del__(self): + """Warn about bugs in client code. + + Since it's ``__del__``, we can't raise any exceptions - which includes + things such as ``AssertionError`` and ``SystemExit``. So we print a + warning. + + **CAUTION**: + + This warning mechanism should help find bugs, but it is not 100% foolproof. + Since ``__del__`` is managed by Python's GC, some object instances may + not get their ``__del__`` called when the Python interpreter itself exits + (if those instances are still alive at that time). + + **Typical causes**: + + *Missing "return"*:: + + @trampolined + def foo(): + jump(bar, 42) + + The jump instance was never actually passed to the trampoline; it was + just created and discarded. The trampoline got the ``None`` from the + implicit ``return None`` at the end of the function. + + (See ``tco_exc.py`` if you prefer this syntax, without a ``return``.) + + *No trampoline*:: + + def foo(): + return jump(bar, 42) + + Here ``foo`` is not trampolined. + + We **have** a trampoline when the function that returns the jump + instance is itself ``@trampolined``, or is running in a trampoline + implicitly (due to having been entered via a tail call). + + *Trampoline at the wrong level*:: + + @trampolined + def foo(): + def bar(): + return jump(qux, 23) + bar() # normal call, no TCO + + Here ``bar`` has no trampoline; only ``foo`` does. **Only** a ``@trampolined`` + function, or a function entered via a tail call, may return a jump. + """ + if not self._claimed: + print("WARNING: unclaimed {}".format(repr(self)), file=stderr) + +# We want @wraps to preserve docstrings, so the decorator must be a function, not a class. +# https://stackoverflow.com/questions/6394511/python-functools-wraps-equivalent-for-classes +# https://stackoverflow.com/questions/25973376/functools-update-wrapper-doesnt-work-properly#25973438 +def trampolined(function): + """Decorator to make a function trampolined. + + Trampolined functions can use ``return jump(f, a, ..., kw=v, ...)`` + to perform optimized tail calls. (*Optimized* in the sense of not + increasing the call stack depth, not for speed.) + """ + @wraps(function) + def decorated(*args, **kwargs): + f = function + while True: # trampoline + v = f(*args, **kwargs) + if isinstance(v, _jump): + if v.target is not SELF: # if SELF, then keep current target + f = v.target + args = v.args + kwargs = v.kwargs + v._claimed = True + else: # final result, exit trampoline + return v + # fortunately functions in Python are just objects; stash for jump constructor + decorated._entrypoint = function + return decorated + +def test(): + # tail recursion + @trampolined + def fact(n, acc=1): + if n == 0: + return acc + else: + return jump(fact, n - 1, n * acc) + assert fact(4) == 24 + + # tail recursion in a lambda + t = trampolined(lambda n, acc=1: + acc if n == 0 else jump(SELF, n - 1, n * acc)) + assert t(4) == 24 + + # mutual recursion + @trampolined + def even(n): + if n == 0: + return True + else: + return jump(odd, n - 1) + @trampolined + def odd(n): + if n == 0: + return False + else: + return jump(even, n - 1) + assert even(42) is True + assert odd(4) is False + assert even(10000) is True # no crash + + # explicit continuations - DANGER: functional spaghetti code! + @trampolined + def foo(): + return jump(bar) + @trampolined + def bar(): + return jump(baz) + @trampolined + def baz(): + raise RuntimeError("Look at the call stack, bar() was zapped by TCO!") + try: + foo() + except RuntimeError: + pass + + # trampolined lambdas in a letrec + from unpythonic.let import letrec + t = letrec(evenp=lambda e: + trampolined(lambda x: + (x == 0) or jump(e.oddp, x - 1)), + oddp=lambda e: + trampolined(lambda x: + (x != 0) and jump(e.evenp, x - 1)), + body=lambda e: + e.evenp(10000)) + assert t is True + + print("All tests PASSED") + + print("*** These two error cases SHOULD PRINT A WARNING:", file=stderr) + print("** No surrounding trampoline:", file=stderr) + def bar2(): + pass + def foo2(): + return jump(bar2) + foo2() + print("** Missing 'return' in 'return jump':", file=stderr) + def foo3(): + jump(bar2) + foo3() + + # loop performance? + n = 100000 + import time + + t0 = time.time() + for i in range(n): + pass + dt_ip = time.time() - t0 + + t0 = time.time() + @trampolined + def dowork(i=0): + if i < n: + return jump(dowork, i + 1) + dowork() + dt_fp1 = time.time() - t0 + + print("do-nothing loop, {:d} iterations:".format(n)) + print(" builtin for {:g}s ({:g}s/iter)".format(dt_ip, dt_ip/n)) + print(" @trampolined {:g}s ({:g}s/iter)".format(dt_fp1, dt_fp1/n)) + print("@trampolined slowdown {:g}x".format(dt_fp1/dt_ip)) + +if __name__ == '__main__': + test() diff --git a/unpythonic/fploop.py b/unpythonic/fploop.py index 928f45bd..bcf6c591 100644 --- a/unpythonic/fploop.py +++ b/unpythonic/fploop.py @@ -29,8 +29,14 @@ def iter(loop, i=0): from functools import partial -# can import from unpythonic.tco_exc instead to use the other implementation. -from unpythonic.tco import SELF, jump, trampolined, _jump +# TCO implementation switchable at runtime +import unpythonic.rc +if unpythonic.rc._tco_impl == "exc": + from unpythonic.tco import SELF, jump, trampolined, _jump +elif unpythonic.rc._tco_impl == "fast": + from unpythonic.fasttco import SELF, jump, trampolined, _jump +else: + raise ValueError("Unknown TCO implementation '{}'".format(unpythonic.rc._tco_impl)) from unpythonic.ec import call_ec @@ -58,7 +64,8 @@ def looped(body): but it also inserts the magic parameter ``loop``, which can only be set up via this mechanism. - Here **loop is a noun, not a verb**, because ``unpythonic.tco.jump`` is. + When using ``fasttco``, **loop is a noun, not a verb**, because + ``unpythonic.tco.jump`` is. Simple example:: @@ -203,10 +210,11 @@ def looped_over(iterable, acc=None): # decorator factory The return value of the loop is always the final value of ``acc``. - Here **loop is a noun, not a verb.** The expression ``loop(...)`` is - otherwise the same as ``jump(SELF, ...)``, but it also inserts the magic - parameters ``loop``, ``x`` and ``acc``, which can only be set up via - this mechanism. + The expression ``loop(...)`` is otherwise the same as ``jump(SELF, ...)``, + but it also inserts the magic parameters ``loop``, ``x`` and ``acc``, + which can only be set up via this mechanism. + + When using ``fasttco``, **loop is a noun, not a verb**, just like ``jump``. Examples:: diff --git a/unpythonic/tco.py b/unpythonic/tco.py index f38c2102..9deab8fe 100644 --- a/unpythonic/tco.py +++ b/unpythonic/tco.py @@ -1,244 +1,82 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Tail call optimization / explicit continuations. +"""Exception-based TCO. -Where speed matters, prefer the usual ``for`` and ``while`` constructs. -Where speed *really* matters, add Cython on top of those. And as always, -profile first. +This is a drop-in replacement for ``fasttco.py``. For full docs, see ``fasttco.py``. -**API reference**: +The only difference in the API is: - - Functions that use TCO must be ``@trampolined``. They are called just like - any normal function. + - ``jump`` **is now a verb**. No need for ``return jump(...)`` to denote + a tail call, a bare ``jump(...)`` will do. - - When inside a ``@trampolined`` function: + - Using ``return jump(...)`` does no harm, though, so do that if you want + your code to be compatible also with the ``fasttco`` implementation. - - ``f(a, ..., kw=v, ...)`` is just a normal call, no TCO. +If you use this implementation, then in ``fploop``, the magic ``loop`` +is a verb, too, because it is essentially a fancy wrapper over ``jump``. - - ``return jump(f, a, ..., kw=v, ...)`` is a tail call to *target* ``f``. +Be careful, the TCO implementations **cannot be mixed and matched**. - - `return` explicitly marks a tail position, naturally enforcing that - the caller finishes immediately after its purported *tail* call. - - - When done (no more tail calls to make), just return the final result normally. - - - In this module, **"jump" is a noun, not a verb**. - - - Returning a ``jump`` instance makes the trampoline perform the tail call. - - - ``jump(f, ...)`` by itself just evaluates to a jump instance, **doing nothing**. - - - If you're getting ``None`` instead of the result of your computation, - check for ``jump`` where it should be ``return jump``; and then check - that you're returning your final result normally. - - - **Lambdas welcome!** For example, ``trampolined(lambda x: ...)``. - - - Just keep in mind how Python expands the decorator syntax; the rest follows. - - - Use the special target ``SELF``, as in ``jump(SELF, ...)``, for tail recursion. - - - Just ``jump``, not ``return jump``, since lambdas do not use ``return``. - - - Or assign the lambda expressions to names; this allows also mutual recursion. - - - **Use only where TCO matters**. Stack traces will be hurt, as usual. - - - Tail recursion is a good use case; so is mutual recursion. Here TCO allows - elegantly expressed algorithms without blowing the stack. - - - Danger zone is if one applies TCO just because some call happens to be - in a tail position. This makes debugging a nightmare, since some entries - will be missing from the call stack. - - This implementation retains the original entry point - due to entering - the decorator ``trampolined`` - and the final one where the uncaught exception - actually occurred. Anything in between will have been zapped by TCO. - -**Notes**: - -Actually it is sufficient that the initial entry point to a computation using -TCO is ``@trampolined``. The trampoline keeps running until a normal value -(i.e. anything that is not a ``jump`` instance) is returned. That normal value -is returned to the original caller. - -The ``jump`` constructor automatically strips the target's trampoline, -if it has one - making sure this remains a one-trampoline party even if -the tail-call target is another ``@trampolined`` function. So just declare -anything using TCO as ``@trampolined`` and don't worry about stacking trampolines. - -SELF actually means "keep current target", so the last function that was -jumped to by name in that trampoline remains as the target. When the trampoline -starts, the current target is set to the initial entry point (also for lambdas). - -Beside TCO, trampolining can also be thought of as *explicit continuations*. -Each trampolined function tells the trampoline where to go next, and with what -parameters. All hail lambda, the ultimate GO TO! - -Based on a quick test, running a do-nothing loop with this is about 40-80x -slower than Python's ``for``. - -**Examples**:: - - # tail recursion - @trampolined - def fact(n, acc=1): - if n == 0: - return acc - else: - return jump(fact, n - 1, n * acc) - assert fact(4) == 24 - - # tail recursion in a lambda - t = trampolined(lambda n, acc=1: - acc if n == 0 else jump(SELF, n - 1, n * acc)) - assert t(4) == 24 - - # mutual recursion - @trampolined - def even(n): - if n == 0: - return True - else: - return jump(odd, n - 1) - @trampolined - def odd(n): - if n == 0: - return False - else: - return jump(even, n - 1) - assert even(42) is True - assert odd(4) is False - - # explicit continuations - DANGER: functional spaghetti code! - @trampolined - def foo(): - return jump(bar) - @trampolined - def bar(): - return jump(baz) - @trampolined - def baz(): - raise RuntimeError("Look at the call stack, where did bar() go?") - try: - foo() - except RuntimeError: - pass +Based on a quick test, running a do-nothing loop with this is about 150-200x +slower than Python's ``for``. Or in other words, the additional performance hit +(over ``fasttco``) is somewhere between 2-5x. """ -__all__ = ["SELF", "jump", "trampolined"] - from functools import wraps -from sys import stderr from unpythonic.misc import call +__all__ = ["jump", "trampolined", "SELF"] + @call # make a singleton -class SELF: # sentinel, could be any object but we want a nice __repr__. +class SELF: def __repr__(self): return "SELF" def jump(target, *args, **kwargs): - """A jump (noun, not verb). - - Used in the syntax `return jump(f, ...)` to request the trampoline to - perform a tail call. - - Instances of `jump` are not callable, and do nothing on their own. - This is just passive data. - - Parameters: - target: - The function to be called. The special value SELF means - tail-recursion; useful with a ``lambda``. When the target - has a name, it is legal to explicitly give the name also - for tail-recursion. - *args: - Positional arguments to be passed to `target`. - **kwargs: - Named arguments to be passed to `target`. - """ + """arg packer, public API. Invokes ``_jump``.""" return _jump(target, args, kwargs) -class _jump: - """The actual class representing a jump. +def _jump(target, args, kwargs): # implementation + """Jump (verb) to target function with given args and kwargs. - If you have already packed args and kwargs, you can instantiate this - directly; the public API just performs the packing. + Only valid when running in a trampoline. """ - def __init__(self, target, args, kwargs): - # IMPORTANT: don't let target bring along its trampoline if it has one - self.target = target._entrypoint if hasattr(target, "_entrypoint") else target - self.args = args - self.kwargs = kwargs - self._claimed = False # set when the instance is caught by a trampoline - - def __repr__(self): - return "<_jump at 0x{:x}: target={}, args={}, kwargs={}".format(id(self), - self.target, - self.args, - self.kwargs) + raise TrampolinedJump(target, args, kwargs) - def __del__(self): - """Warn about bugs in client code. +class TrampolinedJump(Exception): + """Exception representing a jump (noun). - Since it's ``__del__``, we can't raise any exceptions - which includes - things such as ``AssertionError`` and ``SystemExit``. So we print a - warning. + Raised by ``_jump``, caught by the trampoline. - **CAUTION**: - - This warning mechanism should help find bugs, but it is not 100% foolproof. - Since ``__del__`` is managed by Python's GC, some object instances may - not get their ``__del__`` called when the Python interpreter itself exits - (if those instances are still alive at that time). - - **Typical causes**: - - *Missing "return"*:: - - @trampolined - def foo(): - jump(bar, 42) - - The jump instance was never actually passed to the trampoline; it was - just created and discarded. The trampoline got the ``None`` from the - implicit ``return None`` at the end of the function. - - (See ``tco_exc.py`` if you prefer this syntax, without a ``return``.) - - *No trampoline*:: - - def foo(): - return jump(bar, 42) - - Here ``foo`` is not trampolined. - - We **have** a trampoline when the function that returns the jump - instance is itself ``@trampolined``, or is running in a trampoline - implicitly (due to having been entered via a tail call). + Prints an informative message if uncaught (i.e. if no trampoline). + """ + def __init__(self, target, args, kwargs): + if hasattr(target, "_entrypoint"): # strip target's trampoline if any + target = target._entrypoint - *Trampoline at the wrong level*:: + self.target = target + self.targs = args + self.tkwargs = kwargs - @trampolined - def foo(): - def bar(): - return jump(qux, 23) - bar() # normal call, no TCO + # Error message when uncaught + # (This can still be caught by the wrong trampoline, if an inner trampoline + # has been forgotten when nesting TCO chains - there's nothing we can do + # about that.) + self.args = ("No trampoline, attempted to jump to '{}', args {}, kwargs {}".format(target, + args, + kwargs),) - Here ``bar`` has no trampoline; only ``foo`` does. **Only** a ``@trampolined`` - function, or a function entered via a tail call, may return a jump. - """ - if not self._claimed: - print("WARNING: unclaimed {}".format(repr(self)), file=stderr) + def __repr__(self): + return "