DEV Community

Brian Berns
Brian Berns

Posted on • Updated on

Applicatives in F#

Background

Previously, we learned about applicative functors, which are souped-up functors that support multi-argument mapping functions. In this post, I'd like to dive deeper into the practical usage of applicatives in F#.

We described an applicative as a type that:

  • Is a functor, which means that it has a unary (i.e. single-argument) map function (and, thus, an infix version of the same function, called <!>).
  • Provides a way to apply arguments one at a time, via a <*> function.
  • Provides a way to "lift" values into the applicative.

Example

F#'s Option<'t> type is an applicative:

let (<!>) = Option.map

let (<*>) fOpt xOpt =
    match fOpt, xOpt with
        | Some f, Some x -> Some (f x)
        | _ -> None

let lift x = Some x
Enter fullscreen mode Exit fullscreen mode

This allows us, for example, to use multiplication to "map" over two options at once:

let mult = (*)

// functor: times3 takes one argument
let times3 = mult 3
Option.map times3 (Some 4)   // Some 12
Option.map times3 None       // None

// applicative: mult takes two arguments
mult <!> Some 3 <*> Some 4   // Some 12
mult <!> None   <*> Some 4   // None
mult <!> Some 3 <*> None     // None
Enter fullscreen mode Exit fullscreen mode

Pipes

Applying arguments one at a time with <*> is certainly a legitimate way to think about applicatives, but (IMHO) it's not the most intuitive, nor is it used often in F#. I think it's easier to conceptualize applicatives with the lifted function (e.g. mult) at the end of a computation, instead of the beginning. Following the FParsec library, we'll call this "pipes" style:

let pipe2 a b f = f <!> a <*> b
Enter fullscreen mode Exit fullscreen mode

In the case of options, pipe2 has the following signature:1

Option<'a> -> Option<'b> -> ('a -> 'b -> 'c) -> Option<'c>
Enter fullscreen mode Exit fullscreen mode

So pipe2 is a function that sends the result of computations a and b to function f, producing a new computation. We can see it in action with options here:

pipe2 (Some 3) (Some 4) mult   // Some 12
pipe2 None (Some 4) mult       // None
Enter fullscreen mode Exit fullscreen mode

We can define pipe3, pipe4, etc. similarly.

Using pipes style, we can think of applicatives as performing computations in parallel at the same time, with no dependencies between them. For example, consider:

let runMult getOptA getOptB =
    pipe2
        (getOptA ())
        (getOptB ())
        mult
Enter fullscreen mode Exit fullscreen mode

Note that getOptB is executed even if getOptA returns None. There's no way to short-circuit the computation before getOptB is invoked.

Monad-lite

Contrast this with monads, which are a way of performing computations in series, so that the result of one computation flows into the next. In fact, instead of thinking of applicatives as souped-up functors, it's often easier to think of them as a "lite" version of monads.2 Let's see where this notion takes us, starting with a basic computation builder for options:

type OptionBuilder() =
    member __.Return(x) = Some x
    member __.Bind(opt, binder) = Option.bind binder opt

let option = OptionBuilder()
Enter fullscreen mode Exit fullscreen mode

Using this builder, we can create monadic computation expressions, such as:

option {
    let! a = optA
    let! b = optB
    return a * b
}
Enter fullscreen mode Exit fullscreen mode

Note that the computation of b does not depend in any way on the value of a, so a monad is overkill for this computation - it is merely applicative. We can enforce this limitation by removing the Bind method from the computation builder, but then the expression doesn't compile at all.

F# 5 enhancements

Fortunately, F# now supports applicative computation expressions directly. There are two new core builder methods:

  • BindReturn: Corresponds to <!>, which is just map.
  • MergeSources: Similar to <*> and pipe2, except that it always produces a 2-tuple (instead of calling a function).

For our option builder, their implementation looks like this:

member __.BindReturn(opt, f) =
    Option.map f opt

member __.MergeSources(optA, optB) =
    match optA, optB with
        | Some a, Some b -> Some (a, b)
        | _ -> None
Enter fullscreen mode Exit fullscreen mode

Now that these two methods are present in the builder, and! can be used instead of let! to create an applicative expression:

option {
    let! a = optA
    and! b = optB   // OK because b does not depend on a
    return a * b
}
Enter fullscreen mode Exit fullscreen mode

Because the applicative computations are independent, they can potentially be parallelized by the compiler, resulting in more efficient code. So be on the lookout for opportunities to use and! instead of let! in your F# code!

References


  1. Haskell calls this function liftA2 instead of pipe2, where the A stands for "applicative". 

  2. So, just as every applicative is also a functor, every monad is also an applicative. However, not every applicative is a monad, which is what makes the applicative pattern useful on its own, situated between its two better-known cousins. 

Top comments (0)