44 KiB
title | description | author | date | published | tags | description | layout | comments | thumbnail | og | code_repo |
---|---|---|---|---|---|---|---|---|---|---|---|
Enabling Control Flow in Functional Programming | Leveraging the effects of two or more contexts to allow computations to proceed or halt. | Logan McGrath | 2022-05-07T14:21:23-0700 | 2022-06-05T14:01:07-0700 | functional programming, programming, scala, design patterns, contexts | Remember functors? They are structures that abstract away complexity imposed by nondeterminism present in contexts that produce some output; contexts such as optionality, network interaction, or validation. When contexts fail to produce some output, they are in their undesired case and no computation may be performed against them. In this post we will explore how to exploit this characteristic to halt computation in order to express control flow. | post | true | /images/tags/functional-programming/functional-grass-128x128.png | [{image [{url /images/tags/functional-programming/functional-grass-512x512.png} {alt Leveraging the effects of two or more contexts to allow computations to proceed or halt.}]}] | https://bitsof.thisfieldwas.green/keywordsalad/embracing-nondeterminism-code/src/branch/part2 |
Remember functors? Recall from my last post, {{linkedTitle "_posts/2022-03-15-contexts-and-effects.md"}}, they are structures that abstract away complexity imposed by nondeterminism present in contexts that produce some output; contexts such as optionality, network interaction, or validation. When contexts fail to produce some output, they are in their undesired case and no computation may be performed against them. In this post we will explore how to exploit this characteristic to halt computation in order to express control flow.
This post is part of a series:
- {{linkedTitle "_posts/2022-03-15-contexts-and-effects.md"}}
- {{title}}
- {{linkedTitle "_posts/2022-06-17-imperative-computation.md"}}
The code that accompanies this post may be found here.
Motivating applicative functors as a design pattern
Recall the Functor
typeclass:
:::{.numberLines}
trait Functor[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
}
object Functor {
def apply[F[_]: Functor]: Functor[F] = implicitly[Functor[F]]
}
:::
See here for the definition in the sample repository.
For any context F[A]
, the map()
function accepts another function f: A => B
and applies it to the term within the context, giving back F[B]
.
Contexts that are functors thus allow abstraction over unknown cases. For example, a function f: A => B
may be lifted into an Option[A]
and applied if an instance of A
is present. If no instance of A
is present, then nothing happens. Specifically, by using map()
the function f
is unconcerned with the unknown quantity of A
's presence. Many contexts encode dimensions of unknown quantities, and as functors they completely abstract the nondeterminism of these quantities, allowing the business logic expressed by the lifted functions to focus only on the terms that they operate against.
What this means is that for any context in the desired case, such as a Some
of Option[A]
or a Right
of Either[X, A]
, the function f
will be applied when lifted with map()
. Any number of functions may be applied to the new contexts returned by subsequent applications of map()
, and they will all apply as the initial context was in the desired case. Functors thus may be considered as enablers of data transformation, as lifted functions transform data if it exists. But if the initial context is in an undesired case, none of the lifted functions will apply.
Functions lifted into a context are permitted to compute if the context is in the desired case. But if a function is lifted into a context that is in the undesired case, then computation is halted. This means that the case of any context may control the flow of execution within a program.
You can try for yourself from the sample repository's sbt console
:
:::{.numberLines}
scala> :load console/imports.scala
scala> val desiredOption: Option[Int] = Some(42)
val desiredOption: Option[Int] = Some(42)
scala> desiredOption.map(_.toString).map(numStr => s"backwards: ${numStr.reverse}")
res0: Option[String] = Some(backwards: 24)
scala> val undesiredOption: Option[Int] = None
val undesiredOption: Option[Int] = None
scala> undesiredOption.map(_.toString).map(numStr => s"backwards: ${numStr.reverse}")
res1: Option[String] = None
:::
Functors only allow lifting functions of the form f: A => B
. The context can't be modified with a function having this signature, which means we can't use a functor specifically to influence control flow by injecting a context in its undesired case. Functors respect the existing case of a context: they cannot modify it.
Introducing control flow
What function might allow for control flow using contexts? There is one, map2()
:
:::{.numberLines}
def map2[F[_], A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C): F[C]
:::
This two-argument analog of map()
unlocks a key capability: controlling whether to proceed or halt computation against the terms contained within the contexts fa
and fb
. If both fa
and fb
are in their desired case, then there are instances of A
and B
against which the function f
may be applied. But if either one or both are in their undesired case, then f
does not apply, and the undesired cases are propagated through F[C]
. This means that fa
and fb
become levers with which to halt computation that would be performed using function f
and subsequent computation against context F[C]
.
Take for example addition lifted into the context of Option[Int]
within the sample repository's sbt console
:
:::{.numberLines}
scala> :load console/imports.scala
scala> val F = Applicative[Option]
val F: Applicative[Option] = Option$Instances$$anon$1
scala> F.map2(Option(2), Option(2))(_ + _)
val res0: Option[Int] = Some(4)
scala> F.map2(Option(2), Option[Int]())(_ + _)
val res1: Option[Int] = None
scala> F.map2(Option[Int](), Option(2))(_ + _)
val res2: Option[Int] = None
scala> F.map2(Option[Int](), Option[Int]())(_ + _)
val res3: Option[Int] = None
:::
Only when both arguments are in their desired case does the function of addition apply. If either or both arguments are in their undesired case, then the undesired case is propagated. This does not allow the function to apply, and halts any further operations against the context. The functions that produce the two input contexts are thus capable of controlling whether computation via f
proceeds and permits further computation against the context F[C]
.
Applicative functors
The map2()
function is implemented using a new structure, a specialization of a functor called an applicative functor, or simply an applicative.
Applicative functors as a specialization arise in the type of A
contained within a functor F[_]
. If A
is merely an opaque type, then F[A]
is a functor and no more. But if A
is specifically known to have some type A => B
, that is to say A
is a function, then F[A => B]
is an applicative functor.
Applicatives define two functions from which many more are derived, including map2()
:
:::{.numberLines}
trait Applicative[F[_]] extends Functor[F] {
def pure[A](a: A): F[A]
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]
// other functions are introduced below
}
object Applicative {
def apply[F: Applicative]: Applicative[F] = implicitly[Applicative[F]]
}
:::
See here for the definition in the sample repository.
Note that Applicative
extends Functor
as it is a specialization. All applicatives are also functors and therefore also provide the map()
function.
The new functions defined by applicatives offer two capabilities:
pure()
which lifts the result of a pure computationA
into the context such thatpure: A => F[A]
. This is essentially a constructor producing a context in the desired case, such asSome
forOption[A]
orRight
forEither[X, A]
. In short,pure()
putsA
in the box.ap()
, read as apply, for applying a lifted function to a lifted argument. Given two boxes, if the first contains a function and the second contains an argument,ap()
will apply them and put them back in the box.
You might be wondering why a function would ever be lifted into a context? I will demonstrate why this is desirable in how ap()
works by defining map2()
within Applicative
:
:::{.numberLines}
def map2[A, B, C](fa: F[A], fb: F[B])(f: (A, B) => C): F[C] =
ap(ap(pure(f.curried))(fa))(fb)
:::
Lifting f
into the context works following these steps:
- By currying the function
f: (A, B) => C
, it becomesA => B => C
. - Lifting it with
pure()
givesF[A => B => C]
in the desired case, which gives us a clean slate to start our computation with. - Then, with
ap()
we may apply the first argumentfa: F[A]
which will give backF[B => C]
in the desired case iffa
is itself in the desired case. - Then, with
ap()
we may apply the second argumentfb: F[B]
which will give backF[C]
in the desired case iffb
is itself in the desired case.
Each step of lifted function application accounts for the case of the function and argument contexts and halts if either context is in the undesired case. The undesired case will propagate instead through F[C]
if and when it exists.
You might have noticed, map2()
looks an awful lot like map()
. In fact, Applicative
provides a default implementation of map()
following the same pattern:
:::{.numberLines}
def map[A, B](fa: F[A])(f: A => B): F[B] =
ap(pure(f))(fa)
:::
If your context implements Applicative
, then it also implements Functor
with no extra work. You can always provide your own implementation of map()
if it is more efficient to do so.
Validation as a use case
Let's walk through a powerful capability of applicatives: validation.
Validating a User
from external data
When constructing a User
from data that we receive from an external source, such as a form or API, we can use ap()
to lift User
's curried constructor into a validation context and apply it to validated arguments. If all arguments are valid, then we should receive a validation context containing a valid User
. If any arguments are invalid, then we should receive an invalid context with all reasons for validation failure.
Here is the definition for User
:
:::{.numberLines}
case class User(username: String, email: String, password: String)
:::
Each of username
, email
, and password
must to be valid in order for User
itself to be valid. This requires the introduction of a validation context:
:::{.numberLines}
sealed trait Validated[+E, +A] {
def invalid: E
def valid: A
}
case class Valid[+A](valid: A) extends Validated[Nothing, A] {
def invalid: Nothing = throw new NoSuchElementException("Valid.invalid")
}
case class Invalid[+E](invalid: E) extends Validated[E, Nothing] {
def valid: Nothing = throw new NoSuchElementException("Invalid.valid")
}
:::
See here for the definitions in the sample repository.
Why use a new Validated
context instead of Either
?
The Applicative
instance for Either
in the context of validation has one key flaw: it short-circuits on the first Left
.
Refer to Either
's implementation of ap()
:
:::{.numberLines}
override def ap[A, B](ff: Either[X, A => B])(fa: Either[X, A]): Either[X, B] =
(ff, fa) match {
case (Right(f), Right(a)) => Right(f(a))
case (Left(x), _) => Left(x)
case (_, Left(x)) => Left(x)
}
:::
See here for
Either
's definition ofap()
.
For both cases of Left
they are immediately returned and there is no specific handling for situations where both ff
and fa
may be in the Left
case. This means that the first Left
propagates and all subsequent Left
s are swallowed. In the context of validation, this means that for any number of validation errors that the context might produce, we would only receive the first error. We would have to resolve the error and re-run the operation, and repeat for each subsequent error until the operation as a whole succeeded. This makes Either
a very poor choice for modeling validation. It represents strictly one thing or the other, whereas validation we can specialize to propagate all reasons for failure.
Validation via Applicative
Validated
contains the valid value you want or the reasons for invalidation. We could fail to receive a User
for three or more reasons related to username
, email
, and password
all being invalid, which implies that term E
represents some data containing one or more of something. This has a specific implication on how we define an instance of Applicative
for Validated
:
:::{.numberLines}
implicit def validatedApplicative[E]: Applicative[Validated[E, *]] =
new Applicative[Validated[E, *]] {
def pure[A](a: A): Validated[E, A] = Valid(a)
def ap[A, B](ff: Validated[E, A => B])(fa: Validated[E, A]): Validated[E, B] =
(ff, fa) match {
case (Valid(f), Valid(a)) => Valid(f(a))
case (Invalid(x), Invalid(y)) => ??? /* what do we do with these two E's? */
case (Invalid(x), _) => Invalid(x)
case (_, Invalid(y)) => Invalid(y)
}
}
:::
When there are two instances of E
we don't have a way to combine them as E
is an opaque type. Without concretely defining E
, such as with List[String]
or another similar structure, we won't be able to combine their values, but this creates an inflexible API. Specifically, this inability to combine E
leaves Validated
in the same position that Either
is in: the first undesired case propagates and subsequent cases are swallowed. How do we combine the undesired cases?
Modeling combinable structures
Structures defining a combine()
function form a typeclass known as a semigroup under a specific condition: that combine()
is associative. Semigroups are very common, and constraining E
to have an instance of Semigroup
provides great API flexibility. First, let's see how the Semigroup
typeclass is defined:
:::{.numberLines}
trait Semigroup[A] {
def combine(left: A, right: A): A
}
object Semigroup {
def apply[A: Semigroup]: Semigroup[A] = implicit[Semigroup[A]]
}
:::
See here for the definition in the sample repository.
A semigroup is thus an additive form of data. Here's a few familiar data types that you may have used as semigroups without realizing it:
- Lists under concatenation
- Strings under concatenation
- Integers under addition
- Booleans under
&&
- Sets under union
The combine()
function must be associative. This can be tested with a scalacheck
property:
:::{.numberLines}
def checkSemigroupLaws[S: Semigroup: Arbitrary](): Unit = {
import green.thisfieldwas.embracingnondeterminism.syntax.semigroup._
property("Semigroup preserves associativity") {
forAll(for {
a <- arbitrary[S]
b <- arbitrary[S]
c <- arbitrary[S]
} yield (a, b, c)) { case (a, b, c) =>
(a |+| b) |+| c mustBe a |+| (b |+| c)
}
}
}
:::
See here for the definition in the sample repository.
Given an E
with an instance of Semigroup
, we can define an Applicative
instance for Validated
:
:::{.numberLines}
implicit def validatedApplicative[E: Semigroup]: Applicative[Validated[E, *]] =
new Applicative[Validated[E, *]] {
import Semigroup.Syntax._
override def pure[A](a: A): Validated[E, A] = Valid(a)
override def ap[A, B](ff: Validated[E, A => B])(fa: Validated[E, A]): Validated[E, B] =
(ff, fa) match {
case (Valid(f), Valid(a)) => Valid(f(a))
case (Invalid(x), Invalid(y)) => Invalid(x |+| y) // propagate both undesired cases via Semigroup
case (Invalid(x), _) => Invalid(x)
case (_, Invalid(y)) => Invalid(y)
}
}
:::
See here for the definition in the sample repository.
This means that Validated
is usable as an Applicative
for any case where E
is combinable. What implications does this have?
- Errors may exist in a non-zero amount when the context is
Invalid
. - When there are errors, the amount is unbounded.
- Combining errors can be frequent, and should be computationally cheap.
Naively, a List[String]
works for E
. It forms a Semigroup
under concatenation, but concatenation isn't cheap in Scala List
s. It can also be empty per its type, which means that as E
you have to code for invariants where it is actually empty.
There exists a better structure, and we can whip it together pretty quick: the NonEmptyChain
. This structure is a context modeled as two cases: either a single value, or a pair of separate instances of itself appended together. This allows for a List
-like structure with constant-time concatenation that can be converted to a Seq
in linear time.
:::{.numberLines}
trait NonEmptyChain[+A] {
import NonEmptyChain._
def head: A
def tail: Option[NonEmptyChain[A]]
def length: Int
def cons[B >: A](newHead: B): NonEmptyChain[B] = prepend(Singleton(newHead))
def prepend[B >: A](prefix: NonEmptyChain[B]): NonEmptyChain[B] = prefix.append(this)
def append[B >: A](suffix: NonEmptyChain[B]): NonEmptyChain[B] = Append(this, suffix)
def toSeq: Seq[A] = head +: tail.fold(Seq[A]())(_.toSeq)
}
object NonEmptyChain {
def apply[A](value: A, rest: A*): NonEmptyChain[A] =
rest.map[NonEmptyChain[A]](Singleton(_)).foldLeft[NonEmptyChain[A]](Singleton(value))(_ append _)
case class Singleton[+A](head: A) extends NonEmptyChain[A] {
override def tail: Option[NonEmptyChain[A]] = None
override def length: Int = 1
}
case class Append[+A](prefix: NonEmptyChain[A], suffix: NonEmptyChain[A]) extends NonEmptyChain[A] {
override def head: A = prefix.head
override def tail: Option[NonEmptyChain[A]] = prefix.tail.map(suffix.prepend).orElse(Some(suffix))
override def length: Int = prefix.length + suffix.length
}
/** `NonEmptyChain` forms a `Semigroup` under the `append()` function.
*/
implicit def nonEmptyChainSemigroup[A]: Semigroup[NonEmptyChain[A]] = _ append _
}
:::
See here for the definition in the sample repository.
Using a NonEmptyChain
, we can start writing validation functions for User
to test that they work:
:::{.numberLines}
import green.thisfieldwas.embracingnondeterminism.syntax.applicative._
import green.thisfieldwas.embracingnondeterminism.syntax.validated._
case class User(username: String, email: String, password: String)
def validateUsername(username: String): ValidatedNec[String, String] =
if (username.isEmpty) {
"Username can't be blank".invalidNec[String]
} else {
username.validNec[String]
}
def validateEmail(email: String): ValidatedNec[String, String] =
if (!email.contains('@')) {
"Email does not appear to be valid".invalidNec[String]
} else {
email.validNec[String]
}
def validatePassword(password: String): ValidatedNec[String, String] =
List(
Option.cond(password.length < 8)("Password must have at least 8 characters"),
Option.cond(!password.exists(_.isLower))("Password must contain at least one lowercase character"),
Option.cond(!password.exists(_.isUpper))("Password must contain at least one uppercase character"),
Option.cond(!password.exists(_.isDigit))("Password must contain at least one digit"),
Option.cond(!password.exists("`~!@#$%^&*()-_=+[]{}\\|;:'\",.<>/?".contains(_)))(
"Password must contain at least one symbol"
),
).foldLeft(password.validNec[String]) { (validated, errorOption) =>
errorOption.fold(validated) { message =>
Invalid(validated.fold(_.cons(message))(_ => NonEmptyChain(message)))
}
}
def validateUser(username: String, email: String, password: String): Validated[NonEmptyChain[String], User] =
User.curried.pure[Validated[NonEmptyChain[String], *]]
.ap(validateUsername(username))
.ap(validateEmail(email))
.ap(validatePassword(password))
:::
Using this function, we can attempt to create a User
with nothing but invalid data and get the complete collection of errors back:
:::{.numberLines}
val validatedUser = validateUser(
username = "",
email = "bananaphone",
password = "\n\t\r\r\r",
)
inside(validatedUser) { case Invalid(reasons) =>
(reasons.toSeq should contain).theSameElementsAs(
Seq(
"Username can't be blank",
"Email does not appear to be valid",
"Password must have at least 8 characters",
"Password must contain at least one lowercase character",
"Password must contain at least one uppercase character",
"Password must contain at least one digit",
"Password must contain at least one symbol",
)
)
}
:::
And with valid data, receive a constructed User
:
:::{.numberLines}
val validatedUser = validateUser(
username = "commander.keen",
email = "commander.keen@vorticonexterminator.net",
password = "m4rti@an$Rul3",
)
inside(validatedUser) { case Valid(user) =>
user shouldBe User(
username = "commander.keen",
email = "commander.keen@vorticonexterminator.net",
password = "m4rti@an$Rul3",
)
}
:::
See here for the specs in the sample repository.
Applicatives thus enable entire computations to succeed if all context arguments are in the desired case. If any argument is in the undesired case, then this case is propagated and the computation as a whole fails.
Each of validateUsername()
, validateEmail()
, and validatePassword()
act as levers on whether a User
is successfully produced. Writing specific if-statements to guide whether a User
is produced or errors returned instead is not required: the Applicative
typeclass succinctly abstracts away the necessary plumbing to control the flow of logic required to handle undesired cases. Errors are declared where they should occur and the abstraction handles the rest.
Independent evaluation of contexts
It may not have been obvious from validateUser()
, but each validation function evaluates independently of the other validation functions. In the Validation
context, this means that each function executes without impacting the other functions regardless of individual success or failure. Imagine for a moment, what if the functions were evaluated within an asynchronous context?
Let's define a function on the Applicative
typeclass that gathers the results of some effectful operations:
:::{.numberLines}
def sequence[A](listFa: List[F[A]]): F[List[A]] =
listFa.foldLeft(pure(List[A]()))((fListA, fa) => map2(fa, fListA)(_ :: _))
:::
See here for the definition in the sample repository.
And then if we load three User
s at once:
:::{.numberLines}
def loadUser(email: String): Future[User] = ???
val loadingUsers = Applicative[Future].sequence(List(
loadUser("test@email.com"),
loadUser("student@school.edu"),
loadUser("admin@foundation.net"),
))
:::
The variable loadingUsers
now contains Future[List[User]]
. As each Future[User]
resolves, they are collected into a List
. Because each loadUser()
function executes independently, this has a profound implication in the context of a Future
: they are executed concurrently!
Should any User
fail to load, the undesired case will propagate and the sequence()
function will halt. All other User
s are discarded.
The pattern offered by Applicative
is an all-or-nothing result in its output. If all inputs are in the desired case, then the output will be in the desired case as well. But if any are in an undesired case, then the undesired case propagates and computation halts.
Products of contexts
Given a 2-tuple of functors (F[A], F[B])
you can invert the nesting of the context and the 2-tuple using the pure()
and ap()
functions from Applicative
with the following steps:
:::{.numberLines}
scala> :load console/imports.scala
scala> val productOfSomes = (Option(42), Option("banana"))
val productOfSomes: (Option[Int], Option[String]) = (Some(42), Some("banana"))
scala> val someOfProduct = (_: Int, _: String).curried.pure[Option].ap(productOfSomes._1).ap(productOfSomes._2)
val someOfProduct: Option[(Int, String)] = Some((42, "banana"))
:::
This has an important implication, specifically that unlike the sequence()
function these operations allow for gathering effectful operations that produce contexts with heterogeneous terms. Scala allows for tuples up to 22 elements, and it makes sense to abstract the above operations for each tuple size, especially because even at just 2 elements writing all of these out is already clunky!
In the sample repository, I have written a macro which adds two extension methods to each tuple size. Here's the code that is generated for the 2-tuple:
:::{.numberLines}
implicit class Tuple2Ops[F[_], T1, T2](val t: (F[T1], F[T2])) extends AnyVal {
// Converts a product of contexts `(F[T1], F[T2])` into a context of a product `F[(T1, T2)]`
def tupled(implicit F: Applicative[F]): F[(T1, T2)] =
mapN(Tuple2.apply)
// Applies a product of contexts `(F[T1], F[T1])` to function, giving a result in context
def mapN[Out](f: (T1, T2) => Out)(implicit F: Applicative[F]): F[Out] =
F.ap(F.ap(F.pure(f.curried))(t._1))(t._2)
}
:::
Being able to invert the nesting of a tuple and a context is most powerful when constructing case classes from arguments produced by effectful operations. By using the mapN()
function, for example, validating arguments to the User
constructor may be rewritten like this:
:::{.numberLines}
def validateUser(username: String, email: String, password: String): Validated[NonEmptyChain[String], User] =
(
validateUsername(username),
validateEmail(email),
validatePassword(password)
).mapN(User)
:::
This syntax afforded by the mapN()
extension method is much more concise and closely matches the constructor arguments order passed to User
itself. After all, User
is a product of results produced by contexts, which evaluate independently of each other as they do in the sequence()
function.
Becoming an Applicative
In order to become an Applicative
, an effect type must implement the typeclass. Let's implement instances for the usual suspects, Option
, Either
, and List
. As Applicative
is a specialization of Functor
, we can simply upgrade our current Functor
instances to become Applicative
s:
:::{.numberLines}
implicit val optionApplicative: Applicative[Option] = new Applicative[Option] {
// removed map() and now using the default
override def pure[A](a: A): Option[A] = Some(a)
override def ap[A, B](ff: Option[A => B])(fa: F[A]): F[B] =
(ff, fa) match {
case (Some(f), Some(a)) => Some(f(a))
case _ => None
}
}
implicit def eitherApplicative[X]: Applicative[Either[X, *]] = new Applicative[Either[X, *]] {
// removed map() and now using the default
override def pure[A](a: A): Either[X, A] = Right(x)
override def ap[A, B](ff: Either[X, A => B])(fa: F[A]): F[B] =
(ff, fa) match {
case (Right(f), Right(a)) => Right(f(a))
case (Left(x), _) => Left(x)
case (_, Left(x)) => Left(x)
}
}
implicit val listApplicative: Applicative[List] = new Applicative[List] {
// removed map() and now using the default
override def pure[A](a: A): List[A] = List(a)
override def ap[A, B](ff: List[A => B])(fa: List[A]): List[B] =
ff.foldLeft(List[B]()) { (outerResult, f) =>
fa.foldLeft(outerResult) { (innerResult, a) =>
f(a) :: innerResult
}
}.reverse
}
:::
See the instances of
Applicative
forOption
,Either
, andList
in the sample repository.
Option
and Either
's instances of Applicative
are straight-forward: if a function and argument are present, they are applied and the result returned in the desired case. If either are missing, then the undesired case is propagated instead.
List
looks very different at first glance, but conceptually performs the same way. Specifically, List
performs a Cartesian product of its functions and arguments, applying each pair together and building a new List
from the results. If either the function or argument List
are empty, then an empty result List
is returned, as an empty List
represents the undesired case.
Applicative laws
Option
, Either
, and especially List
's Applicative
instances look different. How do we know that they are well-behaved as applicatives? Just like functors, applicatives are expected to conform to a set of laws defined in the higher math of category theory.
There are four applicative laws, which must hold for all applicatives in addition to the functor laws.
- Preservation of identity functions: A lifted identity function applied to a lifted argument is the same as the identity function applied directly to the lifted argument.
- Preservation of function homomorphism: Lifting a function and an argument then applying them produces the same result as applying the unlifted function and unlifted argument then lifting the result.
- Preservation of function interchange: Given a lifted function and an unlifted argument, applying the lifted function after lifting the argument should give the same result as when reversing the order of the function and argument. This is difficult to express in words, and the code is hard to follow, but roughly this translates to
ap(ff: F[A => B])(pure(a)) == ap(pure(f => f(a)))(ff)
. - Preservation of function composition: Given lifted functions
ff: F[A => B]
andfg: F[B => C]
and argumentfa: F[A]
: liftingcompose()
and applyingfg
,ff
, andfa
produces the same result as applyingfg
after applyingff
tofa
.
These laws are rigorous and we can write tests for these to prove that our applicative instances are defined correctly.
Testing "for all" F[_]
In order to properly test our applicative instances, we need to be able to generate a broad range of inputs to verify that the applicative properties hold with a high degree of confidence. Specifically, "for all" List
s, for example, the property checks for Applicative
must pass for each generated instance. We will leverage scalacheck
for property-based testing. scalacheck
will generate for us a set of arbitrary instances of the contexts and execute tests called property checks against each to verify that each check passes. If all checks pass, then the property may be considered to hold "for all" instances of the tested context.
Generating an arbitrary context F[_]
containing an arbitrary A
is not supported directly by scalacheck
, however. We can leverage this typeclass below to enable generating instances of F[A]
from any generator for A
:
:::{.numberLines}
import org.scalacheck.Gen
trait LiftedGen[F[_]] {
def lift[A](gen: Gen[A]): Gen[F[A]]
}
object LiftedGen {
def apply[F[_]: LiftedGen]: LiftedGen[F] = implicitly[LiftedGen[F]]
object Syntax {
implicit class LiftedGenOps[A](val gen: Gen[A]) extends AnyVal {
def lift[F[_]: LiftedGen]: Gen[F[A]] = LiftedGen[F].lift[A](gen)
}
}
}
:::
See here for the definition in the sample repository.
Defining the applicative laws as properties
Now we will walk through defining the properties of Applicative
in such a way that we simply supply the contexts as the argument to the test. This way we only define the properties once.
:::{.numberLines}
trait ApplicativeLaws { this: Laws with FunctorLaws =>
/** Defined per Applicative laws taken from the Haskell wiki:
* [[https://en.wikibooks.org/wiki/Haskell/Applicative_functors#Applicative_functor_laws]]
*
* These laws extend the functor laws, so `checkFunctorLaws[F]()` should be
* executed alongside this function.
*
* @param TT The type tag of the context
* @tparam F The context type being tested
*/
def checkApplicativeLaws[F[_]: Applicative: LiftedGen]()(implicit TT: TypeTag[F[Any]]): Unit = {
property(s"${TT.name} Applicative preserves identity functions") {
// A lifted identity function applied to a lifted argument is the same as
// the identity function applied directly to the lifted argument.
forAll(arbitrary[Double].lift) { v =>
// Haskell: pure id <*> v = v
(identity[Double] _).pure.ap(v) mustBe identity(v)
}
}
property(s"${TT.name} Applicative preserves function homomorphism") {
// Lifting a function and an argument then applying them produces the
// same result as applying the unlifted function and unlifted argument
// then lifting the result.
forAll(for {
f <- arbitrary[Double => String]
x <- arbitrary[Double]
} yield (f, x)) { case (f, x) =>
// Haskell: pure f <*> pure x = pure (f x)
f.pure.ap(x.pure) mustBe f(x).pure
}
}
property(s"${TT.name} Applicative preserves function interchange") {
// Given a lifted function and an unlifted argument, applying the lifted
// function after lifting the argument should give the same result as
// when reversing the order of the function and argument. This is
// difficult to express in words, and the code is hard to follow, but
// roughly this translates to
// `ap(ff: F[A => B])(pure(a)) == ap(pure(f => f(a)))(ff)`.
forAll(for {
u <- arbitrary[Double => String].lift
y <- arbitrary[Double]
} yield (u, y)) { case (u, y) =>
// Haskell: u <*> pure y = pure ($ y) <*> u
u.ap(y.pure) mustBe ((f: Double => String) => f(y)).pure.ap(u)
}
}
property(s"${TT.name} Applicative preserves function composition") {
// Given lifted functions `ff: F[A => B]` and `fg: F[B => C]` and
// argument `fa: F[A]`: lifting `compose()` and applying `fg`, `ff`, and
// `fa` produces the same result as applying `fg` after applying `ff` to
// `fa`.
val compose: (String => Int) => (Double => String) => Double => Int = g => f => g compose f
forAll(for {
u <- arbitrary[String => Int].lift
v <- arbitrary[Double => String].lift
w <- arbitrary[Double].lift
} yield (u, v, w)) { case (u, v, w) =>
// Haskell: pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
compose.pure.ap(u).ap(v).ap(w) mustBe u.ap(v.ap(w))
}
}
}
}
:::
See here for the full definition of the trait.
With this trait, laws specs can be written for each context.
:::{.numberLines}
abstract class Laws extends AnyPropSpec with ScalaCheckPropertyChecks with Matchers {
implicit override val generatorDrivenConfig: PropertyCheckConfiguration =
PropertyCheckConfiguration(minSuccessful = 100)
}
class OptionLawsSpec extends Laws with FunctorLaws with ApplicativeLaws {
import Option.Instances._
implicit val optionLiftedGen: LiftedGen[Option] = new LiftedGen[Option] {
override def lift[A](gen: Gen[A]): Gen[Option[A]] =
Gen.lzy(
Gen.oneOf(
gen.map(Some(_)),
Gen.const(None),
)
)
}
checkFunctorLaws[Option]()
checkApplicativeLaws[Option]()
}
class EitherLawsSpec extends Laws with FunctorLaws with ApplicativeLaws {
import Either.Instances._
implicit def eitherLiftedGen[X: Arbitrary]: LiftedGen[Either[X, *]] = new LiftedGen[Either[X, *]] {
override def lift[A](gen: Gen[A]): Gen[Either[X, A]] =
Gen.lzy(
Gen.oneOf(
gen.map(Right(_)),
arbitrary[X].map(Left(_)),
)
)
}
checkFunctorLaws[Either[String, *]]()
checkApplicativeLaws[Either[String, *]]()
}
class ListLawsSpec extends Laws with FunctorLaws with ApplicativeLaws {
import List.Instances._
implicit val listLiftedGen: LiftedGen[List] = new LiftedGen[List] {
override def lift[A](gen: Gen[A]): Gen[List[A]] =
for {
size <- Gen.sized(Gen.choose(0, _))
items <- Gen.listOfN(size, gen)
} yield List(items: _*)
}
checkFunctorLaws[List]()
checkApplicativeLaws[List]()
}
:::
See these laws specs for
Option
,Either
, andList
in the sample repository.
Try running these specs!
But don't forget Validated
!
We also defined an Applicative
instance for Validated
above:
:::{.numberLines}
implicit def validatedApplicative[E: Semigroup]: Applicative[Validated[E, *]] =
new Applicative[Validated[E, *]] {
import Semigroup.Syntax._
override def pure[A](a: A): Validated[E, A] = Valid(a)
override def ap[A, B](ff: Validated[E, A => B])(fa: Validated[E, A]): Validated[E, B] =
(ff, fa) match {
case (Valid(f), Valid(a)) => Valid(f(a))
case (Invalid(x), Invalid(y)) => Invalid(x |+| y) // propagate both undesired cases via Semigroup
case (Invalid(x), _) => Invalid(x)
case (_, Invalid(y)) => Invalid(y)
}
}
:::
This instance should be tested as well:
:::{.numberLines}
class ValidatedLawsSpec extends Laws with FunctorLaws with ApplicativeLaws {
import Validated.Instances._
import NonEmptyChain.Instances._
implicit val nonEmptyChainLiftedGen: LiftedGen[NonEmptyChain] = new LiftedGen[NonEmptyChain] {
override def lift[A](gen: Gen[A]): Gen[NonEmptyChain[A]] = {
def go(depth: Int): Gen[NonEmptyChain[A]] =
Gen.choose(0, (depth - 1).max(0)).flatMap {
case 0 => gen.map(NonEmptyChain(_))
case nextDepth =>
for {
prefix <- go(nextDepth)
suffix <- go(nextDepth)
} yield prefix.append(suffix)
}
Gen.sized(go)
}
}
implicit def validatedNecLiftedGen[E: Arbitrary]: LiftedGen[ValidatedNec[E, *]] = new LiftedGen[ValidatedNec[E, *]] {
override def lift[A](gen: Gen[A]): Gen[ValidatedNec[E, A]] =
Gen.lzy(
Gen.oneOf(
arbitrary[E].lift[NonEmptyChain].map(Invalid(_)),
gen.map(Valid(_)),
)
)
}
checkFunctorLaws[ValidatedNec[Exception, *]]()
checkApplicativeLaws[ValidatedNec[Exception, *]]()
}
:::
And we should also make sure NonEmptyChain
is well-behaved as a semigroup:
:::{.numberLines}
class NonEmptyChainLaws extends Laws with SemigroupLaws {
import NonEmptyChain.Instances._
implicit val nonEmptyChainLiftedGen: LiftedGen[NonEmptyChain] = new LiftedGen[NonEmptyChain] {
override def lift[A](gen: Gen[A]): Gen[NonEmptyChain[A]] = {
def go(depth: Int): Gen[NonEmptyChain[A]] =
Gen.choose(0, (depth - 1).max(0)).flatMap {
case 0 => gen.map(NonEmptyChain(_))
case nextDepth =>
for {
prefix <- go(nextDepth)
suffix <- go(nextDepth)
} yield prefix.append(suffix)
}
Gen.sized(go)
}
}
implicit def arbitraryNonEmptyChain[A: Arbitrary]: Arbitrary[NonEmptyChain[A]] =
Arbitrary(nonEmptyChainLiftedGen.lift(arbitrary[A]))
checkSemigroupLaws[NonEmptyChain[Int]]()
}
:::
See these laws specs for
Validated
andNonEmptyChain
Implications of the applicative laws
Each of Option
, Either
, and List
conform to the applicative laws and we only had to write the properties once. These properties prove that functions and arguments used within these contexts maintain referential transparency in their arrangements and that the specific contexts do not change the factoring semantics of the code.
What does change, however, are these contexts' specific effects. For example, you would not have to refactor code abstracted by applicative functions if you changed the backing implementation from Either
to List
, but your code would produce potentially more than one result in the desired case.
This is the goal, however, as these effects' dimensions of unknown quantity should not burden our code. Instead, we push the complexity to the edge of the context, where it is important that our context is an Either
or a List
, and keep our business logic focused on individual instances contained within each context.
What is enabled by applicatives?
Applicatives primarily offer independent computation. Specifically, the arguments to applicative functions such as ap()
, map2()
, or sequence()
are evaluated independently of one another, and their individual outputs as a whole influence whether the functions consuming them are permitted to compute against the outputs of their desired cases or if they should halt computation and propagate any undesired cases.
When all inputs to an applicative function are in the desired case, then the output of the lifted functions will also be in the desired case. Conversely, if any input is in the undesired case, then it will be propagated instead, and the other cases will be discarded. In this regard, applicative functions provide an all-or-nothing operation.
Independent computation provides some level of control flow, but it doesn't guide execution to proceed only if the previous execution has succeeded, as all operations evaluate independently of each other. Applicatives therefore do not provide a mechanism to support imperative programming. For this kind of control flow, you need to further specialize the applicative functor.
In my next post {{linkedTitle "_posts/2022-06-17-imperative-computation.md"}} we will explore the infamous monad and how it enables imperative control flow in functional programming.