Daan Leijen
Microsoft Research |
Jonathan Brachthäuser
Universität Tübingen
|
We show three new language features, namely ambient values, functions, and control.
All three can be expressed by algebraic effect handlers.
We argue that these concepts merit study by themselves to make it easier to reason about programs and provide efficient implementations.
ambients relate to effect handlers as “while” to “goto” ;-)
We assume a pretty printing library that produces pretty strings from documents:
fun pretty( d : doc ) : string
Unfortunately, it has a hard-coded maximum display width deep within the code:
... if (line.length ≤ 78) then ...
To abstract over the maximum display width, we can:
We need something that is neither global nor local.
We can declare the type of an explicit value as:
ambient val width : int
and can refer to it deep inside the library:
... if (line.length ≤ width) then ...
The type system tracks the use of ambient values in row types denoted between angled brackets:
fun pretty( d : doc ) : 〈width〉 string
signifying that pretty
can only be used in a context that
binds the ambient width
value.
Ambient values are bound using with
expressions:
fun pretty-thin( d : doc ) : 〈〉 string {
with val width = 40 in pretty(d)
}
Here the ambient value width
is dynamically bound to 40
for the
dynamic extent evaluating the body expression pretty(d)
. The scope of the
with
-binder is not limited to its lexical closure.
There is also a statement form of the with
expression that scopes
over the rest of the current lexical block scope. Using this form we can write
the previous example as:
fun pretty-thin(d) {
with val width = 40
pretty(d)
}
we can also re-bind ambient values. For example we might want to pretty print part of a document in half the available display width:
fun pretty-wide( d : doc ) : 〈width〉 string {
with val width = width * 2
pretty(d)
}
Here, the type of pretty-wide
reflects that even though it binds
width
, it also still depends on a width
binding in its own context.
Where an ambient binding finds its bound value in the dynamic evaluation context (i.e. the stack). The innermost binding is used due to the side condition which ensures that is not bound in itself.
The semantics for ambient values as exactly the same as of dynamic binding described by Moreau (1998) and Kiselyov, Shan, and Sabry (2006).
We can see that ambient values cannot be freely α-renamed, as they are bound dynamically at evaluation time by the innermost binding. Consider the following function:
fun scope() {
with val width = 78
val g = fun() { with val width = 80 in width + 1 }
val h = with val width = 80 in (fun(){ width + 1 })
g().println
h().println
}
Of course we can bind functions as an ambient value. For example,
ambient val emit-naive : ((s : string) → 〈〉 ())
where the pretty printing library functions can use this to emit partial output:
...
match(doc) {
Text(s) → emit-naive(s)
Unfortunately, the ambient value declaration has a type signature that severely limits the number of possible implementations. For example, we might want to use the display width in an implementation like:
with val emit-naive = (fun(s : string){ ... s.truncate(width) ... })
...
We can of course change the type signature of the value declaration to:
ambient val emit-naive : ((s : string) → 〈width〉 ())
But now the signature exposes accidental details of our particular
implementation. Even worse, it might not be the desired semantics, since
the width is dynamically bound at the call site of emit-naive
, while
we usually want to bind it at the definition site and encapsulate this
in the definition of emit-naive
.
Not only ambient bindings are resolved at the call-site – the same problem extends to other side-effects:
with val emit-naive = (fun(s){ println(s) })
What if println
happens to throw an exception?
The effects and ambient bindings still leak into the calling context.
What we need instead are ambient functions: such functions are bound dynamically, but evaluated in the lexical context of their binding. Similar to ambient values, we can declare an ambient function as:
ambient fun emit( s : string ) : ()
There is no syntactic difference between calling an ambient functions
and calling any other function. The inferred type of pretty
now becomes:
fun pretty( d : doc ) : 〈width,emit〉 ()
fun pretty-thin(d) {
with val width = 40
with fun emit(s) { println(s.truncate(width)) }
pretty(d)
}
Note that emit
's body executes in the
lexical context of pretty-thin
and not in its calling context.
width
in the body of emit
is now
always resolved to 40 no matter if width
is rebound inside pretty
.
println
is handled by the innermost
exception handler around pretty-thin
, not by some handler inside pretty
.
In contrast to the ambient value rule, we first evaluate the function body with substituted by outside the calling evaluation context , and only after that resume in the original context with the result .
Continuing with our example, we may want to collect all output using local mutable state:
fun emit-collect(action) {
var out := ""
with fun emit(s) { out := out + s + "\n" } in action()
out
}
Note how we fully encapsulated the local state.
Both the action
and emit-collect
do not have observable
state effects.
Effectively, the ambient provides a secret channel to mutate state in the local lexical scope.
Example from Lewis, Launchbury, Meijer, and Shields (2000).
alias vertex = int
type graph { ... }
type rose { Rose(v : vertex, sub : list〈rose〉 ) }
ambient fun marked( v : vertex ) : bool
ambient fun mark( v : vertex ) : ()
ambient fun children( v : vertex ) : list〈vertex〉
fun dfs( g : graph, vs : list〈vertex〉 ) : list〈rose〉 {
var visited := vector(g.bound,False)
with fun children(v) { g.gchildren(v) }
with fun marked(v) { visited[v] }
with fun mark(v) { visited[v] := True }
dfs-loop(vs)
}
fun dfs-loop( vs : list〈vertex〉 ) {
match(vs) {
Nil → Nil
Cons(v,vv) →
if (marked(v)) then dfs-loop(vv) else {
mark(v)
val sub = dfs-loop( children(v) )
Cons( Rose(v,sub), dfs-loop(vv) )
}
}
}
The type of dfs-loop
reflects that it only depends on the
declared ambient functions and has no other side effects:
fun dfs-loop( vs : list〈vertex〉 ) : 〈mark,marked,children〉 list〈rose〉
In contrast, Lewis et al. (2000) bind functions
by value and thus leak the side-effects of the particular
implementation that uses mutable state into the dfs-loop
function.
The loop needs to be written in a monadic style and has the type:
dfsLoop :: (?children :: Graph → [Vertex],
?marked :: Vertex → ST s Bool,
?mark :: Vertex → ST s ()) ⇒ [Vertex] → ST s [Rose]
where the ST
effect of the operations leaks into the definition
of dfsLoop
.
Ambient control functions are a further extension to ambient
functions that return to lexical scope of their definition instead –
similar to how exceptions “return” to their innermost try
block. For
example, let's extend our pretty printing example to stop once a certain
amount of output has been produced:
ambient control stop() : ()
fun pretty-stop(d) {
with control stop() { "" }
with emit-collect
pretty-thin(d)
}
Ambient control also subsumes exception handling, where we can define
an ambient control function throw
. For example, here is a function that
transforms an exception throwing action to a maybe
result:
ambient control throw(msg : string) : ()
fun to-maybe( action : () → 〈throw〉 a ) : maybe〈a〉 {
with control throw(msg) { Nothing }
Just(action())
}
ambient control emit( s : string ): ()
fun emit-collect(action) {
var out := ""
with control emit(s) {
out := out + s + "\n"
if (out.length ≥ 100) then () else resume(())
} in action()
out
}
As another example, we can use the resumption to implement backtracking by calling it multiple times:
ambient control choice() : bool
fun amb( action : () → 〈choice|e〉 string ) : e list〈string〉 {
with control choice() { resume(True) + resume(False) }
[action()]
}
Now, we can use choice
in the pretty printing library to
produce all possible layouts. For example, if
fun f() : list〈string〉 {
with amb
with emit-collect
emit("hi")
if (choice()) then emit("world") else emit("universe")
}
then f()
returns:
["hi\nworld\n","hi\nuniverse\n"]
First we α-rename such that local variables are uniquely
named. Every binding of a local variable s
of some type τ
is
then translated to a function application of a locals
function:
var s : τ := init ~> locals(init,fun(){ ... })
...
and occurrences of s
are translated to
either gets
or sets
:
ambient val gets : τ
ambient control sets(x : τ) : ()
s := expr ~> sets(expr)
s ~> gets
This is similar to the translation by Kammar and Pretnar (2017) from mutable dynamic binding to effect handlers.
ambient val gets : τ
ambient control sets(x : τ) : ()
fun locals(init,action) {
with val gets = init
with control sets(x) { mask〈gets〉{
with val gets = x
resume(())
}
}
action()
}
This encoding is the same as described by Biernacki, Piróg, Polesiuk, and Sieczkowski (2017).
fun escape() {
var s := 0
(fun() { s := s + 1; s }) }
Transforms to:
fun escape() {
locals(0,fun(){
(fun() { sets(gets() + 1; gets() })
}) }
it becomes clear that the type of the result function becomes
() → 〈gets,sets〉 int
, i.e. it is impossible to use
this function as it depends on two (unique and hidden) ambients
that cannot be bound by the user.
It is safe to assign default implementations to ambient values and functions (but not control!)
ambient fun utc() : leapseconds = { [(2017,1,1), ..., (1972,1,1)] }
If main
still has unresolved ambients, it binds them implicitly
using the default definitions. Due to the unspecified order, the
definitions must be total and not control functions.
ambient state〈s〉 {
fun get() : s
fun set(x:s) : ()
}
fun state(init : s, action : () → 〈state〈s〉|e〉 a) : e a {
var s := init
with {
fun get() { s }
fun set(x) { s := x }
}
action()
}
ambient heap { }
val heap = with〈heap〉{ }
ambient instance ref〈a〉 in heap {
fun get() : a // (ref〈a〉) → 〈heap,exn〉 a
fun set( value : a ) : ()
}
fun new-ref(init : a, action : (ref〈a〉 → e b)) : e b {
var s := init
with r = instance {
fun get() { s }
fun set(x) { s := x }
}
action(r)
}
fun main() {
with heap
with r1 = new-ref(40)
with r2 = new-ref("hi")
println( r1.get() + r2.get().count)
}
syntax note:
with x = f(x1,...,xn) ~> f(x1,...,xn,fun(x){ 〈body〉 })
〈body〉
fun escape() {
with x = new-ref(42)
x
}
fun main() {
with heap
val y = escape()
println( y.get() )
}
The y.get()
raises an exception as it is outside the
scope of the new-ref
.
ambient heap {
control new-ref(init:a) : ref〈a〉
}
val heap = with control new-ref(init){ withref(init,resume) } // Yeah!
fun withref(init,action) {
var s := init
with r = instance {
fun get() { s }
fun set(x) { s := x }
}
action(r)
}
fun escape() {
val x = new-ref(42)
x
}
fun main() {
with heap
val y = escape()
println( y.get() )
}
daan@microsoft.com
Expressions | ||||
Values | ||||
Dynamic Binding | ::= | |||
Evaluation Context | ::= |
if |
if | |||
if |
with fresh
Types | ||||
Ambient Row | ||||
Constants | ::= | … | ||
Type Environment | ||||
Ambient Signature |
Definition 1.
A term is -stuck if it has the form where .
Theorem 1.
If and is not a value and not -stuck,
then for some term .
Theorem 2.
If then
is not -stuck.
Theorem 3.
If and is not a value,
then for some term .
Theorem 4.
If , then .
Theorem 5.
If then