Skip to content

Commit adf2b30

Browse files
committed
add call/ec
1 parent 4e0056c commit adf2b30

File tree

4 files changed

+207
-3
lines changed

4 files changed

+207
-3
lines changed

README.md

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,9 @@ def f():
553553
f() # --> 15
554554
```
555555

556-
In Lisp terms, `@setescape` essentially captures the escape continuation (ec) of the function decorated with it. The nearest (dynamically) surrounding ec can then be invoked by `raise escape(value)`. The escaped function immediately terminates, returning ``value``.
556+
**CAUTION**: Because the implementation is based on exceptions, catch-all ``except:`` statements will intercept also ``escape`` instances, breaking the escape mechanism. As you already know, be specific in what you catch!
557+
558+
In Lisp terms, `@setescape` essentially captures the escape continuation (ec) of the function decorated with it. The nearest (dynamically) surrounding ec can then be invoked by `raise escape(value)`. The escaped function immediately terminates, returning ``value``. In Python terms, an escape means just raising a specific type of exception; the usual rules concerning ``try``/``except``/``else``/``finally`` and ``with`` blocks apply.
557559

558560
To make this work with lambdas, and for uniformity of syntax, **in trampolined functions** (such as FP loops) it is also legal to ``return escape(value)``. The trampoline specifically detects `escape` instances, and performs the ``raise``.
559561

@@ -594,6 +596,67 @@ def f():
594596
assert f() == "hello from g"
595597
```
596598

599+
#### First-class escape continuations (call/ec)
600+
601+
We provide also ``call/ec`` (``call-with-escape-continuation``), in Python spelled as ``call_ec``. It's a decorator that, like ``@immediate``, immediately runs the function and replaces the def'd name with the return value. The twist is that it internally sets up an escape point, and hands over a **first-class escape continuation** to the callee.
602+
603+
The function to be decorated **must** take one positional argument, the ec instance.
604+
605+
The ec itself is another function, which takes one positional argument: the value to send to the escape point. Both the ec instance and the escape point are tagged with a temporary process-wide unique id that connects them. (Untagged ``@setescape`` points may still catch this escape; this may be subject to change in a later version.)
606+
607+
A particular ec instance is only valid inside the dynamic extent of the ``call_ec`` invocation that created it. Attempting to call it later raises ``RuntimeError``.
608+
609+
The above caution about catch-all ``except:`` statements applies here, too.
610+
611+
```python
612+
@call_ec
613+
def result(ec): # effectively, just a code block, capturing the ec as an argument
614+
answer = 42
615+
ec(answer) # here this has the same effect as "return answer"...
616+
print("never reached")
617+
answer = 23
618+
return answer
619+
assert result == 42
620+
621+
@call_ec
622+
def result(ec):
623+
answer = 42
624+
def inner():
625+
ec(answer) # ...but here this directly escapes from the outer def
626+
print("never reached")
627+
return 23
628+
answer = inner()
629+
print("never reached either")
630+
return answer
631+
assert result == 42
632+
```
633+
634+
This also works with lambdas, by using ``call_ec()`` directly:
635+
636+
```python
637+
result = call_ec(lambda ec:
638+
begin(print("hi from lambda"),
639+
ec(42),
640+
print("never reached")))
641+
assert result == 42
642+
```
643+
644+
Normally ``begin()`` would return the last value, but the ec overrides that; it is effectively a ``return`` for multi-expression lambdas!
645+
646+
And named functions, why not those too:
647+
648+
```python
649+
def f(ec):
650+
print("hi from f")
651+
ec(42)
652+
print("never reached")
653+
654+
# ...possibly somewhere else, possibly much later...
655+
656+
result = call_ec(f)
657+
assert result == 42
658+
```
659+
597660
### Dynamic scoping
598661

599662
A bit like global variables, but slightly better-behaved. Useful for sending some configuration parameters through several layers of function calls without changing their API. Best used sparingly. Similar to [Racket](http://racket-lang.org/)'s [`parameterize`](https://docs.racket-lang.org/guide/parameterize.html).

quick_tour.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,26 @@ def g():
8484
return False
8585
assert f() == "hello from g"
8686

87+
# lispy call/ec (call-with-escape-continuation)
88+
@call_ec
89+
def result(ec):
90+
answer = 42
91+
def inner():
92+
ec(answer) # this directly escapes from the outer def
93+
print("never reached")
94+
return 23
95+
answer = inner()
96+
print("never reached either")
97+
return answer
98+
assert result == 42
99+
100+
# begin() returns the last value. What if we don't want that?
101+
result = call_ec(lambda ec:
102+
begin(print("hi from lambda"),
103+
ec(42), # now we can effectively "return ..." at any point from a lambda!
104+
print("never reached")))
105+
assert result == 42
106+
87107
# dynamic scoping
88108
def f1(): # no "a" in lexical scope here
89109
assert dyn.a == 2

tour.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
let, letrec, dlet, dletrec, blet, bletrec, \
1111
immediate, begin, begin0, lazy_begin, lazy_begin0, \
1212
trampolined, jump, looped, looped_over, SELF, \
13-
setescape, escape
13+
setescape, escape, call_ec
1414

1515
def dynscope_demo():
1616
assert dyn.a == 2
@@ -385,5 +385,34 @@ def s(loop, acc=0, i=0):
385385
return False
386386
assert foo() == 15
387387

388+
# lispy call/ec (call-with-escape-continuation)
389+
@call_ec
390+
def result(ec): # effectively, just a code block!
391+
answer = 42
392+
ec(answer) # here this has the same effect as "return answer"...
393+
print("never reached")
394+
answer = 23
395+
return answer
396+
assert result == 42
397+
398+
@call_ec
399+
def result(ec):
400+
answer = 42
401+
def inner():
402+
ec(answer) # ...but here this directly escapes from the outer def
403+
print("never reached")
404+
return 23
405+
answer = inner()
406+
print("never reached either")
407+
return answer
408+
assert result == 42
409+
410+
# begin() returns the last value. What if we don't want that?
411+
result = call_ec(lambda ec:
412+
begin(print("hi from lambda"),
413+
ec(42), # now we can effectively "return ..." at any point from a lambda!
414+
print("never reached")))
415+
assert result == 42
416+
388417
if __name__ == '__main__':
389418
main()

unpythonic/ec.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# -*- coding: utf-8 -*-
33
"""Escape continuations."""
44

5-
__all__ = ["escape", "setescape"]
5+
__all__ = ["escape", "setescape", "call_ec"]
66

77
from functools import wraps
88

@@ -104,6 +104,58 @@ def decorated(*args, **kwargs):
104104
return decorated
105105
return decorator
106106

107+
def call_ec(f):
108+
"""Decorator. Call with escape continuation (call/ec).
109+
110+
Parameters:
111+
`f`: function
112+
The function to call. It must take one positional argument,
113+
the first-class escape continuation (ec).
114+
115+
The ec, in turn, takes one positional argument; the value
116+
to send to the escape point (i.e. the return value of ``f``).
117+
118+
Both the ec and the escape point are tagged with a temporary
119+
process-wide unique id.
120+
121+
Like in ``@immediate``, the function ``f`` is called immediately,
122+
and the def'd name is replaced by the return value of ``f(ec).``
123+
124+
This can also be used directly, to provide a "return" for multi-expression
125+
lambdas::
126+
127+
from unpythonic.misc import begin
128+
result = call_ec(lambda ec:
129+
begin(print("hi from lambda"),
130+
ec(42),
131+
print("never reached")))
132+
assert result == 42
133+
"""
134+
# We need a process-wide unique id to tag the ec:
135+
anchor = object()
136+
uid = id(anchor)
137+
# Closure property important here. "ec" itself lives as long as someone
138+
# retains a reference to it. It's a first-class value; the callee could
139+
# return it or stash it somewhere.
140+
#
141+
# So we must keep track of whether we're still inside the dynamic extent
142+
# of the call/ec - i.e. whether we can still catch the escape exception
143+
# if it is raised.
144+
ec_valid = True
145+
# First-class ec like in Lisps. What's first-class in Python? Functions!
146+
def ec(value):
147+
if not ec_valid:
148+
raise RuntimeError("Cannot escape after the dynamic extent of the call_ec invocation.")
149+
raise escape(value, uid)
150+
try:
151+
@setescape(uid) # Set up a tagged escape point here and call f.
152+
def wrapper():
153+
return f(ec)
154+
return wrapper()
155+
finally: # Our dynamic extent ends; this ec instance is no longer valid.
156+
# Clear the flag (it will live on in the closure of the ec instance).
157+
ec_valid = False
158+
107159
def test():
108160
# "multi-return" using escape continuation
109161
@setescape()
@@ -115,6 +167,46 @@ def g():
115167
print("not reached either")
116168
assert f() == "hello from g"
117169

170+
# lispy call/ec (call-with-escape-continuation)
171+
@call_ec
172+
def result(ec): # effectively, just a code block!
173+
answer = 42
174+
ec(answer) # here this has the same effect as "return answer"...
175+
print("never reached")
176+
answer = 23
177+
return answer
178+
assert result == 42
179+
180+
@call_ec
181+
def result(ec):
182+
answer = 42
183+
def inner():
184+
ec(answer) # ...but here this directly escapes from the outer def
185+
print("never reached")
186+
return 23
187+
answer = inner()
188+
print("never reached either")
189+
return answer
190+
assert result == 42
191+
192+
try:
193+
@call_ec
194+
def erroneous(ec):
195+
return ec
196+
erroneous(42) # invalid, dynamic extent of the call_ec has ended
197+
except RuntimeError:
198+
pass
199+
else:
200+
assert False
201+
202+
# begin() returns the last value. What if we don't want that?
203+
from unpythonic.misc import begin
204+
result = call_ec(lambda ec:
205+
begin(print("hi from lambda"),
206+
ec(42), # now we can effectively "return ..." at any point from a lambda!
207+
print("never reached")))
208+
assert result == 42
209+
118210
# tests with @looped in tco.py to prevent cyclic dependency
119211

120212
print("All tests PASSED")

0 commit comments

Comments
 (0)