Monad of No Return: Issues with `(>>) = (*>)`

It might be useful in general if GHC would provide a way to annotate default implementations with warnings. Then this warning does not have to be coupled to base.

5 Likes

I think a good way forward is to make two changes to rewrite rules which would then allow us to rewrite all occurrences of *> to >> if a monad instance is available:

  1. Add an early opportunity for rewrite rule firing, before typeclass members get specialized.
  2. Allow rewrite rules to be guarded on whether certain typeclass instances exist.

These seem feasible and generally useful.

One concern for change 2 is that rewrite rules work on the level of Core at which point typeclasses have been desugared away. That means that we somehow need to check if certain dictionaries exist rather than just type class instances. I don’t know if there’s already a mechanism in GHC to do this.

Even with those extra features, how would this work in detail?

Does the rule rewrite (*>) as literally (>>)? If so, we can’t have (>>) = (*>) anymore (either as a default or as written by hand), because that itself will get rewritten. If it rewrites (*>) as thenM, then does it do so generically for all Monads? If so, then user-written (*>) will always be ignored. Otherwise, we’re talking about adding a rule to each individual Monad module, a scope of work comparable to adding (*>) = thenM to each individual Monad module, with the same questions around identifying affected Monads.

Indeed, I should have thought this through more carefully, but this made me think of an even better design.

We could have the default implementation be (*>) = thenA, add {-# INLINE [1] thenA #-}, and add a rule thenA = thenM which is guarded on a monad instance existing.

Ah, I see! That does make more sense, but now I see your point about having to find dictionaries in Core. I suspect that’s an unpleasant nut to crack; the Monad instance might come with all sorts of additional constraints that would need to be solved, and so I expect that feature would need to support a copy of the whole instance resolution algorithm.

If we’re brainstorming new language features in support of this, how about chained method defaults instead?

class Applicative f where
  ...
  (*>) :: f a -> f b -> f b
  default (*>) :: Monad f => f a -> f b -> f b
  (*>) = thenM
  else default (*>) :: f a -> f b - f b
  (*>) = thenA

The implementation would be chosen at the site of the Applicative instance’s declaration, based on the context available to that instance. So if there’s a corresponding Monad that doesn’t require more context than Applicative does, you get thenM; else, thenA.

This also seems generally useful, and can be done in the type checker instead of in Core (and doesn’t change semantics based on whether optimization is enabled). Downside is that there are still cases that the hypothetical rule-with-instance-resolution feature would catch that this one wouldn’t, since the instance resolution there would happen dynamically at the use site instead of at the instance declaration, and therefore may have more context with which to solve for Monad.

3 Likes