haskell-effectful/effectful

MonadThrow/MonadCatch/MonadMask are always available

qrpnxz opened this issue ยท 8 comments

I find it surprising there is no IOE :> es constraint on the MonadThrow et al. instances for Eff. I shouldn't get exceptions from pure computations. Please add IOE :> es constraint. Those who want these kinds of surprise exceptions can already use throw from Control.Exception. Furthermore, MonadCatch can catch exceptions from Effectful.Error.Static, which is also not great.

I shouldn't get exceptions from pure computations

You can't prevent that because Haskell is a language where any expression is free to have a throw inside, not to mention asynchronous exceptions.

Those who want these kinds of surprise exceptions can already use throw from Control.Exception.

Exactly, that's why adding an IOE requirement to MonadThrow changes nothing, because you can always use throw.

Furthermore, MonadCatch can catch exceptions from Effectful.Error.Static, which is also not great.

Yes, that is quite unfortunate, but it's only a problem if you catch SomeException and ignore it, which you should never do.

To summarize:

  • MonadMask can't require IOE because it allows usage of functions like bracket and finally to perform cleanup actions (like restoring a state on error), even within runPureEff.
  • MonadThrow is inconsequential because anything can throw at any time.
  • MonadCatch is the one that would be nice to require IOE, but it can't because if it did, MonadMask would also have to because of arguably invalid class hierarchy in the exceptions package and that ship unfortunately has sailed.

Good points, in particular for MonadThrow, you have changed my mind.

Regarding your first and last points, consider this: If the finalization you want to do is for an effect that you run entirely within the catch, then whatever thing you did in finalization will be irrelevant because the exception threw the context away. If it's dependent on an effect that is also in the stack that's doing the catching (and in particular, it's stateful: IOE, State), then it makes sense, because that context will still be relevant after catching. Because of that, I it doesn't seem to be a real problem that you cannot bracket if you cannot catch.

I hadn't meant to post just yet. There is of course one effect that is relevant even if you never catch: IOE (because thankfully exceptions don't delete the universe ๐Ÿ™‚). If you have IOE you should absolutely be able to bracket, which is the case even if MonadMask requires IOE, so no problem there. As far as I can tell that is the only exception (haha), so it shouldn't be a problem to have the constraint.

I find it hard to visualize what you described, but if MonadMask required IOE, the following function

transactionally :: forall s es a. State s :> es => Eff es a -> Eff es a
transactionally = bracketOnError (get @s) (put @s) . const

cannot be written.

Moreoever, even if your effect stack has IOE, ideally you want to not have it visible in most of your application code and only use more restricted effects. Again, if MonadMask required IOE, you couldn't use things like bracket in your functions without bringing in the whole IOE, which would be bad.

I agree IOE is overkill for a function like transactionally. So let's write an Effect then ๐Ÿ™‚. I'll call it Catch for now. Catch will let you catch any (synchronous) IO exception, and another effect Mask will let you do masking. Then, looking at the new function type, the possible effects would amount to State s, "can catch any exception" and "can mask", which are indeed the only effects done by transactionally.

You've mentioned it would be nice to have MonadCatch require IOE. I'm guessing you would find Catch nice for similar reasons. Here is one motivation: if I pass a throwing higher order effect, and my exceptions disappear, I can find what functions might be responsible; namely those with Catch or IOE. Functions without either are transparent to exceptions.

We could also have another effect for catching only exceptions of a particular type.

More concretely why MonadCatch => MonadMask makes sense.

Let's suppose that the higher order effect in transactionally throws. If the exception is never caught, or is caught in a context in which State s has already been run, then the (put @s) finalization will be irrelevant, because that state will be gone by the time we get control again:

main = neverCatches (transactionally ...) -- Program exits. Bracketing useless.
catch (runState (neverCatches (transactionally ...)))
      (\e -> ...) -- State inaccessible. Bracketing useless.
runState $ catch (transactionally ...)
                 (\e -> ...) -- Still using that state. Bracketing helped.

Now let's say the function using transactionally doesn't have a Catch constraint. Then using transactionally will be disallowed, but that's okay because unless we catch, or run in a context where catching is possible, the effects of the bracket will not be appreciable. Furthermore, the implementation of bracket actually requires catching, so it makes sense that that constraint would be required.

Now, if the effect is IOE, then it's a little different:

main = neverCatches (bracket allocRealWorldResource
                             freeRealWorldResource
                             badAction)
  -- Real world still exists, bracket action still relevant.

But this bracket doesn't need you to say we can Catch, because with IOE we get MonadUnliftIO. So in the one effect you might want to bracket without being able to catch, you can! (Though of course, IOE can catch too. ๐Ÿ™‚)

Similarly with mask, the corruption that mask would prevent is only relevant if we ever catch, enabling access to our state again. However, it is true that masking itself doesn't technically require the capability to catch, so having an Effect just for masking might be nice. Then a function that just masks doesn't look like it might catch when it asserts there is catching in the effect stack. MonadMask still needs Catch because it provides a bracket. But if for some reason you only wanted to mask, you could use the Mask effect.


I'm willing and able to implement these effects.

Please also see #65 (comment).

I pondered the idea of having an Except effect and constraint these instances to this effect, but discarded the idea precisely because exceptions are everywhere in Haskell.

However, it is true that masking itself doesn't technically require the capability to catch, so having an Effect just for masking might be nice

That would be nice, but it's not possible because of the superclass hierarchy. Try it yourself. It would have to be:

instance Catch :> es => MonadCatch (Eff es)
instance (Catch :> es, Mask :> es) => MonadMask (Eff es)

if you attempt to do this:

instance Catch :> es => MonadCatch (Eff es)
instance Mask :> es => MonadMask (Eff es)

you'll get an error

โ€ข Could not deduce (Catch :> es)
        arising from the superclasses of an instance declaration
      from the context: Mask :> es
      ...

if I pass a throwing higher order effect, and my exceptions disappear, I can find what functions might be responsible; namely those with Catch or IOE. Functions without either are transparent to exceptions.

That's a good argument, but we're unfortunately tied by the hierarchy of these classes.

Now let's say the function using transactionally doesn't have a Catch constraint. Then using transactionally will be disallowed, but that's okay because unless we catch, or run in a context where catching is possible, the effects of the bracket will not be appreciable. Furthermore, the implementation of bracket actually requires catching, so it makes sense that that constraint would be required.

The problem is that bracket catching exceptions is an implementation detail ๐Ÿ˜ž The caller of bracket doesn't care how it works, so I find requiring a Catch effect for it at the call site to be weird.

I'll close this for now. There is no good solution to this problem because of the unfortunate class hierarchy in exceptions, but I consider the current one the most pragmatic.