cleff icon indicating copy to clipboard operation
cleff copied to clipboard

Advantages over effectful

Open arybczak opened this issue 3 years ago • 17 comments

Disclaimer: this isn't supposed to be an attack.

It looks to me that it'd be better to join forces and make effectful happen (or improve its shortcomings if there are any significant ones) instead of redoing everything from scratch (unless you just want to play with different implementations, then fair enough) given the similarities.

From what I gather, this library is very similar to effectful, but:

  • It can't support thread-local state.
  • It has worse asymptotics for adding/removing effects and dispatch IIUC, which stems from using TypeRepMap underneath
  • It has slightly more verbose interface because of Typeable requirement (again, TypeRepMap).
  • It can't do static dispatch if someone really wants maximum performance.

It says it the readme that effectful's internal representation

largely restricts the effect interpretation interface

Can you elaborate?

and

operations like local is not achievable without dealing with the library internals.

Yes, but it doesn't really matter. Primitive operations are to be re-used via reinterpret, no one has to reimplement them if they don't want to.

Also, you say that

cleff uses a simpler ReaderT Env IO, where the Env is an immutable map that stores all effect handlers, i.e. implementation of effect operations.

but it's worth noting that effect handlers inside Env can be closures with IORefS and MVarS inside of them, so the immutability of Env itself doesn't gain you much. effectful makes mutability explicit, which is arguably clearer.

arybczak avatar Oct 18 '21 12:10 arybczak

Actually, API-wise, what I'm more aiming for is a library similar to polysemy instead of effectful. What I want to value more is to have a nice, unified API for effect interpretation that is simple & expressive enough (though we're sure to lose nondeterminism - but that's acceptable to me). As for performance, all I want is to be better than polysemy and comparable to freer-simple/fused-effects. With that said, there perhaps will be a difference in the audience between cleff and effectful.

(I'm not saying effectful has a bad effect interpretation interface! - it is just that I have something different in my mind)

The wording in the README makes it seem like mainly - if not solely - competing against effectful. I apologize for that! That part is just some rationale that I expressed poorly with too much sentiment, and may be removed upon release (if that was to happen - it's a hobby project, it's not being used in any real application, and I may abandon it at any time).


Warning: the following answers are very opinionated, take them as what I wanted for cleff instead of general criticism!

It has worse asymptotics for adding/removing effects and dispatch IIUC, which stems from using TypeRepMap underneath

It has slightly more verbose interface because of Typeable requirement (again, TypeRepMap).

~~Ah this. For dispatch I think there won't be worse asymptotics because it is actually O(1) array indexing? For adding/removing that is true indeed, but that only happens at 1) higher order effect operations with local contexts, which are rare compared to first-order ones, and 2) running the whole effect stack at the end of the application.~~ No, effect lookup is O(logn). I'm sorry for that.

I chose to use TypeRepMap because I don't want to - and may not be able to - build one from the ground that fits my need better. I've looked at what effectful implements and it seems to me that effects are indexed directly via array indices - and I'm not sure if I can still have interpose, subsume, raise etc with that approach.

There are clearly problems with using TypeRepMap and there may be a better approach. I need to think about this.

It can't do static dispatch if someone really wants maximum performance.

This is - I'd say - intentional. Static dispatch, in my opinion, kind of defeats the objective of an extensible effects library: being able to interpret an effect differently. They are just wiring primitive operations in. That being purely philosophical, a more realistic problem is that code using different, say, State effects doesn't work with each other, and library authors defining other stateful effects will need to define multiple interpreters into different static States. Added with my main objective - to have a nice unified interface - I'd rather just not add it.

largely restricts the effect interpretation interface

For this, I was mainly talking about interpreting higher order effects that have local contexts. Since effectful's env is opaque, you can't do much with that. These effects are rare in the wild, but I'd really like to enable users - and me - to define and interpret them, even for the very foundational ones like Reader and Writer, without needing to wrestle with any internal stuff.

but it's worth noting that effect handlers inside Env can be closures with IORefS and MVarS inside of them, so the immutability of Env itself doesn't gain you much. effectful makes mutability explicit, which is arguably clearer.

The answer to the last question applies here. I worded it very poorly in the README, but what I actually don't want is the hassle of adding static dispatch instead of mutability itself.


All those answers may seem pretty amateurish (given I am really an amateur who hasn't dug deep into Haskell), and in conclusion, I may say that - I'm just trying to enjoy building a thing that is small, works well and is as close to my "aesthetics" as possible. It is not anything driven by any real industrial need nor based on solid theoretical grounds (to put it more directly: I'm prioritizing personal taste and some niche obsessions).

PS It is obvious that we can borrow many ideas from each other - and we are already doing that, which is good!

re-xyr avatar Oct 18 '21 14:10 re-xyr

With bea7c56, cleff should have the same asymptotics as effectful for dynamic effects (and lifts Typeable contraints) 🙂

re-xyr avatar Oct 19 '21 15:10 re-xyr

Thanks for the writeup, things are clearer to me (and others that could've wondered about the same thing) :+1:

I'm not sure if I can still have interpose, subsume, raise etc with that approach.

Yes, I don't think you can write raise and subsume. As for interpose, I'm not sure what are its use cases and how is it supposed to work, especially with higher order effects.

Static dispatch, in my opinion, kind of defeats the objective of an extensible effects library: being able to interpret an effect differently.

You're not forced to use static versions of primitive effects, you can use dynamic ones (all State/Reader/Writer/Error have dynamic counterparts).

more realistic problem is that code using different, say, State effects doesn't work with each other

This is actually an advantage. Local and shared state have different semantics for multi-threaded programs and you might want to have a static guarantee that someone uses one or the other.

and library authors defining other stateful effects will need to define multiple interpreters into different static States.

I don't get it. If an effect needs internal state, the author will simply make a choice about what State they want to use depending on their needs and that's it.

arybczak avatar Nov 01 '21 13:11 arybczak

As for interpose, I'm not sure what are its use cases and how is it supposed to work, especially with higher order effects.

It is supposed to... modify an element in the Env instead of consing one. Semantically it allows you to intercept (I believe that's how the function called in polysemy) effect operations and then send something else to the original handler to handle. It is somehow similar to a monomorphic (?) reinterpret (Env (e : es) ~> Env (e : es)). There may be some use cases in the wild (I'd like to believe so), since freer-simple and polysemy both have that.

You're not forced to use static versions of primitive effects, you can use dynamic ones (all State/Reader/Writer/Error have dynamic counterparts).

I now tend to believe static dispatch is to some extent reasonable, such as when there is only one reasonable interpretation (ResourceT, for example). For other cases I'm not really sure how I feel about them. This is arguably a matter of taste (or settlement on technical opinions) though.

The thing I'm concerned about is how to implement a good API for defining static effects (no need to use primLiftIO, having a closed set of operations for each effect, etc). Current effectful implementation is not good at this (you must have a state + IO are only achieved via unsafeEff), though that's sufficient for internal use.

This is actually an advantage. Local and shared state have different semantics for multi-threaded programs and you might want to have a static guarantee that someone uses one or the other.

I agree. I wasn't fully aware of the difference between Local and Shared states back then.

I don't get it. If an effect needs internal state, the author will simply make a choice about what State they want to use depending on their needs and that's it.

An effect may need to operate on an existing State. And if an effect doesn't care about whether the state is local or shared, they'll have to implement two interpretations for the local and shared state.


I also talked about cleff with Alexis at the mean time, and she convinced me to investigate whether functionalities available in cleff can be integrated into effectful. If that was successful we get the best from the two, and even if not we can still learn more about the differences between them and how the potential audience will differ. 😉

re-xyr avatar Nov 03 '21 11:11 re-xyr

It is supposed to... modify an element in the Env instead of consing one. Semantically it allows you to intercept (I believe that's how the function called in polysemy) effect operations and then send something else to the original handler to handle.

Ah, right. Doing that directly won't work in effectful since the Env is mutable, but you can always write it yourself on a case-by-case basis using interpret and adding a duplicate effect with a handler that does something and then dispatches to the old handler :thinking:

IO are only achieved via unsafeEff

I think IOE can be made dynamic. I just didn't bother for the same reason you made it internal by default - there's little point :slightly_smiling_face:

An effect may need to operate on an existing State. And if an effect doesn't care about whether the state is local or shared, they'll have to implement two interpretations for the local and shared state.

Sure, but it's extremely rare that an effect will "borrow" existing state. I've never seen libraries written in mtl-style do this.

I also talked about cleff with Alexis at the mean time, and she convinced me to investigate whether functionalities available in cleff can be integrated into effectful. If that was successful we get the best from the two, and even if not we can still learn more about the differences between them and how the potential audience will differ. wink

Sounds good :+1: I'm open to tinkering with internal representation of Env in effectful if that makes some things easier/possible to express without (significant) downsides.

arybczak avatar Nov 04 '21 15:11 arybczak

you can always write it yourself on a case-by-case basis using interpret and adding a duplicate effect with a handler that does something and then dispatches to the old handler

Yes, that's reasonable. The reason to use interpose is similar to that of using reinterpret - to hide the details (I.e. in the final stack you don't see the effects introduced and then eliminated via reinterpret, neither will you see two duplicate effects if you use interpose; both will be visible when only using interpret). Some say this prevents leaking of implementation.

I think IOE can be made dynamic. I just didn't bother for the same reason you made it internal by default - there's little point

I think there can be ways other than making IOE dynamic to make static effects not need to use internal constructs directly. What I hope for static effects are (randomly ordered):

  • Have a clear, closed set of effect operations, just like dynamic ones;
  • Not needing to use unsafeEff for IO. For example, we can have some constraint that marks a "static effect handler scope" inside which static effects perform IO freely. That can just be a phantom thin barrier that is thrown away by the reflection trick.
  • A way of specifying static effect state, not using the effect GADT (which is kind of confusing and make me feel guilty of using the same thing for two different purposes).

What I can now come up with is a Static e typeclass that stores how to static dispatch the effect type e with a function staticSend :: (Static e, e :> es) => e (Eff es) ~> Eff es. Not sure about performance regression compared to using internal constructs directly. (Users are not supposed to place Static e constraints on their functions, at that will be dynamic dispatch again and defeats the whole purpose)

Sure, but it's extremely rare that an effect will "borrow" existing state. I've never seen libraries written in mtl-style do this.

One prominent example is zoom - but yeah, that's the only one I've seen.

re-xyr avatar Nov 05 '21 00:11 re-xyr

For this, I was mainly talking about interpreting higher order effects that have local contexts. Since effectful's env is opaque, you can't do much with that.

Not quite. You just modify local context by interacting with private effects of the handler directly, like this:

import Control.Monad.Catch
import Effectful
import Effectful.State.Local

data X :: Effect where
  X :: m a -> X m a

type instance DispatchOf X = 'Dynamic

runX :: Eff (X : es) a -> Eff es a
runX = reinterpret (evalState @Int 0) $ \localEs -> \case
  X m -> do
    bracket (state @Int $ \s -> (s, 0)) put $ \_ -> do
      localSeqUnlift localEs $ \unlift -> unlift m

I didn't add other methods that would modify the private state, but you get the point.

Btw, didn't 65be66edd89b0af63b1ee50da075803d256d6300 make Env semi-mutable?

arybczak avatar Jan 09 '22 13:01 arybczak

Not quite. You just modify local context by interacting with private effects of the handler directly

Indeed, this is also what cleff's reinterpretN does; however I believe this is not as expressive as allowing the user to change the semantics of local actions by passing in a new handler, i.e. what cleff's runHere does. For example you cannot express the dynamic interpreter of Reader in effectful.

Again there may be an argument of practicality here since being able to derive runReader without using primitives is not really a useful thing. I do not have a good counter argument for that (though I still believe sometimes some users will want to have this feature) so I'd say this is purely a matter of expressiveness.

Btw, didn't 65be66e make Env semi-mutable?

Depends on what semi-mutable means. It does allow a handler to be "changed" retroactively. What do you want to achieve with it?

re-xyr avatar Jan 09 '22 15:01 re-xyr

What do you want to achieve with it?

Nothing really, just asking for better understanding. IIUC the differences of effectful vs cleff stem from the fact that I chose mutable environment while you choose immutable. I'm guessing the last commit brings you further to what effectful does 🤔

arybczak avatar Jan 09 '22 15:01 arybczak

Ah ok. Basically, 65be66e says that Env no longer stores handlers; instead it stores pointers to handlers.

So originally, each handler for effect e in the stack es captures all handlers for es at that interpret call, and any changes to these handlers afterwards don't affect them at all.

After the change, a handler for e in the stack es only captures the pointers to handlers for es, and the actual correspondence table from pointers to handlers needs to be passed in from the sending context. This allows us to change what a pointer points to and pass that modified table in. Therefore we actually retroactively changed the behavior of one effect that handler depends on. This way we can have the correct HO semantics, fixing #5.

re-xyr avatar Jan 09 '22 16:01 re-xyr

However this is not really close to what mutability is like in effectful; instead of having get and set it is more like that I implemented some kind of local.

re-xyr avatar Jan 09 '22 16:01 re-xyr

FYI I got inspired by Env from cleff and rewrote the Env from effectful to be somewhat similar (still mutable though).

It makes indexing always O(1) as opposed to dependent of the number of forks and I'm pretty sure it allows for support for subsume, interpose etc.

arybczak avatar Mar 12 '22 02:03 arybczak

Given the above and that effectful got raise, subsume, interpose, impose and inject, I think that this statement from README:

cleff has a more versatile and expressive effect interpretation mechanism

no longer applies, right?

arybczak avatar Jul 06 '22 06:07 arybczak

I think one thing left is translate and transform, though I doubt they have much practical usefulness.

Another one is that cleff supports changing handlers "on the fly" via toEffWith. This one is pretty crucial to cleff, because things like local and listen are implemented with it. But on the other hand, it may not be that significant in effectful because usages like local are expressed via builtin state functionalities.

I guess it's not too hard to implement both of them in effectful though, so indeed that statement you quoted could soon no longer apply.

re-xyr avatar Jul 06 '22 06:07 re-xyr

While we're at it, there are 2 reasons why I used the immutable scoping approach for the effect Env in cleff instead of the mutable one in effectful (they are relatively minor but I just couldn't get over them):

  • lazy IO (I know they're bad) can end up reading the wrong (or even nonexistent) handler when they are only forced after their result left the scope;
  • in effectful, you need cloneEnv for each new thread so that the handlers aren't shared across threads. This means that forkIO and any async infrastructure around that needs to be reimplemented instead of directly piggybacking unliftio.

re-xyr avatar Jul 06 '22 06:07 re-xyr

Fair enough :+1:

lazy IO (I know they're bad) can end up reading the wrong (or even nonexistent) handler when they are only forced after their result left the scope;

I didn't even think of that, an interesting catch. But yeah, as you said, lazy IO is so bad that it shouldn't be used at all, so I choose to pretend it doesn't exist. I can't think of reasonable use-cases for unsafeInterleaveIO combined with Env access anyway (that's the only thing that might break, lazy IO that doesn't touch Env will work as usual).

This means that forkIO and any async infrastructure around that needs to be reimplemented instead of directly piggybacking unliftio.

That's true, but with a caveat ;) Functions constrained by MonadUliftIO and MonadBaseControl IO can be used directly outside of effect handlers, you just need to potentially adjust the UnliftStrategy locally to ConcUnlift with withUnliftStrategy.

Most of the time you'll either re-use the Concurrent effect from effectful which is already written (and I wouldn't necessary call that reimplementiation, these are very light wrappers) or use some IO functions in the handler, at which point you need to unlift them explicitly and then you just use localUnlift with ConcUnlift.

Though cleff just has toEff and you don't need special unlifting functions in handlers, so yeah, you have it slightly better.

I still think that mutable environment is the way to go, since you get more flexibility for thread-local state (I'm not particularly fond of semantics in cleff, I think it's confusing that you go back to the initial state when forking in the middle of a computation) and extremely fast static effects.

arybczak avatar Jul 16 '22 11:07 arybczak

Side note: A solid use case for interpose:

-- | Mutate all `trace` calls in the given action
mapTrace :: Trace :> es => (String -> String) -> Eff es a -> Eff es a
mapTrace f = interpose $ \case
  Trace t -> trace (f t)

-- | Add prefix to all `trace` calls in the given action
prefixTrace :: Trace :> es => String -> Eff es a -> Eff es a
prefixTrace prefix = mapTrace (prefix<>)

I use this to add prefixes to trace calls so I can tell from which part of my program a particular trace call is coming from. It seems to work really well!

goertzenator avatar Sep 02 '22 14:09 goertzenator

Closing as the discussion is mostly concluded.

re-xyr avatar Feb 07 '23 08:02 re-xyr