Applicatives: Cooking up Compositions With Ease

Explore Applicatives in functional programming, stepping up from Functors to apply multiple functions to values within a context. Delve into a structured approach for independent computations, setting the stage for advanced abstractions like Monads.

Applicatives: Cooking up Compositions With Ease

Overview

This piece is part of a series on functional programming. If you haven't yet, please check out the earlier articles on Algebraic Data Types (ADTs) and Functors to get up to speed.

Stepping into functional programming, we've explored the basics with Algebraic Data Types (ADTs) and dived deeper with Functors. Now, it's time to add a new tool to our toolkit - Applicatives.

Applicatives, also known as Applicative Functors, sit between Functors and Monads in functional programming. 

Unlike Functors, which operate in a single context, Applicatives allow us to work across multiple contexts. This is like moving from being able to add spices to a single dish to orchestrating the flavors across a multi-course dinner.

Consider a scenario where we are validating multiple fields on a form. With Functors, we could apply validation to each field independently. However, if we want to accumulate all errors across fields without stopping at the first one, Applicatives are our go-to tool.

In this article, we'll delve into the core of Applicatives, explore the laws they adhere to, and look at practical examples to understand their use. We’ll also touch on how they pave the way to Monads, our next stop in this functional journey.


Understanding Applicatives

Let's build upon the foundation laid by Functors to extend our ability to work with context-bound values.

Applicatives, also known as applicative functors, are a type that allows function application within a context. They can be viewed as an extension of Functors. While a Functor will enable us to apply a function to a value in a context using the map method, an Applicative also facilitates function application but with the ability to handle multiple context-bound values.

The Interface of an Applicative

To be an applicative an ADT should provide the following methods: pure and apply.

  • Pure takes a value and returns the value wrapped in a context. Putting a value within a context is usually referred to as lifting in technical literature.
  • Apply (apply or ap) takes a function wrapped in a context and a value wrapped in the same context and returns a new function that takes and returns values wrapped in that context. In technical literature, this is often called lifting the function into the Applicative.

The Functor Connection

Every Applicative is also a Functor. This relationship is fundamental as the map method of Functor can be defined in terms of apply and pure of Applicative.

This relationship is usually described as "if we get an Applicative, we get a Functor for free."

Here’s a simplified illustration in Scala-like pseudocode:

Scala 3 Haskell

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

trait Applicative[F[_]] extends Functor[F] {
  def apply[A, B](ff: F[A => B]): (F[A] => F[B])
  def pure[A](a: A): F[A]

  // Implement map using apply and pure
  override def map[A, B](fa: F[A])(f: A => B): F[B] =
    apply(pure(f))(fa)
}

In the above snippet, the Applicative trait extends the Functor trait, allowing us to pass instances of Applicative types to algorithms expecting a Functor.

We wrote a default implementation for map using its apply and pure methods. By implementing the Applicative in our types, we'll get the Functor for free; Applicatives extend the capabilities of Functors to handle a more sophisticated level of function application within a context.


Applicative Laws

Like Functors, Applicatives define laws, ensuring all implementations behave consistently and predictably. These laws help us reason about the code and are crucial for understanding the essence of Applicatives.

Besides the Functor laws, the Applicative should obey two more laws. The four laws that any Applicative must satisfy are:

Identity: apply(pure(id))(Ax) == Ax

Ax is a value inside an applicative context, and it is the identity function.

This law states that lifting the identity function (pure(id)) and applying it to the value inside the context (Ax) should result in precisely the same value.

Composition: apply(comp(f, g), Ax) == comp(apply(f), apply(g))(Ax)

The Functor version of this law says that repeated mapping of functions to a functor is the same as first composing the functions and then mapping the combined function. 

In the Applicative, this law asserts the same, but in the applicative context. To understand the definition, we need to define the terms:

  1. f and g are simple functions not in the Applicative context, and their signatures align (f: A => B , g: B => C), allowing us to call g with the result of f.
  2. comp is a function that takes two aligned functions and returns their composition. In our Scala-like pseudo-code, this will look as follows:
Scala 3 Haskell

def comp[A, B, C](f: A => B, g: B => C): A => C = {
  a: A => g(f(a))
}         

Armed with this knowledge, we can interpret the law as lifting the composition of two functions into the Applicative, which is the same as lifting each function individually and then composing the lifted versions.

Homomorphism: apply(pure(f))(pure(x)) == pure(f(x))

If we have a function and a value, both without context, lifting the function and the value to a context and executing the Applicative (calling apply), should be the same as executing the function with the given value normally and then lifting the result.

Interchange: apply(Af)(pure(x)) == apply(pure(g => g(x))(Af)

This law states that applying a function within a context to a lifted value is the same as putting the value into a function that takes any function and applies it to x, lifting that function and running the Applicative with it and the original function within the context.

Simply put, it states that the application behaves like a regular function application, just doing it inside the Applicative context.

These laws provide a framework for understanding the behavior of Applicatives and allow us to reason about function application in a context in a structured way. They ensure that Applicatives behave predictably, which is crucial for writing reliable and maintainable code.


Unveiling the Features and Utility of Applicatives

Applicatives offer a step up from Functors by allowing multiple function applications within a context. They shine in scenarios where we need to apply several computations to data in a context, and the computations are independent.

One such practical scenario is form validation. Let's consider a web form with two fields: username and email. We want to validate these fields by applying a series of independent validation functions to each field. In Scala, we can model this scenario using an Applicative instance for Lists.

Here's a simplified example:

Scala 3 Haskell

trait Functor[F[_]]:
  def fmapply[A, B](fa: F[A])(f: A => B): F[B]


trait Applicative[F[_]] extends Functor[F]:
  def pure[A](a: A): F[A]
  def apply[A, B](ff: F[A => B])(fa: F[A]): (F[B])

  // Implement map using apply and pure
  override def fmapply[A, B](fa: F[A])(f: A => B): F[B] = 
    ap(pure(f))(fa)


// Implement the Applicative instance for Lists
given ListApplicative: Applicative[List] with {
  def pure[A](x: A): List[A] = List(x)
  def apply[A, B](ff: List[A => B])(fa: List[A]): List[B] =
    def helper(ff1: List[A => B], fa1: List[A], acc: List[B]): List[B] = {
      (ff1, fa1) match {
        case (   Nil,     _) => acc
        case (f::ffs,   Nil) => helper(ffs, fa, acc)
        case (f::ffs, a::as) => helper(ff1, as, f(a) :: acc)
      }      
    } 
    helper(ff, fa, List.empty)
}

case class Form(id: Int, username: String, email: String, age: Int)

type Validated[+A] = Either[String, A]

def validateUsername(form: Form): Validated[Form] =
  if (form.username.nonEmpty) Right(form)
  else Left(s"Form ${form.id}: Username cannot be empty")

def validateEmail(form: Form): Validated[Form] =
  if (form.email.contains("@")) Right(form)
  else Left(s"Form ${form.id}: Invalid email")

def validateAge(form: Form): Validated[Form] =
  if (form.age >= 18) Right(form)
  else Left(s"Form ${form.id}: Age must be at least 18")

def validate(forms: List[Form])(using appl: Applicative[List]): List[Validated[Form]] = {
  val validations = List(validateUsername, validateEmail, validateAge)

  
  return appl.ap(validations)(forms)  
}

@main def run(): Unit = {
  val forms = List(
    Form(1, "Alice", "alice@example.com", 25),
    Form(2, "", "bob@example.com", 20),
    Form(3, "Charlie", "charlie@", 17)
  )

  val results = validate(forms)

  // Display the results
  results.foreach(println)  
  // Left(Form 3: Age must be at least 18)
  // Right(Form(2,,bob@example.com,20))
  // Right(Form(1,Alice,alice@example.com,25))
  // Right(Form(3,Charlie,charlie@,17))
  // Right(Form(2,,bob@example.com,20))
  // Right(Form(1,Alice,alice@example.com,25))
  // Right(Form(3,Charlie,charlie@,17))
  // Left(Form 2: Username cannot be empty)
  // Right(Form(1,Alice,alice@example.com,25))
}
In future articles, we'll learn how to use Scala 3 features to make the use of the Applicative (and similar classes) more idiomatic.

In this setup, we first define a simple Form case class to hold the username and email fields. We wrote an Applicative trait and implemented it for Lists.

Let's take a closer look at our Applicative instance. We could explain it as defining how to apply many functions over many values with a single call. Next, we define validation functions for each field we want to verify.

Each function takes a Form object and returns a Validated with the valid Form or an error message. Then we put the functions into a List, and run it as an Applicative over a List of Forms.

At the end, we have a List of Validated Forms or the errors found on each form. This way, we achieve a series of independent validations on each form, demonstrating the power of Applicatives.

We can use them to handle many computations in a structured and composable manner. Having Applicatives in our developer's toolkit allows us to simplify our applications.

This is because we can reuse the design pattern and the code whenever we need to run many functions on some inputs wrapped in an Applicative context.


Conclusion

Delving into Applicatives has equipped us to apply many functions to values within a context. This step up from Functors enables a structured approach to handling independent computations, as illustrated in our form validation example.

The journey from Functors to Applicatives is like moving from cooking a single dish to preparing a full-course meal.

The pathway to exploring more advanced abstractions like Monads is now visible as we wrap up. But for now, we can appreciate the simplicity and structure Applicatives bring to our code.

Stay tuned for the next installment in our series, where we'll unravel the sequencing magic of Monads.


Addendum: A Special Note for Our Readers

Before we sign off, we’d like to share something special with our readers who’ve appreciated the visual journey accompanying our articles.

We invite you to visit the TuringTacoTales Store on Redbubble. Here, you can explore a range of products featuring these unique and inspiring designs.

Each item showcases the beauty and creativity inherent in coding. It’s a perfect way to express your love for programming while enjoying high-quality, artistically crafted merchandise:

So, please take a moment to browse our collection and perhaps find something that resonates with your passion for Python and programming!