Closed robrix closed 6 years ago
Upon reflection, I don’t think it’ll work for effects to take the effect list as a parameter, since that just moves the problem around again—that would mean that handling an effect would have to change the type of every effect in the list.
Upon reflection, I don’t think it’ll work for effects to take the effect list as a parameter, since that just moves the problem around again—that would mean that handling an effect would have to change the type of every effect in the list.
Upon further reflection, that’s exactly what we want—and we have the handler to do it, when we handle the effect, by definition.
Looks like Syntax and Semantics for Operations with Scopes by Maciej Piróg, Tom Schrijvers, Nicolas Wu, and Mauro Jaskelioff is exactly what we’re looking for here.
This came up most recently in the context of #46, but we’ve seen it in a few different contexts overall. I’m aware of three useful lenses on the problem, so I’ll try to detail each of them.
interpose
applies only to the effects inside it, not whatever effects they’re handled with.If you use resumable exceptions,
Yield
, or some effect-as-abstraction which hides the implementation details behind its requests & handlers, you have to be aware of the interactions with e.g.Exc
andReader
.catchError
andlocal
both useinterpose
to intercept requests, but only within the action passed to them at the time at which they were called.Thus,
local (+ (1 :: Int)) (send Foo)
will increment the context value for anyask
s or furtherlocal
inside the action it receives,send Foo
, and not whatever action handlessend Foo
. Since there are noReader
actions withinsend Foo
itself (onlyFoo
, whatever that is), it’s as if we hadn’t bothered usinglocal
at all.Effects inserted by handlers (
relay
, etc) are run in the context at which the handler is called, not in the context of the effects they’re handling; or, handlers can inject values, but not effects, into effectful actions.This is easiest to demonstrate by zooming out a bit from the
local
handler described above to encompass theFoo
andReader
handlers as well:The
relay
handlingFoo
is free to sendReader
requests, sinceReader
is handled above it. However, these requests are made in the contextrelay
is called at, which only has the top-levelrunReader
handler present, and not where theFoo
effect is sent. Thus, the result oftestFoo
will be0
, despite thelocal
increment—sending theFoo
effect escapes from thelocal
context and there’s no way to un-escape.This is the case even tho we’re calling
yield
to provide theInt
back to the sending context—so we can inject values back in, but not effects. That takes us to the third angle on this.Higher-order effects are inflexible.
The one exception I’m aware of to the rule that handlers can inject values but not effects is if we have what I’m terming a higher-order effect, i.e. an effect whose result is an effectful action. We can define
Foo
thus by changing its result type:Rather than encoding a request for an
Int
,Foo
is now a request for anEff
producing anInt
. Since we’ve changed the result type ofFoo
, we must also change its handler and requests:Note that we’re
yield
ing theask
action (rather than its result), andjoin
ing the result of thesend
. Now, the result ofrun testFoo
is (correctly)1
, which is exactly the behaviour we want. Furthermore, it’s clear that the change to the semantics of the request are the only sensible way of injecting effects into an action; the effect has to specify that they’ll occur, the code making the request has to request andjoin
them, and the handler has to satisfy that request with an effectful action.Unfortunately, this comes at a significant cost to flexibility.
Foo
’s result type enumerates all of the effects it’s able to perform. This same problem exists withEmbedded
, which makes it easier to encode a smaller list of effects, but in no way addresses the need to enumerate them. (Furthermore, asEmbedded
requests hold anEff
and return ana
, they’re only able to express the passing of an action to a handler, rather than the handling of an effect by passing an action back.)This can pose a serious barrier to implementation, since we have to be careful not to mention the effects list itself, i.e. the naïve
Member (Reader (Eff effects Int)) effects
constraint we would wish to be able to employ is unsolvable due its request for the occurrence ofeffects
withineffects
—and so we can’t actually handle the effect.The reason becomes obvious when you try to give a type for the handler: we start with
Eff (Reader _ ': effects) a -> Eff effects a
, and then try to fill in the hole. But since it’s self-referential, we have to fill it in withEff (Reader _ ': effects) Int
, and then again, ad infinitum. Attempts to resolve this with a type equality constraint oneffects
also fail, and while it’s not explicitly mentioned, I believe it’s due to the occurs check.When needing to break a cycle like this, the obvious solution is to employ a
newtype
, which we can indeed use successfully here. However, this is really just moving the problem around. While something like this:does enable us to write the handler, now the actions making requests have to be aware that a) they need to use & unwrap
Inner
, and b) that it’s at the head of the effect list (a constraint imposed byInner
itself). While the former problem is quite minor, the latter means that they instantly become very inflexible, and can’t easily be run in different contexts.These three problems—really the same problem—are the cause of significant complexity in our use cases. I think we might be able to make some headway by having
Eff
provide effects with the effect list as a parameter, but I’m not at all certain of that.It’s also entirely possible that this is just a reality of using this system. In that case, we should at least document some best practices for handling these sorts of situations.