Is it possible to reconcile UnliftIO and continuations?
First, there is a higher-order effect for UnliftIO
that can be used: Data.Effect.Unlift.UnliftIO. When the UnliftIO higher-order effect is in the effect list, users can use withRunInIO
.
On the other hand, handlers involving continuations, such as runNonDet
, runThrow
, or runState
, require that the list of higher-order effects be empty when they’re used: Control.Effect.Interpreter.Heftia.Except.runThrow
runThrow :: Eff '[] (Throw e ': r) a -> Eff '[] r (Either e a)
(I’ve simplified the type signatures for clarity.)
This is the limitation. The UnliftIO higher-order effect remains in the list until the very end, when runUnliftIO
is executed, which prevents it from being used alongside continuation-handling effects: Control.Effect.Interpreter.Heftia.Unlift.UnliftIO
runUnliftIO :: Eff '[UnliftIO] '[IO] a -> IO a
The only exception is the Shift_
effect. This is the limit of what Heftia (and probably higher-order effects in general) can do to relax these restrictions and allow higher-order effects and delimited continuations to be used together.
It functions even when higher-order effects remain in the list, and when used alongside higher-order effects like withRunInIO
, it behaves such that reset (in the sense of delimited continuations, shift/reset) is applied in places where it is ‘principally impossible to escape the scope.’ This is quite advanced and tricky, but it behaves logically, so advanced users should be able to use it in meaningful ways. Example of Shift_
in use.
Handlers that don’t require continuations, like runReader
, or those using runStateIORef
, runThrowIO
, or runNonDetForkIO
, which rely on the IO monad internally, can be used even if higher-order effects remain in the list. These handlers can be used without issue1:
Control.Effect.Interpreter.Heftia.Except.runThrowIO
runThrowIO ::
(IO <| r, ForallHFunctor eh) =>
Eff eh (Throw s ': r) a -> Eff eh r a
In short, Heftia separates the world into UnliftIO-based and continuation-based realms, and they can’t typically be mixed. This is a type-level safeguard to prevent weird things from happening, and the only breakthrough is the Shift_
effect, which pushes the limits of what can be achieved while adhering to the restrictions2.
So, because Heftia can handle the UnliftIO-based realm, it essentially includes everything that can be achieved by other IO-based libraries. It’s just that they can’t be mixed with the continuation-based realm. Heftia gradually expands the feasible area within safe boundaries, maximizing user freedom while preventing weird things from happening.
So, would it be a practical problem if they are incompatible? If you want to use fork
and bracket
, does that mean you can no longer use runState
in that Haskell project and have to use runStateIORef
instead?
No, that’s not the case.
Whether it becomes a practical issue depends on trying out a few real-world programs.
However, when I personally created a small automation program using Heftia with concurrency, transactional DB access, and runState
(without IORef
), it was not an issue.
Here’s how to handle it.
First, perform effect handling involving higher-order effects and continuation operations. Once that handling is complete and continuation operations are no longer needed, you can handle UnliftIO
and other higher-order and first-order effects last. Essentially, this means separating the stage of handling continuation operations from the stage of handling IO dependencies with UnliftIO
:
main :: IO ()
main = runApp yourApplication
runApp :: Eff '[...] '[...] ~> IO
runApp = runUnliftIOStage . runContinuationStage
runUnliftIOStage :: Eff '[... , UnliftIO] '[... , IO] ~> IO
runContinuationStage :: Eff '[...] '[...] ~> Eff '[... , UnliftIO] '[... , IO]
In this example, the stages are completely separated into two functions, but in reality, they can be mixed together more. The degree of inconvenience due to the lack of modularity is unknown. However, in most practical cases, this approach should work well.
Here is a code example based on this method: https://github.com/sayo-hs/heftia/blob/v0.5.0/heftia-effects/Example/UnliftIO/Main.hs
From a program design perspective, this is also desirable. Resource-dependent, external IO-like, infrastructural “impure” code is entirely aggregated in the UnliftIO
layer. Meanwhile, the pure domain model or business logic, decoupled from IO, are aggregated in the continuation realm. A desirable architecture is achieved by following the guidance of types.
This post addresses the following question in Reddit: https://www.reddit.com/r/haskell/comments/1fbvmo8/comment/lm92t03/
Yeah. Have a look at https://discourse.haskell.org/t/the-issues-with-effect-systems/5630/19?u=arybczak, in particular:
Why we can’t support both nondeterminism and MonadUnliftIO? It is perhaps less well known that many IO functions accessible via MonadUnliftIO, mainly those that use fork and bracket, only work when the control flow is deterministic. This has not been a problem since the beginning since Haskell was a deterministic language - but not now! Future effect system users will probably face a choice between nondeterminism-capable libraries and MonadUnliftIO-capable libraries and they will need to choose based on their specific needs.
Your library claims to support both, which, according to my knowledge, can’t work. What happens when you combine
NonDet
(orThrow
/Catch
) with unliftedbracket
orforkIO
?
-
As for the return value here, by adding the
UnliftIO <<| eh
constraint, it can returnEither e a
instead of justa
. It’s simply thatUnliftIO.Exception.try
isn’t being used. ↩ -
I haven’t tried it yet, but it’s likely possible to write continuation-based handlers like
runThrow
that can be used alongside higher-order effects by usingShift_
internally. In that case, the behavior when higher-order effects are involved would probably inherit fromShift_
—meaning the context would be reset at the point where the higher-order effect’s scope ends. ↩