( I’m posting this here for future reference in mentoring sessions. But by all means feel free to ask questions here as well. )
Note: this post is liable to be edited heavily as a result of responses and future insights.
Perspectives on Applicative
The standard definition of the Applicative
type class perhaps isn’t too easy to intuitively grasp the sense of. Luckily, other classes exist that are easier to understand and that are equivalent to it.
By equivalent I mean: any instance of Applicative
is also an instance of the alternative class, and vice versa.
If you understand an alternative perspective well, and have some insight into how this alternative perspective translates to (and from) Applicative
, then this might provide extra intuition for Applicative
. To facilitate this, I show two equivalent classes below.
The standard perspective
The following is the standard definition of Applicative
.
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
I will not belabour it here; your other learning resources should already cover it in detail.
The laws that all instances of this class should obey are
-
Identity —
pure id <*> v = v
-
Composition —
pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
-
Homomorphism —
pure f <*> pure x = pure (f x)
-
Interchange —
u <*> pure y = pure ($ y) <*> u
It is widely agreed that these are ugly.
The monoidal perspective
As it turns out, some Functor
ial structures lend themselves to ‘merging’ in a nice way. These functors are instances of the following Monoidal
type class.
class Functor f => Monoidal f where
unit :: f ()
(<×>) :: f a -> f b -> f (a, b)
Example instances
instance Monoidal Maybe where
unit :: Maybe ()
unit = Just ()
(<×>) :: Maybe a -> Maybe b -> Maybe (a, b)
Just x <×> Just y = Just (x, y)
_ <×> _ = Nothing
instance Monoid r => Monoidal ((,) r) where -- pairs: (r, _)
unit :: (r, ())
unit = (mempty, ())
(<×>) :: (r, a) -> (r, b) -> (r, (a, b))
(m, x) <×> (n, y) = (m <> n, (x, y))
instance Monoidal ((->) e) where -- functions: e -> _
unit :: e -> ()
unit = \_ -> ()
(<×>) :: (e -> a) -> (e -> b) -> e -> (a, b)
f <×> g = \x -> (f x, g x)
instance Monoidal [] where
unit :: [()]
unit = [()]
(<×>) :: [a] -> [b] -> [(a, b)]
xs <×> ys = [(x, y) | x <- xs, y <- ys]
This perspective emphasizes the structure combining aspect of Applicative
. The structure being the f
part in the types. This class is named after monoids because its methods should satisfy the following laws, which look a lot like the Monoid
laws indeed:
-
Left identity —
unit <×> v ≅ v
-
Right identity —
u <×> unit ≅ u
-
Associativity —
u <×> (v <×> w) ≅ (u <×> v) <×> w
These definitely look a lot simpler!
The equality in these laws is not strict. We use the ≅ symbol to indicate sorta-equality: we consider (a, (b, c)) ≅ ((a, b), c)
and (a, ()) ≅ a ≅ ((), a)
to be true. This sloppiness with types is not necessary, but does make the laws easier to read. The equalities can be made strict through insertion in the right places of functions that convert between these types.
The lifting perspective
Consider the Functor
type class:
class Functor f where
fmap :: (a -> b) -> (f a -> f b)
It allows ‘lifting’ of unary functions, i.e. functions of one argument. Given a function, fmap
will produce another function.
As it turns out, some Functor
s allow lifting of n-ary functions: functions of n arguments, where n can be any natural number (as opposed to just 1). These functors are instances of the following LiftA
type class.
class Functor f => LiftA f where
liftA0 :: a -> f a
liftA1 :: (a -> b) -> (f a -> f b)
liftA2 :: (a -> b -> c) -> (f a -> f b -> f c)
liftA3 :: (a -> b -> c -> d) -> (f a -> f b -> f c -> f d)
{- etc. -}
-- Example:
f :: Bool -> Int -> Char -> String
f p n c = let s = replicate n c in if p then map toUpper s else s
-- >>> liftA3 f (Just True) (Just 3) (Just 'a')
-- Just "AAA"
Example instances
instance LiftA Maybe where
liftA0 x = Just x
liftA1 f (Just x) = Just (f x)
liftA1 f _ = Nothing
liftA2 f (Just x) (Just y) = Just (f x y)
liftA2 f _ _ = Nothing
liftA3 f (Just x) (Just y) (Just z) = Just (f x y z)
liftA3 f _ _ _ = Nothing
-- etc.
instance Monoid r => LiftA ((,) r) where -- pairs: (r, _)
liftA0 x = ( mempty , x )
liftA1 f (m, x) = ( m , f x )
liftA2 f (m, x) (n, y) = ( m <> n , f x y )
liftA3 f (m, x) (n, y) (o, z) = ( m <> n <> o, f x y z )
-- etc.
instance LiftA ((->) e) where -- functions: e -> _
liftA0 x = \_ -> x
liftA1 k f = \x -> k (f x)
liftA2 k f g = \x -> k (f x) (g x)
liftA3 k f g h = \x -> k (f x) (g x) (h x)
-- etc.
instance LiftA [] where
liftA0 x = [ x ]
liftA1 f xs = [ f x | x <- xs ]
liftA2 f xs ys = [ f x y | x <- xs, y <- ys ]
liftA3 f xs ys zs = [ f x y z | x <- xs, y <- ys, z <- zs ]
-- etc.
This class actually isn’t expressible in Haskell, because it contains an infinite series of methods. But the pattern is obvious, no?
The types of liftA0
and liftA1
should look familiar: they are the same as the types of pure
and fmap
. In fact, these functions should be identical.
The number of methods might be infinite, but they are all strongly related:
-
liftA1
can be defined in terms ofliftA0
, -
liftA2
can be defined in terms ofliftA1
, -
liftA3
can be defined in terms ofliftA2
, - etc.
And all in the same way, with the help of some operator of type f (a -> b) -> f a -> f b
Hence, to derive all methods of this class, it suffices to start with only liftA0
/pure
and this mystery operator.