Why is ap available inside Applicative?

Viewed 93

I am trying to implement MonadUnliftIO for Snap and analyzing Snap classes. I discovered that ap is used for implementing Applicative while ap requires Monad and Monad requires Applicative. It looks like a loop.

I thought till now that is not possible to write such things. What is the limit for such kind of trick?


class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b

class Applicative m => Monad m where
  return :: a -> m a
 
instance Applicative Snap where
  pure x = ...
  (<*>) = ap

ap :: Monad m => m (a -> b) -> m a -> m b

3 Answers

This only works because Snap has a Monad instance (and it's actually in scope at that point).

Effectively, the compiler handles declarations in two separate passes: first it resolves all the instance heads

instance Applicative Snap
instance Monad Snap

...without even looking in the actual method implementations. This works out fine: Monad is happy as long as it sees the Applicative instance.

So then it already knows that Snap is a monad. Then it proceeds to typecheck the (<*>) implementation, notices that it requires the Monad instance, and... yeah, it's there, so that too is fine.

The actual reason we have ap :: Monad m => ... is mostly historical: the Haskell98 Monad class did not have Applicative or even Functor as a superclass, so it was possible to write code Monad m => ... that could then not use fmap or <*>. Therefore the liftM and ap functions were introduced as replacement.

Then, when the better current class hierarchy was established, many instances were simply defined by referring back to the already existing Monad instance, which is after all sufficient for everything.

IMO it is usually a good idea to directly implement <*> and definitely fmap before writing the Monad instance, rather than the other way around.

I think you are imagining a cycle like this:

  • (<*>) is implemented with ap
  • (>>=) is implemented with (<*>)
  • ap is implemented using (>>=)

And yes, if you try this, it will indeed give you an infinite loop!

However, this is not what your code block does. Its implementations look more like this:

  • (>>=) is implemented from first principles, without using any Applicative functions
  • ap is implemented using (>>=)
  • (<*>) is implemented in terms of ap

Which is obviously fine — there’s no cycles of any sort in this set of function definitions.

One thing which might still be a bit confusing is: how can you implement an Applicative function in terms of a Monad function, when a type can only be a Monad if it is already Applicative? To answer this, let’s add explicit type signatures to your code sample (note this requires language extensions to compile):

class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b

class Applicative m => Monad m where
  return :: a -> m a
 
instance Applicative Snap where
  pure :: a -> Snap a
  pure x = ...

  (<*>) :: Snap (a -> b) -> Snap a -> Snap b
  (<*>) = ap

ap :: Monad m => m (a -> b) -> m a -> m b

The answer is now clear: we are not in fact defining (<*>) for just any arbitrary Applicative type! Rather, we are defining it for Snap only, which means we can use any function defined to work on Snaps — including those from the Monad typeclass. The fact that this function happens to be within an instance Applicative Snap block doesn’t matter: in all other respects, it’s just an ordinary function definition, and there’s no reason why the full range of Snap functions shouldn’t be able to appear in it.

There should be some instance Monad Snap somewhere else. The ap use in the Applicative instance will make use of >>= from that instance.

In general, an instance for Applicative can not make use of ap in this way, but when then applicative is also a monad, I think it is quite common to do so, since it's convenient.

Note that, if one chooses this route, it should avoid using <*> or ap inside the definition of >>=, since that could lead to infinite recursion.

The fact that the two instances are mutually recursive, in some sense, is not an issue. Haskell allows mutual recursion, and this also reflects on instances. The programmer however must ensure that the recursion actually terminates, or be prepared to have a non-terminating program.

Related