2. Using Monads in chaining functions
With function composition, we can build complex functions by combining a series of simpler ones. When such functions are chained together, the output from one function serves as the input to the next e.g. with (f ∘ g)(x) = f(g(x))
, f is applied to the output of g(x).
Challenges in chaining functions in computer programs
In mathematics, chained functions operate smoothly, free of unnecessary complexities. Yet, replicating these ideas in a computer program proves more challenging:
(1) Unlike mathematics, where inputs and outputs are primarily numerical, computer programs handle a wider array of data types. Because of this lack of uniformity, we cannot automatically assume that the output of one function can seamlessly serve as the input for another.
(2) Within a computer program, functions may generate undesired side effects, such as exceptions.
Using Monads
To tackle these challenges, one approach is to employ Monads. Essentially, a Monad can be thought of as a structure that enables a smooth chaining of function calls. In defining such a structure, we need to provide two key behaviours :
(1) a "unit" function to instantiate a Monadic value. This wraps the raw value (t), and adds contextual information associated with that value.
def unit[T](t: T) : Monad[T]
The contextual information varies from case to case; for instance, it may indicate whether the raw value reflects a valid outcome of a calculation or if it denotes an error condition (as seen in the Either Monad).
(2) a bind function which is smart enough to know how to use the contextual information when transforming the inputs to outputs. This is most commonly implemented as a "flatMap" function with the following signature:
def flatMap[A,B](a: Monad[A])(f: A => Monad[B]): Monad[B]
In the case of the Either Monad, the flatMap function applies the given function 'f' if the contextual information indicates that a valid result has been provided. Otherwise, the function 'f' will not be applied and the error message will be propagated to the caller.
Use of Monads in our "Recipes in Finance"
So far, the description may sound a little abstract. It gets clearer when we look at a real world example from our "Recipes in Finance". These recipes make heavy use of Monads to pass results from one function to another. For example, our interest rate sensitivity functions (e.g Bond.duaration, Bond.convexity) rely on the Bond.grossPrice function, which in turn relies on a Schedule.create function. If something goes wrong in Schedule.create, this will have an adverse effect on subsequent calculations in the chain, first in the price functions and then in the sensitivity ones. Here's how the Schedule function implements the unit functions to instantiate the Monadic value:
// code snippet illustrating how the Monad is created...
object PeriodicSchedule {
def create(...): Either[String, List[SchedulePeriod]] = {
if (!ordered(...)) {
Left("The dates used to define the schedule are not ordered correctly")
} else {
...
Right(...)
}
The Monadic results of the function above are used in calculating a price as below:
// code which consumes Monad...
val schedule = PeriodicSchedule.create(...)
val grossPrice = schedule.flatMap(sched => Right(Bond.grossPrice(..., sched, ...))
Given how a Monad's flatMap function is implemented, the grossPrice function will only be invoked if the result from Schedule.create is valid (in the "Right" state). Otherwise the error (in "Left" state) will be passed on to the calling function.
With a combination of a wrapped type which stores both the value and the context, along with a flatMap function which understands how to react to the contextual information, we have an effective mechanism for chaining functions. This neatly addresses both of the challenges highlighted earlier in this article, namely : (a) lack of uniformity in the input and output types, and (b) the existence of side effects.
Additional challenge
Before concluding the article, we ought to address one further challenge related to legacy or third-party code, which we may not be able to modify. In such cases, if a function does not produce a Monadic output, we can use a 'lift' function to transform it to one that does. Such a function is typically implemented like this:
def lift[A, B] (f: A => B) : A => Option[B] = {
(a: A) => try Some(f(a)) catch {
case _ => Option.empty
}
}
Last updated