Kevin Reid
2014-10-11 03:21:17 UTC
This came out of Friam today. Hasty but complete writeup follows. BCC'd to the
friam list for archival/notification purposes but I want the discussion to
happen on e-lang because this is a very E thing.
Introduction
------------
The problem is reconciling the requirements of synchronous and asynchronous
error propagation/reporting/handling. We are concerned with those errors which
are _handled_ by application code in some way, as opposed to those which are
treated as 'Something unexpected happened, stop.'
-- Sealed Exceptions --
Years ago, I proposed that conventional thrown/caught exceptions, as E had at
the time, were an unacceptable leak: one component's descriptive error message
is another's secret leak, if the latter doesn't catch all exceptions from the
former.
The fix I proposed and prototyped, "sealed exceptions" said that when you
'throw(X)', the object you get from a (dynamically) enclosing 'catch' is not
X, but a sealed-box of X: nobody can unseal it except for someone with
debugging access to the vat. The box itself provides no information except the
implicit "something unspecified and unexpected happened".
-- Ejectors --
This means that you can't use exceptions to report errors, so what do you do
instead? Use ejectors. To recap, an ejector is an object created by the
'escape' syntax:
escape ej {
...
ej('oops')
...
}
The expression "escape ej { ... }" binds "ej" within its block to an ejector.
If the ejector is called before the block has been exited normally, then a
non-local exit occurs and the ejector returns the value passed to the ejector.
(There is an optional 'catch' block to distinguish this exit from normal
returns.)
This can be used to handle errors in this sort of way:
escape notFound {
value := things.lookup(key, notFound)
} catch _ {
value := makeNewThing()
}
-- Promises --
The problem with the above is that it doesn't help at all with eventual
(asynchronous) programming.
The original form of promise error handling was that when a message was
received and delivered to a near (local) object, the local call operation
would be effectively wrapped in a try-catch, something like:
def deliver(resolver, target, argument) {
try {
resolver.resolve(target(argument)) # obviously a more general
# call, actually
} catch aProblem {
resolver.smash(aProblem)
}
}
However, sealed exceptions says that aProblem is one of those uninformative
sealed objects, so this mechanism doesn't work for _handled_ errors any more.
We can't use ejectors in this case, either, because an ejector is always
invalid by the time a remote object receives it as a parameter, since the
block has long since been exited.
-- The bad idea --
However, we can imagine passing an ejector-like object. Verbosely in current
E:
def [valuePromise, valueResolver] := Ref.promise()
def notFound() {
valueResolver.smash('not found')
throw('this is just to abort execution')
}
when (def successfulValue := things <- lookup(key, notFound)) -> {
valueResolver.resolve(successfulValue)
}
The problem with this pattern is that it needs some syntactic sugar to make it
sanely usable. My older notion was to provide a special part of the
eventual-send syntax which did this:
def valuePromise := things <- lookup(key, <!>)
The <!> can occur only within an eventual send's arguments list and is
essentially bound to "resolver.smash" of the implicitly constructed promise.
The problem with _this_ is that it's just plain ugly -- particularly, it
introduces a new syntactic element which is associated with "the nearest
enclosing X" (where X is "eventual send" in this case), which is bad in
principle.
Today's idea
------------
Suppose that we're willing to admit named parameters as a basic part of the
language. (I want to do this anyway for reasons which I should write up
separately.)
In that case, we can say that there is a specific parameter name (it can be a
distinct object, not a string) set aside for "the failure handler". Let's call
this parameter "FAIL" for now. There are two special features:
1. Every eventual send has a "Miranda" FAIL argument bound to resolver.smash
as discussed above.
2. Every normal function call has a "Miranda" FAIL argument bound to the
'throw' function.
3. Every method has a "Miranda" FAIL parameter which is ignored.
(Miranda: "If you do not have one, one will be provided for you.")
Therefore, any method which wishes to report a handleable failure can use
FAIL:
def things {
to lookup(key, => FAIL) {
...
FAIL('not found')
...
}
}
(E syntax reminder: "=> foo" is shorthand for a named map-entry (or parameter)
whose key is "foo" and is bound to foo. We'd need to refine this if the FAIL
name is not a string...)
And in order to call it asynchronously and get the failure back, you don't
need to do anything at all, because the implicit FAIL handles it:
def valuePromise := things <- lookup(key)
# valuePromise will be broken with 'not found' if this fails
To do synchronous error handling, you don't have to do much more than
ejector-styled error handling requires:
escape notFound {
value := things.lookup(key, FAIL => notFound)
} ...
If you want to simply propagate an error synchronously, it's:
def wrapper(key, => FAIL) {
return things.lookup(key, => FAIL)
}
which is just like current ejectors except with a special name for the ejector
argument.
The asynchronous version is actually simpler, because we can just return the
promise:
def wrapper(key) {
return things <- lookup(key)
}
The fact that the two "just a wrapper" cases have different amounts of
explicitness is a bit alarming, I admit. But I think this is fundamental to
the fact that, if you think of synchronous operations as an _extension_ to the
eventual computation semantics, it comes about from the fact that synchronous
programming distinguishes failures _now_ (throw/eject) from failures that
already, or haven't yet, happened and you're merely passing around a broken
promise.
(And in E, don't we want to encourage asynchronous programming, anyway?)
As I said, I just had this idea today, but so far it seems less horrible than
the alternatives.
friam list for archival/notification purposes but I want the discussion to
happen on e-lang because this is a very E thing.
Introduction
------------
The problem is reconciling the requirements of synchronous and asynchronous
error propagation/reporting/handling. We are concerned with those errors which
are _handled_ by application code in some way, as opposed to those which are
treated as 'Something unexpected happened, stop.'
-- Sealed Exceptions --
Years ago, I proposed that conventional thrown/caught exceptions, as E had at
the time, were an unacceptable leak: one component's descriptive error message
is another's secret leak, if the latter doesn't catch all exceptions from the
former.
The fix I proposed and prototyped, "sealed exceptions" said that when you
'throw(X)', the object you get from a (dynamically) enclosing 'catch' is not
X, but a sealed-box of X: nobody can unseal it except for someone with
debugging access to the vat. The box itself provides no information except the
implicit "something unspecified and unexpected happened".
-- Ejectors --
This means that you can't use exceptions to report errors, so what do you do
instead? Use ejectors. To recap, an ejector is an object created by the
'escape' syntax:
escape ej {
...
ej('oops')
...
}
The expression "escape ej { ... }" binds "ej" within its block to an ejector.
If the ejector is called before the block has been exited normally, then a
non-local exit occurs and the ejector returns the value passed to the ejector.
(There is an optional 'catch' block to distinguish this exit from normal
returns.)
This can be used to handle errors in this sort of way:
escape notFound {
value := things.lookup(key, notFound)
} catch _ {
value := makeNewThing()
}
-- Promises --
The problem with the above is that it doesn't help at all with eventual
(asynchronous) programming.
The original form of promise error handling was that when a message was
received and delivered to a near (local) object, the local call operation
would be effectively wrapped in a try-catch, something like:
def deliver(resolver, target, argument) {
try {
resolver.resolve(target(argument)) # obviously a more general
# call, actually
} catch aProblem {
resolver.smash(aProblem)
}
}
However, sealed exceptions says that aProblem is one of those uninformative
sealed objects, so this mechanism doesn't work for _handled_ errors any more.
We can't use ejectors in this case, either, because an ejector is always
invalid by the time a remote object receives it as a parameter, since the
block has long since been exited.
-- The bad idea --
However, we can imagine passing an ejector-like object. Verbosely in current
E:
def [valuePromise, valueResolver] := Ref.promise()
def notFound() {
valueResolver.smash('not found')
throw('this is just to abort execution')
}
when (def successfulValue := things <- lookup(key, notFound)) -> {
valueResolver.resolve(successfulValue)
}
The problem with this pattern is that it needs some syntactic sugar to make it
sanely usable. My older notion was to provide a special part of the
eventual-send syntax which did this:
def valuePromise := things <- lookup(key, <!>)
The <!> can occur only within an eventual send's arguments list and is
essentially bound to "resolver.smash" of the implicitly constructed promise.
The problem with _this_ is that it's just plain ugly -- particularly, it
introduces a new syntactic element which is associated with "the nearest
enclosing X" (where X is "eventual send" in this case), which is bad in
principle.
Today's idea
------------
Suppose that we're willing to admit named parameters as a basic part of the
language. (I want to do this anyway for reasons which I should write up
separately.)
In that case, we can say that there is a specific parameter name (it can be a
distinct object, not a string) set aside for "the failure handler". Let's call
this parameter "FAIL" for now. There are two special features:
1. Every eventual send has a "Miranda" FAIL argument bound to resolver.smash
as discussed above.
2. Every normal function call has a "Miranda" FAIL argument bound to the
'throw' function.
3. Every method has a "Miranda" FAIL parameter which is ignored.
(Miranda: "If you do not have one, one will be provided for you.")
Therefore, any method which wishes to report a handleable failure can use
FAIL:
def things {
to lookup(key, => FAIL) {
...
FAIL('not found')
...
}
}
(E syntax reminder: "=> foo" is shorthand for a named map-entry (or parameter)
whose key is "foo" and is bound to foo. We'd need to refine this if the FAIL
name is not a string...)
And in order to call it asynchronously and get the failure back, you don't
need to do anything at all, because the implicit FAIL handles it:
def valuePromise := things <- lookup(key)
# valuePromise will be broken with 'not found' if this fails
To do synchronous error handling, you don't have to do much more than
ejector-styled error handling requires:
escape notFound {
value := things.lookup(key, FAIL => notFound)
} ...
If you want to simply propagate an error synchronously, it's:
def wrapper(key, => FAIL) {
return things.lookup(key, => FAIL)
}
which is just like current ejectors except with a special name for the ejector
argument.
The asynchronous version is actually simpler, because we can just return the
promise:
def wrapper(key) {
return things <- lookup(key)
}
The fact that the two "just a wrapper" cases have different amounts of
explicitness is a bit alarming, I admit. But I think this is fundamental to
the fact that, if you think of synchronous operations as an _extension_ to the
eventual computation semantics, it comes about from the fact that synchronous
programming distinguishes failures _now_ (throw/eject) from failures that
already, or haven't yet, happened and you're merely passing around a broken
promise.
(And in E, don't we want to encourage asynchronous programming, anyway?)
As I said, I just had this idea today, but so far it seems less horrible than
the alternatives.
--
Kevin Reid <http://switchb.org/kpreid/>
Kevin Reid <http://switchb.org/kpreid/>