IO and effects refactoring #1343
louthy
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
In the last release I wrote this:
I realised that because
@catchcreates a temporarystruct(the variousCatch*record structs) that I could attachoperator |to those types and make@catchwork forK<F, A>types that areFallible<F>orFallible<E, F>.@catchAs, many of you know, in
v4we can@catcherrors raised in theEff<RT, A>andEff<A>types by using the|operator like a coalescing operator. For example:This imposes a time-limit on the
longRunningoperation, which throws aTimedOuterror if it doesn't finish in time. It then catches the timeout and continues safely by returning a default value ofunit.There were a number of types that
@catch(depending on the overload) could create:CatchValue<A>- for returning default values (as above)CatchValue<E, A>- for returning default values (with generic error type)CatchError- for returning an alternative errorCatchError<E>- for returning an alternative error (with generic error type)CatchIO<A>- for returning and lifting anIO<A>as the resultCatchIO<E, A>- for returning and lifting anIO<A>as the result (with generic error type)CatchM<M, A>- for returning and lifting anK<M, A>as the resultCatchM<E, M, A>- for returning and lifting anK<M, A>as the result (with generic error type)Each one carries a predicate function and an action function. If the predicate returns
truefor the error raised then the action is run, otherwise the result it left alone. This means a chain of| catch(...)operations can effectively pattern match the errors raised.Back to the idea that we have a
Fallible<E, F>(andFallible<F>which is equivalent toFallible<Error, F>). Because, operator declarations can't have generic parameters, all generic parameters must come from the type.To be able to leverage the
Fallible<E, F>trait then we needF(the trait type),E(the error type), andA(the bound value type):Only one of the
Catch*record structs has all of those generics:CatchM<E, M, A>- for returning and lifting anK<M, A>as the result (with generic error type)So, that's the only type that can support an
operator |that can work withFallible<E, M>:So, I had a couple of options:
CatchMand leave the otherCatch*types as non-Fallible supportingCatch*types that can't support FallibleOption 1 would mean that some usages of
@catchwould work withEff<A>but notK<Eff, A>. This felt unsatisfactory.Option 2 would mean that some of the convenience
@catchoverrides would have to be removed. So, you couldn't write this anymore:You'd have to write (one of):
Option 2 is the option I've gone with. The reasons for this are primarily for consistency between the concrete types (
Eff<A>) and their abstract pairs (K<Eff, A>), but also...Every single
Fallibletype gets to use@catch!So, previously,
@catchonly worked forEff<RT, A>,Eff<A>, andIO<A>. It now works for:IO<A>Eff<RT, A>Eff<A>Either<L, R>EitherT<L, M, R>Fin<A>FinT<M, A>- more on this laterOption<A>OptionT<M, A>Try<A>TryT<A>Validation<F, A>ValidationT<F, M, A>So now all
Fallibletypes get to use@catchand they all get to use the same set (well, some are specifically for theErrortype, like@expectedand@exceptional, but other than that they're all the same).Things to note about this change:
@catchis now entirely generic and based aroundFallibletypes, the|operator can only returnK<M, A>, so you may need to use.As()if you need to get back to the concrete type.@catchat all, unless you need access to the error value.MonadIOrefactorThe generalisation of catching any errors from
Fallibleled to me doing some refactoring of theEff<RT, A>andEff<A>types. I realised not all errors were being caught. It appeared to be to do with how theIOmonad was lifted into theEfftypes. In theMonad<M>trait was a function:WithRunInIOwhich is directly taken from the equivalent function in Haskell'sIO.Unliftpackage.I decided that was too complicated to use. Every time I used it, it was turning my head inside out, and if it's like that for me then it's probably unusable for others who are not fully aware of unlifting and what it's about. So, I removed it, and
UnliftIO(which depended on it).I have now moved all lifting and unlifting functions to
MonadIO:LiftIOas most will know, will lift anIO<A>into your monad-transformer.ToIOis the opposite and will unpack the monad-transformer until it gets to theIOmonad and will then return that as the bound value.For example, this is the implementation for
ReaderT:So, we run the reader function with the
envenvironment-value, it will return aK<M, A>which we then callToIO()on to pass it down the transformer stack. Eventually it reaches theIOmonad that just returns itself. This means we run the outer shell of the stack and not the innerIO.That allows methods like
MapIOto operate on theIO<A>monad, rather than the<A>within it:What does this mean?
.MapIO(...)on any monad that has anIOmonad within it (as long asToIOhas been implemented for the whole stack)Generalised IO behaviours
The
IO<A>monad has many behaviours attached to it:Local- for creating a local cancellation environmentPost- to make the IO computation run on theSynchronizationContextthat was captured at the start of the IO operationFork- to make an IO computation run on its own threadAwait- for awaiting a forked IO operation's completionTimeout- to timeout an IO operation if it takes too longBracket- to automatically track resource usage and clean it up when doneRepeat,RepeatWhile,RepeatUntil- to repeat an IO operation until conditions cause the loop to endRetry,RetryWhile,RetryUntil- to retry an IO operation until successful or conditions cause the loop to endFold,FoldWhile,FoldUntil- to repeatedly run an IO operation and aggregating a result until conditions cause the loop to endZip- the ability to run multiple IO effects in parallel and join them in a tuppled result.Many of the above had multiple overrides, meaning a few thousand lines of code. But, then we put our
IOmonad inside monad-transformers, or encapsulate them inside types likeEff<A>and suddenly those functions above are not available to us at all. We can't get at theIO<A>monad within to pass as arguments to the IO behaviours.That's where
MapIOcomes in. Any monadic type or transformer type that has implementedToIO(and has anIO<A>monad encapsulated within) can now directly invoke those IO behaviours. And not only that, they can be fully generalised:So, to call the
IO<A>.Timeoutfunction for theIO<A>monad buried withinK<M, A>we simply callMapIOto get theiomonad an then use it to invoke our IO behaviour. It then automatically gets wrapped back up inside aK<M, A>monad.This means every single IO behaviour is now available to you as soon as you make a type that encapsulates the IO monad. Again, as long as
ToIOis implemented.To avoid name-clashes with existing methods and functions, these are the generic behaviours:
LocalIOandPrelude.localIOPostIOandPrelude.postIOForkIOandPrelude.forkIOTimeoutIOandPrelude.timeoutIOBracketOandPrelude.bracketIORepeatIO,RepeatWhileIO,RepeatUntilIOandPrelude.repeatIO,repeatWhileIO,repeatUntilIORetryIO,RetryWhileIO,RetryUntilIOandPrelude.retryIO,retryWhileIO,retryUntilIOFoldIO,FoldWhile,FoldUntilandPrelude.foldIO,foldWhileIO,foldUntilIOZipIOandPrelude.zipIODeleted
EffextensionsAll of the above means that
Eff<RT, A>andEff<A>don't nee their ownFork,Repeat,Retry,Zip, etc. extensions or prelude functions. So, they've been deleted. But note, that will have the following fallout:K<Eff, A>andK<Eff<RT>, A>, so you may need strategic use of.As()to get back to a concrete type.Effgone back toReaderTbacked rather thanStateTWhen refactoring the
Effmonads forv5I decided to switch to use theStateT<RT, IO, A>transformer-stack as the underlying implementation. The problem with this is that the liftedIOmonad isn't anIO<A>it's anIO<(A, S)>(because we need to return the updated state). Unfortunately, that means we can't implementToIOforStateTbecause we'd lose the updated state if we mapped the resultingIO<(A, S)>toIO<A>; breaking the soundness of theStateTmonad and probably bringing in other unexpected side-effects.So if
Eff<RT, A>andEff<A>are going to be able to leverage these new generalised IO behaviours (from the last section), then they have to be implemented withReaderT. IfIO<A>is lifted into aReaderTthen it can yield anIO<A>in anyToIOimplementation, which makes it sound.Statefullness in runtimes (next part of the rabbit hole...)
The reason I decided to make
Eff<RT, A>use aStateTtransformer before was because I wanted the runtimes (RT) to allow for stateful behaviour. And so, going back to being aReaderTmeant that theReadsandMutatestraits could no longer work (because they both depended onStateful, which is the generalised state mutation trait).The following refactorings have happened:
Hastrait has now gonestaticTraitproperty is now calledAsk.Traitto.Askand to make the implementationsstaticReadstrait has now been deleted, it ended up being the same asHasMutatestrait now derives fromHasand provides aMutableproperty that exposes anAtom<InnerEnv>MutatesAskLocalReadabletype can do viaReadable.local(f, ma)HasLocal.with(f, ma)to create a localised runtime andHas.askto access the current valueThe one piece of code in all of language-ext that needed this local-state was
Activity<M, RT>. So, here's some of it, so you can see what's changed:Things to note:
RT, toHas<M, ActivitySourceIO>andLocal<M, ActivityEnv>ActivitySourceIOinterface by using:Has<M, RT, ActivitySourceIO>.askActivityEnvby usingHas<M, RT, ActivityEnv>.askLocal.with<M, RT, ActivityEnv, A>(f, operation)Has<M, VALUE>is not the same asHas<M, Env, VALUE>Ask, that will return aK<M, VALUE>value.RTtypes in generalised code, it's not possible to callRT.Askwithout there being type-system ambiguities.Hastype:Has<M, Env, VALUE>, has been added to resolve those ambiguitiesThis is the implementation of
Has<M, Env, VALUE>implementation:Because it constrains to only a single trait (
Has<M, VALUE>) it can callEnv.Askand have it resolve unambiguously. This has the added benefit that theK<M, VALUE>value, that it reads from your runtime, will be cached. That will minimise allocations in effectful code.So, this whole system now completely generalises the idea of environment access and scoping as well as mutation. It also means you don't need to carry around either the
StatefulorReadabletraits in your generalised effectful code.Here's an example, before and after, from the
Newslettersample project:Before
After
LanguageExt.SysHas been refactored to use these new traits and constraints.
FinT- new monad transformerThere's a new monad transformer,
FinT, which is the transformer version ofFin. It was only of the last of the monadic types not to have a transformer pair, so that's now done.Conclusion
This just confirms why keeping the project in a beta state for now makes sense. My real-world usage of the beta is bringing up these areas of improvement and allows me to bring them in without causing multiple migration problems! Any questions feel free to discuss below...
This discussion was created from the release IO and effects refactoring.
Beta Was this translation helpful? Give feedback.
All reactions