Basics 14 - Functional Patterns - Map and Bind

The Problem with Learning Functional Patterns

Patterns in the functional world are extremely different from patterns in the OOP world. It's a borderline impossible task to understand functional patterns for beginners. This happens for various reasons:

  1. Functional programming has roots in mathematics and category theory. The lingo used is completely alien to a C#/Java programmer.

  2. There are no classes/interfaces/inheritance etc. Just container types + functions.

  3. Partial application makes matter worse. Here's an example:

This is the documentation for map function from List module. https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-listmodule.html.

The signature of List.map is: mapping: ('T -> 'U) -> list: 'T list -> 'U list

This means it is a two-parameter function, which returns a list. So far so good. Then you search and study more, and realize, it's a cousin of LINQ's Select method. No problems so far.

Then, since you are learning functional patterns, you study the map function. You study category theory, and what the functional programming guides have to say. Loosely put, the description you find is:

  1. map returns a function

  2. map lifts a function

At this point, nothing makes sense. What's the connection between List.map, which is mapping: ('T -> 'U) -> list: 'T list -> 'U list and returning/lifting a function? Are we dealing with List (and its elements) or with functions? Chaos!

The issue here is perspective, what one is looking at:

  1. One perspective is very specific and involves both the parameters of List.map

  2. The other perspective, which is very abstract, involves only the first parameter of List.map

  3. No surprise, if you call List.map with the first parameter only, you get another function

Functional patterns are about abstract thinking. It's a slow process. You can't learn to think abstractly overnight. So, be patient.

How Functional Patterns are Evolved

Functional patterns are based on common recurring themes (problem statements, solutions) and giving them names. For instance:

  1. Counting items in a list, set, map etc.

  2. Finding min, max, average etc.

  3. Converting a list of numbers to another list where each value is doubled

  4. Compressing a list of numbers to just one number (depending upon the use case)

The goal is:

  1. You focus on what is needed

  2. You don't focus on structural issues, for instance:

    1. How to traverse a list or an array

    2. How to extract value out of an option or a Result

What's Lifting or Elevation?

Remember, in the world of functional programming you are dealing with two levels:

  1. Regular data types: int, string and so on

    1. Here you are dealing at the regular level

    2. Nothing fancy about data or functions

  2. Container data types: option, Result, list and so on

    1. Here you are dealing with data that's lifted, which means data is now inside a container; not dealing with an int, but a int list or an int option

    2. Here you have functions that operate on lifted data; a function that can operate on int list or int option, not int

Lifting/elevating has two aspects:

  1. Lifting/elevating/packaging/storing data into a container; int to int list

  2. Lifting/elevating functions that operate on regular data types

    1. A function that operates on int to operating on int list or int option

    2. This is where things get crazy confusing. If there is a function, which takes an int parameter, how can that be lifted? In other words, how can this function magically start operating on an int list or an int option

Here's an example in C#. You are writing a program where you need to double int values. So you write a function like this:

static int DoubleMyInt(int x)
{
  return x * 2;
}

// Calling
int x = DoubleMyInt(whatever);

Now you need to double all the int values in a list. So you write a function like this:

static int DoubleMyInt(int x)
{
  return x * 2;
}

static List<int> DoubleMyIntList(List<int> list)
{
  List<int> result = new();

  foreach (int x in list)
  {
    result.Add(DoubleMyInt(x));
  }

  return result;
}

What we have now is a combo of:

  1. The need (aka the business case) - double an int, already implemented as DoubleMyInt

  2. Structural issues: DoubleMyInt can't operate on List<int>, so we create another function DoubleMyIntList. This function has structural awareness, that is, how to create a new list, traverse an existing list etc.

Then you consult a smart friend of yours who suggests there is no need for DoubleMyIntList function. You can simply write something like this:

var doubled = list.Select(DoubleMyInt).ToList();

Pay attention to what's happening here:

  1. You have a function DoubleMyInt which operates on an int value

  2. Select method is enabling you to use the same function for operating on List<int>

In strict terms, this is not lifting DoubleMyInt, but the next best thing, that is, there is a provision to use DoubleMyInt with lifted data. You'd call it lifting DoubleMyInt if you have a function called LiftMyFunctionToOperateOnIntList. Something like this:

var liftedFunction = LiftMyFunctionToOperateOnIntList(DoubleMyInt);

var doubled = liftedFunction(someIntList);

Here liftedFunction is a new function, which operates on List<int>. This is lifting DoubleMyInt and getting liftedFunction.

Sadly, in C# there is no straightforward way to achieve this. It can be done, however, requires implementation (simulation) of partial application and whatnot.

Before we move forward, one important point to note here:

  1. In regards to functions, operating on lifted data refers to parameters. For example: int list -> int, int list -> int list, int option -> string, int option -> ().

  2. The return type of function has nothing to do with the whole argument. These are examples of functions that are not operating on lifted data: int -> int, int -> string, int -> int list, int -> int option.

Map

Very simply put, map allows the transformation of lifted data.

If you have an int option and you want to add 2 to its value:

  1. First, you need to know if the option is in Some or None case; for None case you exit with None only

  2. For Some case:

    1. You need to extract the int value out of int option

    2. Add 2 to the value

    3. Return a new int option with the new value

Something like this:

let add2 x = x + 2

let opt1 = Some 1
let opt2 = None

match opt1 with
| Some(x) -> Some(add2 x)
| None -> None
|> printfn "%A" // Some 3

match opt2 with
| Some(x) -> Some(add2 x)
| None -> None
|> printfn "%A" // None

All we need to do is add 2 to the lifted value, however, what we have is the function add2, which is the business logic, plus a whole lot of structure-aware code. Not what we need.

Let's look at the Option.map function. https://fsharp.github.io/fsharp-core-docs/reference/fsharp-core-optionmodule.html

Option.map allows lifted value transformation using functions that don't deal with lifted data. The function add2 doesn't deal with lifted data. It deals with an int only.

If you read the signature of Option.map it says:

  1. Give me a function with of type 'T -> 'U, and a 'T option

  2. I will return 'U option

In the example below, function add2 is of type int -> int, and we have an int option.

let add2 x = x + 2

let opt1 = Some 1
let opt2 = None

Option.map add2 opt1 |> printfn "%A" // Some 3
Option.map add2 opt2 |> printfn "%A" // None

Here we focus on the function add2 only. Rest everything is handled by Option.map. This is what I call abstraction. I will focus on what I need, and not on the structural issues.

Option.map isn't the only map function available. There are various container types for which map function is available with similar ideas. Array.map, List.map, Seq.map, Result.map, Result.mapError are some other examples, which we will discuss later.

Map as a Function Lifter

Let's try this:

let add2 x = x + 2

let result = Option.map add2

What do you think the result is? What is its type?

Let's revisit the function signature of Option.map: ('T -> 'U) -> 'T option -> 'U Option

Option.map add2 is a partial application of Option.map, which means, result should be a function of type int option -> int option. In other words, result is lifted add2.

Here's the lifted function in action:

let add2 x = x + 2

let result = Option.map add2

let opt1 = Some 1
let opt2 = None

result opt1 |> printfn "%A" // Some 3
result opt2 |> printfn "%A" // None

Map and a Problem

Let's try this:

let add2IfEven x = if x % 2 = 0 then Some(x + 2) else None

let opt1 = Some 1

let result = Option.map add2IfEven opt1

Function add2IfEven:

  1. Adds 2 if the given number is even and returns Some

  2. If the number if not even returns None

What do you think the type of result is? It is int option option, not int option.

Let's revisit the function signature of Option.map: ('T -> 'U) -> 'T option -> 'U Option

If the supplied function's (first parameter to Option.map) return type is 'U, Option.map should return 'U option. Option.map lifts 'U into 'U option.

The function add2IfEven is returning int option itself. This means you'd get int option option.

In other words: if the function injected for map returns lifted data, you will end up with double lifting. map function (regardless of the type of container) is designed for injected functions that operate on and return regular data.

So, what do you do when you need to use add2IfEven? The answer is: use map function's cousin named bind.

Bind

The bind function has two aspects. One, I will not cover at this moment as it is related to continuations, computation expressions, monadic bind etc. For the other one, let's look at the Option.bind function. https://fsharp.github.io/fsharp-core-docs/reference/fsharp-core-optionmodule.html

If you read the signature of Option.bind it says:

  1. Give me a function with of type 'T -> 'U option, and a 'T option

  2. I will give 'U option

The only difference between the signatures of Option.map and Option.bind is the first parameter, the type of function.

Option.map asks for 'T -> 'U

Option.bind asks for 'T -> 'U option

As I mentioned in the previous section, Option.map lifts 'U into 'U option. Option.bind on the other side, expects 'U option to be returned by the injected function.

let add2IfEven x = if x % 2 = 0 then Some(x + 2) else None

let opt1 = Some 1
let opt2 = Some 2
let opt3 = None

Option.bind add2IfEven opt1 |> printfn "%A" // None
Option.bind add2IfEven opt2 |> printfn "%A" // SOme 4
Option.bind add2IfEven opt3 |> printfn "%A" // None

Bind as a Function Lifter

Same as Option.map, Option.bind can lift a function.

add2IfEven x = if x % 2 = 0 then Some(x + 2) else None

let result = Option.bind add2IfEven

let opt1 = Some 1
let opt2 = Some 2
let opt3 = None

result opt1 |> printfn "%A" // None
result opt2 |> printfn "%A" // SOme 4
result opt3 |> printfn "%A" // None

Summary

Map:

  1. Allows you to transform data that's lifted

  2. Simply write your functions (business logic) where you transform data

  3. Both the input and output of the function (business logic) should be regular data

  4. You can also use map as a function lifter

Bind

  1. Similar to map, except that the function (business logic) should return lifted data

  2. You can also use bind as a function lifter

If you have reached so far, congratulations.

Keep reading!