Bluefin compared to effectful [video]

I think a function with 20+ effects is probably a “code smell”, regardless how you pass the handlers around. It may mean that:

  • The effects are too fine-grained.

    For example, two separate effects Stdin and Stdout instead of Console (which combines both) may not buy you much unless you actually need the flexibility.

  • Effects deep down in a call stack are propagated all the way to the high-level functions.

    In large code bases, propagating a type change transitively to all of the use sites can cause a massive amount of churn and headache.

Solutions may be:

  • Combining multiple effects into larger ones.

    In the example above, structured concurrency, concurrency, and logging may be combined in a single effect if a lot of code use them in combination.

  • Modeling entire parts of the program as effects.

    If I have an message loop that receives RPC messages and calls handlers (maybe provided as callbacks), instead of propagating all of the effects of all of the message handlers to the type signature of the message loop, I may implement an effect with message handling operations. Handlers/interpreters would then use other effects in the use site of the loop, but the message loop function itself would not have to be updated with effects of message handling code as they are updated.

    (This effectively separates the code for the message loop and handling the messages, which may not always be desirable.)

  • A type-synonym-like approach for naming a collection of effects, and using that name in the call sites (transitively) might make this more manageable. If I start using a new effect deep down in a call stack, I can update the type synonym, and if all the callers also use I don’t have to change them, until the code that needs to handle the new effect.

    (I suspect this is probably not possible with the type system features of GHC today, but just an idea, perhaps for another library or language.)

4 Likes

It’s actually fine, with ConstraintKinds you can write type AppE es = (Effect1 :> es, Effect2 :> es, Effect3 :> es) and then use AppE es in relevant contexts.

4 Likes

Note that using ConstraintKinds in this way has two related downsides:

  1. You lose fine-grained “unused constraint” warnings from GHC
  2. You lose some of the benefit of “saying what the function does not do”, since you will tend to “over-empower” functions to do things that they don’t actually need to do
3 Likes

This is great, thank you!

1 Like

@tbidne, based on your report of your experience I made some improvements.

  • Added withEffToIO_, which is the simplest IO unlifting operation.
  • Added documentation to the weird EffReader instance for MonadUnliftIO that withEffToIO_ should be preferred.
  • Added makeOp and useImplUnder and recommend them as the way of making dynamic effects. This is more uniform than before, because it extends to dynamic effects that take handles as arguments.
1 Like

Whoa, unused constraint warnings? That’s amazing! One of my biggest struggles with effectful is that I don’t realize when there are unused effects in the constraints. I’m going to have a wonderful time enabling that warning and laughing maniacly while sweeping all the redundant effects from the code base where I use effects. :broom::broom:

On a more serious note, I’d argue against grouping together as well. Listing all effects in a function, be it constraint or arguments, will tell you a lot about what a function, and its transitive calls will do. The absence of an effect tells you what kind of stuff a function won’t do. Questions like “will this touch the database?” would otherwise be answered by a lot of go-to-source.

Having a lot of effects in a type signature could still say something about the cohesion of the function. Maybe it just does too much, and some of the effect uses can be extracted out to another function. To me, effects push (but do not force!) me to think about that more. That does not globally apply though. The functions closest to your runEff will likely still be sitting on a big pile of effects.

I am interested in how the so-called interpose operation can be used in bluefin.
The interpose operation plays a central role in dynamic effects.

It allows for locally modifying the behavior of an effect handler within the scope enclosed by interpose, and can be considered a kind of generalization of the Reader’s local operation.

When users hear that “dynamic effects are possible,” they will expect the functionality provided by interpose.

interpose is usually used as follows:

modifyPlus1 :: Reader Int :> es => Eff es a -> Eff es a
modifyPlus1 = interpose \case
    Ask       -> (+1) <$> ask
    Local f m -> local f m

main = runReader 0 $ modifyPlus1 $ print =<< ask
-- > main
-- 1

In practical terms, for example, it can be used to add logging locally after the fact to an effect:

logWriteDB = interpose \case
    WriteDB ... -> do
        writeDB ...
        log "Wrote to the DB"

Now, in effectful, there is an interpose function at Effectful.Dispatch.Dynamic. On the other hand, it seems that bluefin currently does not have this functionality for general effects, but if you have any ideas, please let me know.

My guess is that (considering that bluefin explicitly propagates evidence via arguments* instead of implicitly holding the environment in a ReaderT IO), in bluefin it would theoretically take the following form:

modifyPlus1
    :: Reader Int :> es
    => Reader Int e
    -> (Reader Int e -> Eff (e :& es) a)
    -> Eff (e :& es) a
modifyPlus1 r f = f $ r{ask = (+1) <$> ask r}

newtype Reader r e = MkReader { ask :: ? r } -- Not quite sure what to do here...

* It seems I had a slight misunderstanding… What bluefin carries around isn’t the handler functions, but just an IORef, right?

4 Likes