Refactoring with Applicative - a small example in Haskell

Recently I came across a neat little refactoring example that I thought might prove useful, particularly to those starting out with Haskell.

Setting the scene

Say you have a file of comma-separated data, where each row is meant to represent the length, breadth and height of a box. Our task is to calculate the volume of each box. Simple, right? Well, like in real life, the data is not complete and sometimes values are missing. We want to handle these cases by simply not calculating a volume if any of the dimensions of the box are missing.

l,b,h (inputs)      v (our output)
---------------------------------
4,5,1               20
1,,                 -
3,4,                -
1,2,4               8

Maybe volume

We'll work towards a function to calculate volume - something with a signature like this:

maybeVolume :: Maybe Length -> Maybe Breadth -> Maybe Height -> Maybe Volume

To illustrate the patterns at play in this example let's start with something a bit simpler. Let's work with Area before we get to Volume.

A great place to start is with some data types.

newtype Length =
  Length {
    getLength :: Int
  } deriving (Eq, Show)
newtype Width =
  Width {
    getWidth :: Int
  } deriving (Eq, Show)
newtype Area =
  Area {
    getArea :: Int
  } deriving (Eq, Show)

So with these types our pure function for area is:

area :: Length -> Width -> Area
area l w =
  Area $ (getLength l) * (getWidth w)

Working towards the problem at hand, let's extend this to a function that handles missing values. Maybe encodes the concept that a value may be missing. Following a simple pattern matching approach gets us:

marea :: Maybe Length -> Maybe Width -> Maybe Area
marea ml mw =
  case ml of
    Nothing ->
      Nothing
    Just l ->
      case mw of
        Nothing ->
          Nothing
        Just w ->
          Just $ area l w

This is fine but it is a little verbose and is ripe for some simplification.

Let's use the fact that Maybe is a monad. The bind operation passes through Just, while Nothing will force the result to always be Nothing. Using do notation we can rewrite marea like so:

marea' :: Maybe Length -> Maybe Width -> Maybe Area
marea' ml mw = do
  l <- ml
  w <- mw
  pure $ area l w

The shape of marea' is exactly the motivating example for introducing applicative in McBride and Patterson's, Applicative Programming with Effects. Using applicative notation gets us to a one-liner.

marea'' :: Maybe Length -> Maybe Width -> Maybe Area
marea'' ml mw =
  area <$> ml <*> mw

Armed with these examples, let us return to maybeVolume. We will need a couple of extra data types.

newtype Breadth =
  Breadth {
    getBreadth :: Int
  } deriving (Eq, Show)
newtype Volume =
  Volume {
    getVolume :: Int
  } deriving (Eq, Show)

And then our pure function for volume is simply:

volume :: Length -> Width -> Breadth -> Volume
volume l w b =
  Volume $ (getLength l) * (getWidth w) * (getBreadth b)

Skipping over the simplest version of the function (that uses pattern matching), here are the monadic and applicative versions of maybeVolume

-- monadic approach
mvolume :: Maybe Length -> Maybe Width -> Maybe Breadth -> Maybe Volume
mvolume ml mw mb = do
   l <- ml
   w <- mw
   b <- mb
   pure $ volume l w b

-- and with applicative
mvolume' :: Maybe Length -> Maybe Width -> Maybe Breadth -> Maybe Volume
mvolume' ml mw mb =
    volume <$> ml <*> mw <*> mb

To wrap up, we've seen how to take perfectly good code that uses pattern matching, to code that uses idiomatic Haskell patterns like monadic do notation, and composable applicative notation. It's routine to run into monads and applicatives in Haskell codebases and I found simple examples like this one to help concretely connect with them.

Highly recommend the McBride and Patterson paper on applicatives by the way. Same goes for pretty much anything written by Conor McBride.