You don't need an effect system

I broadly agree! This is basically RIO/ReaderT _ IO minus the (reader) monad bit (i.e. just passing stuff around, nice) plus the attempt at scoped exceptions (nice). I think most Haskell users would be better served by that style than by traditional (“synthetic”) effect systems. However, I think “analytic” effect systems (first effectful, later Bluefin) which are wrappers around IO (in Bluefin’s case, a very simple wrapper around IO) are a compelling alternative.

There are some caveats with the article, one serious. The serious one, a deal breaker for me, and probably a dealbreaker for all pratical use cases, is that Early e aliases. If I have two Early es floating around, with the same e, they interfere with each other. Consider this:

-- > aliasExample
-- "inner caught: tried to leave via outer"
aliasExample :: IO String
aliasExample = do
  result <- runEarly $ \outer -> do
    let act = leave outer "tried to leave via outer"
    innerResult <- runEarly $ \_inner -> do
      act
    case innerResult of
      Left err -> pure ("inner caught: " ++ err)
      Right impossible -> absurd impossible
  pure $ case result of
    Left err -> "outer caught: " ++ err
    Right message -> message

act can be arbitrarily complicated and defined arbitrarily far away from its use within the inner runEarly, but there’s nothing the caller can do to ensure that its exception gets picked up by the intended handler (i.e. the outer runEarly).

The particularly dangerous case is where you have a function f uses runEarly at type e and that accepts a g :: ... -> IO r as an argument. If you call f with a g that has another Early e in its closure then a call of leave in g will not return to the point you expect.

That’s already solved though, by Bluefin.Internal.Exception.Scoped (based on an ideas from Andrzej Rybczak and @Leary). It’s a simple drop-in replacement for Early defined in the article.

A second caveat, less serious and not a dealbreaker, is that Earlys can escape their scope:

-- > escapeExample
-- *** Exception: Early return exception
escapeExample :: IO ()
escapeExample = do
  innerResult <- runEarly pure
  inner <- case innerResult of
    Left () -> error "Impossible"
    Right early -> pure early

  escapedEarly <- runEarly $ \outer ->
    leave outer inner
  case escapedEarly of
    Left inner ->
      leave inner ()
    Right impossible -> absurd impossible

It might be nice to prevent that from happening. How could we do that? Well, one could use scope tracking a la Bluefin. That comes with some, but I would claim not much, additional complexity. I won’t hold it against anyone who doesn’t want to pay that additional complexity.


Beyond that, there were a number of claims in the article that don’t make sense to me, and some other things I thought worth commenting on:

Does it make for a safer codebase?

No, tracking effects does not preclude anyone from adding bad IO to an effect implementation, from importing unsafePerformIO, or from finding a sum of an infinite list

Perhaps not, but then by the same logic Haskell doesn’t make for a safer codebase than Python.

Am I using it outside of IO?

No, for pure functions that do mutation the ST monad works just fine.

For pure functions that do mutation and throw exceptions the ST monad doesn’t work fine. For that you need Bluefin.

random number generation is commonplace, but the steps necessary to generate a random value are different every time. The only shared behavior is the use of generator state, and even then it’s unclear if passing it around has any benefits over using the global generator

Passing generators around has a performance benefit over the global generator, if one is in a concurrent situation: “AtomicGenM is the slowest of the monadic adapters due to the overhead of its atomic operations”.

Have I made use of higher-order effects?

No, none of the effects I’m using need to overlap or nest

There seems to be a misconception here. The effects from this article are perfectly consistent with higher order uses, and here is a higher-order effect from later in the article:

runStack
  :: Logger -> Metrics -> DatabasePool -> MessagingEnv
  -> (Early () -> Database -> Messaging -> IO ())
  -> IO ()

The presence of something returning IO in argument position makes runStack a higher order effect.

(I’m not sure what the article means by “nest”. The effects in this article clearly nest! And higher order effects are not really about overlapping or nesting.)

This may well be the most embarrassing problem in all of Haskell. Over and over again people waltz in with the exact same basic question: “How do I return from a function early?”.

I agree it’s embarrassing. To dispute nomenclature, runEarly is not really an “early return”, it’s more like a “scoped try”. “Early return” would be something like Typeable e => (Early e -> IO e) -> IO e.

(For the record, the GHC proposal linked as a footnote was one of the things that led me to develop Bluefin, since I did consider it embarrassing that early return was considered difficult enough to require a syntax extension.)

And just like that I ran out of reasons to use an effect system.

IO doesn’t have a “thread-safe, locally modifiable state” so if one wants that one has to use an effect system (although there is a proposal to add thread-safe, locally modifiable state to IO, in the scoped thread-locals proposal). (Perhaps @BurningWitness really does not need that, though).

There is only one unique feature effect systems—or, more generally, custom monads designed to supercede IO—provide: they can do anything in between the lines [of code].

Hmm, no, not really. Bluefin doesn’t do anything “between the lines” (with the arguable exception of passing around a Vault to support the Ask capability, but if IOScopedRef is added to GHC then it won’t even need to do that). Bluefin effectful operations accept value-level capabilities as arguments.

Compared to the style presented in the article, the benefits are Bluefin are

  • No leaked effects (such as the leaked exceptions above), no leaked resource handles (no “Database” is in scope after the connection is closed)
  • You can run observationally-pure code to a pure value, i.e. you have runPureEff :: (forall e. Eff es r) -> r

The relative costs are:

  • You write e1 <: es => Throw e e1 -> Eff es r rather than Early e -> IO r
  • You sometimes have to manually munge these e1/es thinks with useImpl and friends.

(I’ll let the authors of other effect systems speak for themselves if they object to that characterization, but the others are all significantly more complex than Bluefin.)

Effects are tracked as function arguments; this is quite different from effect systems, which prefer constraints

No, not in the case of Bluefin. It also tracks effects as function arguments.

Here are the disadvantages:

  • Some functions will be verbose.

I hope this knowledge is used for things.

Strong agreement with the author in this point of style. After using arguments-as-effects in Bluefin for 2.5 years I have never once thought “passing arguments is tedious; I wish it would happen automatically”.

I’ll use the database effect as an example. Here’s a rough outline of the implementation function:

runDatabase connInfo f =
  mask $ \unmask -> do
  ...

I don’t understand why this isn’t just using bracket. Isn’t that one of the advantages of this style, that you can use bracket as it is, without any wrapping? Have I missed something? I also don’t understand why the implemenation throws a naked DatabaseException:

case fromException ex of
  Just dbEx -> pure $ Left (dbEx :: DatabaseException)
  Nothing   -> _ -- (4) Failure above.

Shouldn’t it take advantage of Early DatabaseException?

2 Likes