Functional Patterns: Zips and the Applicative

if-els

tyrael

Posted on July 4, 2024

Functional Patterns: Zips and the Applicative

This is part 5 of a series of articles entitled Functional Patterns.

Make sure to check out the rest of the articles!

  1. The Monoid
  2. Compositions and Implicitness
  3. Interfaces and Functors
  4. Recursion and Reduces

Functor Recap

Learning about Functors have taught us the very important function that is fmap, or mapping a function on a Functor interface (most commonly on the array).

Remember, the Functor interface differs from the actual term functor. Members of the interface Functor,
to put simply, can be mapped over by endofunctors (which are a type of a function that maps a category to
itself).

# python
map( lambda a: a**2, [1, 2, 3] )  # [1, 4, 9]
Enter fullscreen mode Exit fullscreen mode

Mapping a function on an array is a pretty common operation, a transformation on your data, if you may. But what if we wanted to apply a different function to each element? In terms of using map (or fmap), we are out of luck, as the functor supported by map are applied to all of the elements.

Zips

Say we have an array of int, our points in a game show. We'll get this in array for every round of the game.

# points per round
 [4, 3, 8, 1]
# r1 r2 r3 r4
Enter fullscreen mode Exit fullscreen mode

We'd like to get the total of our score. However, we are told that points are worth more in the later rounds! That is, every point is worth the round it was gained in.

[
    4 * 1,
    3 * 2,
    8 * 3,
    1 * 4
]
Enter fullscreen mode Exit fullscreen mode

Let's try to get these values programatically. Let's first take an imperative approach, by using the index of our current value + 1 to multiply at our current number. We'll do this in Go, for a clearer demonstration:

rounds := []int{ ... }
result := 0

for (i := 0; i < len(rounds); i++) {
   result += rounds[i] * (i+1)
}

return result
Enter fullscreen mode Exit fullscreen mode

then in Python:

rounds = [ ... ]
result = 0

for round, score in enumerate(rounds):
    result += score * (round + 1)

return result
Enter fullscreen mode Exit fullscreen mode

We achieve pretty similar code, except for how we're now using a function named enumerate. Now what exactly does enumerate do?

enumerate(["a", "b", "c", "d"]) # [ (0, "a"), (1, "b"), (2, "c"), (3, "d") ]
Enter fullscreen mode Exit fullscreen mode

Ah, the enumerate function "maps" some function to our original array, turning it into an array of pairs containing the index and the original value. But this can't be any ordinary functor, since there can't be any function that can achieve this changing value without side-effects!

Let's try to work backwards, and separate the pairs into their own arrays.

a = [ 0,   1,   2,   3 ]
b = ["a", "b", "c", "d"]
Enter fullscreen mode Exit fullscreen mode

It shouldn't be that hard to get back to our paired array.

a = [ 0,   1,   2,   3 ]
b = ["a", "b", "c", "d"]
result = []

for i in range(len(b)):
    c.append( (a[i], b[i]) )

return c
Enter fullscreen mode Exit fullscreen mode

We can simplify this further by realizing our a array is actually just range(len(b)).

a = range(len(b))
b = ["a", "b", "c", "d"]
result = []

for i in a:
    result.append( (i, b[i]) ):

return result

# also can be written as
# [ (i, b[i]) for i in a ]
Enter fullscreen mode Exit fullscreen mode

Now we've implemented a function that takes two arrays a and b, and pairs them up into a resulting array! This is called the zip function. And the enumerate function is just a specialization of this, a zip with the first argument as the range(len(b)) of the second argument.

This is a simplified implementation for Python, as zip and enumerate actually return generators, and
mostly likely don't need to use range as a dependency.

b = ["a", "b", "c", "d"]
[*zip(range(len(b))], b) == [*enumerate(b)]  # [ (0, "a"), (1, "b"), (2, "c"), (3, "d") ]
Enter fullscreen mode Exit fullscreen mode

Also, we've unlocked a whole new world for our map function, since now we can do differing partial applications for every element!

-- haskell
zip [3 .. 5] [9 .. 11] == [ (3, 9), (4, 10), (5, 11) ]
-- moreover, since functions are values...
zip [1 .. 3] [(*4), (+5), negate] == [ (1, (*4)), (2, (+5)), (3, negate)]
Enter fullscreen mode Exit fullscreen mode

And since now we're in the array category once again, we can simply map or reduce over these pairs to perform our transformation!

points = [4, 3, 8, 1]

-- we pattern match on the tuple to multiply both elements
sum . map (\(a,b) -> a * b) . zip [1..] $ points

-- alternatively we can use the `uncurry` function, which does that
sum . map (uncurry (*)) . zip [1..] $ points

-- uncurry and mapping a zip is such a common pattern that it can be written as `zipWith`
sum . zipWith (*) [1..] $ points

-- point-free definition
totalScore = sum . zipWith (*) [1..]
Enter fullscreen mode Exit fullscreen mode

image

Applicatives

A much more specific interface that is relevant to us is the Applicative typeclass.

class (Functor f) => Applicative f where
    pure :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b
--  ...
Enter fullscreen mode Exit fullscreen mode

What this means is that:

  • in order to be Applicative, you first must be an instance of Functor.
  • You have to define a function pure, which takes any type and then wraps it with your type constructor f (again, this is synonymous to a struct)
  • You have to define a function <*>, which applies a wrapped function f (a -> b) to your wrapped value f a resulting in a wrapped result f b.

The following are additional axioms that need to be fulfilled ontop of being a valid member of the Functor typeclass. You don't have to fully understand these right now, but I figured I shouldn't omit them.

pure id <*> v = v                            -- Identity
pure f <*> pure x = pure (f x)               -- Homomorphism
u <*> pure y = pure ($ y) <*> u              -- Interchange
pure (.) <*> u <*> v <*> w = u <*> (v <*> w) -- Composition
Enter fullscreen mode Exit fullscreen mode
  • The identity axiom states that applying a wrapped id (identity function) does nothing, working like the unwrapped id function.
  • The homorphism axiom states that applying a wrapped function to a wrapped value is equal to applying the function to the value and then wrapping it.
  • The interchange axiom states that applying u to a wrapped value is equal to applying a wrapped partial application of $ (thrush) of the original value to the function.
    • ($ a) f is the same as f $ a or f a.
  • The composition axiom states that applying a wrapped (.) (composition function, b-combinator) is equal to composing the applied function to the following applications.

If we look at the signatures of the two main functions here fmap and <*>:

fmap  ::   (a -> b) -> f a -> f b
(<*>) :: f (a -> b) -> f a -> f b
Enter fullscreen mode Exit fullscreen mode

We see that the only difference between these two is that Applicative has your functor wrapped in the same category (or data type) as your inputs!

The Maybe Monad

A ubiquitous Monad (which requires it be an Applicative) is the Maybe monad. We won't be talking about its Monad aspects here but what it does, to put simply, is act as context for functions with optional returns.

data Maybe m = Just m | Nothing
Enter fullscreen mode Exit fullscreen mode

What this says is: Maybe is a data type (can be thought of as a struct) that holds type m and can either be a Just m or Nothing. An example of a function using this data type is:

findIndex :: (a -> Bool) -> [a] -> Maybe Int

findIndex even [1, 3, 4, 5] == Just 2
findIndex even [1, 3, 5] == Nothing
Enter fullscreen mode Exit fullscreen mode

What findIndex does is find the first index wherein a predicate returns True. Now of course, this has a case wherein the predicate never returns True, and what would we return in that case? This is where the Maybe monad comes in, by wrapping our result in the Maybe type, we can return a Nothing in this case, and when we do find a True case, we just have to wrap our return with Just.

And if we're certain that we will always return a Just, we can just unwrap it using the fromJust function.

Since Maybe is under the Monad interface, and therefore an Applicative and Functor, we can do the things we were able to do on Applicative and Functor on it.

-- Being a functor:
fmap (* 2) (Just 4) == Just 8   -- we can map on it because its a functor, removing the need to unwrap
fmap (* 2) Nothing  == Nothing  -- which allows us to safely do operations on it.
Enter fullscreen mode Exit fullscreen mode

But what if we needed to perform operations on multiple instances of a Maybe? We would have to unwrap them both, and then apply the function, right? However, this poses as risk— as unwrapping a Nothing will cause a runtime error, and writing all of that is just, horrible.

import Data.Maybe

Just (fromJust (Just 4)) + (fromJust (Just 5))  -- Just 9
Just (fromJust Nothing + (fromJust (Just 5))    -- ERROR!
Enter fullscreen mode Exit fullscreen mode

Applicative allows us to compose functions without unwrapping our data types using the <*> function! Recall that all it does is apply a wrapped function, so we simply need to get to that point first. This is made more terse by the infix version of our lovely fmap function, written as <$>.

-- `pure` typed in the context of `Maybe Int` wraps our value in Just.
(pure 5) :: Maybe Int == Just 5

-- Applying a wrapped function
Just negate <*> Just 5 == Just (-5)
Just (+5)   <*> Just 2 == Just 7

-- Applying Nothing on either side resolves to Nothing
Nothing     <*> Just 5 == Nothing
Just (+5)  <*> Nothing == Nothing

fmap (+2) (Just 3) == Just 5
(+2) <$> Just 3    == Just 5        -- infix

(+) <$> Just 5 == Just (+5)         -- we get a wrapped *partially applied* function!

-- Therefore ..

(+) <$> Just 4 <*> Just 5 == Just 9   -- Composition!
(+) <$> Just 4 <*> Nothing == Nothing -- Composition!
Enter fullscreen mode Exit fullscreen mode

The Maybe is analogous to Rust's Option type (although they do not have a way of simply composing these types), in that it can be used to avoid using sentinel or null values in your code altogether! This can even be further extended to handle errors in your code as a Nothing always propagates throughout the composition.

The ZipList Applicative

From reading the documentation, we actually find out that [] is also a data type of the Monad interface, again making it an Applicative and a Functor. We won't be demonstrating it's Functor properties as we've already covered that in a previous part of the series.

-- `pure` in the context of `[Int]` creates a singleton list
(pure 5) :: [Int] == [5]

-- Applying a list of functions to a singleton list returns a list of the applications!
[negate] <*> [5] == [-5]
[(*3), abs, (+2)] <*> [5] == [15, 5, 7]

-- Applying to our empty list always returns an empty list
[] <*> [3, 4] == []
[(*3), abs, (+2)] <*> [] == []

-- Applying a list of functions to a list of elements, performs a cartesian product
[(+2), (*3)] <*> [5, 2, 1] == [7, 15, 4, 6, 3, 3]
Enter fullscreen mode Exit fullscreen mode

What's interesting with the list of functions applied to a list of values is that: it performs the function on all combinations of the functions and elements. Moreover, they are kept in a flattened list. This would be equivalent to:

result = []

for x in values:
    for f in functions:
        result.append(f(x))

# or
[ f(x) for x in values for f in functions ]
Enter fullscreen mode Exit fullscreen mode

All within a single line.

We can actually implement the zip behavior of lists using an Applicative interface!

Let us define a ZipList data type.

-- we define our struct that holds an array of `a`
data ZipList a = ZipList { getZipList :: [a] }

-- we define its membership to the Functor typeclass
instance Functor Ziplist where
    -- we say that mapping on a ZipList is just mapping over its embedded array
    fmap f (Ziplist a) = Ziplist (map f a)

instance Applicative ZipList where
    -- we say that applying a list of wrapped functions is just zipping over wrapped values
    ZipList fs <*> ZipList xs = ZipList (zipWith ($) fs xs)
Enter fullscreen mode Exit fullscreen mode

However, we lack one definition here: pure. I've intentionally skipped this because this is a good demonstration to show where the axioms come into play.

Say we define the following:

pure x = ZipList [x]   -- mirroring the definition for `pure` of [], we put x in a singleton list
Enter fullscreen mode Exit fullscreen mode

Recall that according to the identity axiom:

pure id <*> v == v
Enter fullscreen mode Exit fullscreen mode

This definition falls apart when we substitute v as the infinite list [1..]

pure id <*> v == v

ZipList [id] <*> [1..] == ZipList [1..]
ZipList (zipWith ($) id [1..]) == ZipList [1..] -- zipWith truncates based on the shorter list
Ziplist [1] != ZipList [1..]                    -- false!
Enter fullscreen mode Exit fullscreen mode

The problem with zip is that it truncates based on the shorter list, meaning for an application to be valid, the list fs must be infinite! So the definition of pure follows from that.

instance Applicative ZipList where
    -- we say that applying a list of wrapped functions is just zipping over wrapped values
    ZipList fs <*> ZipList xs = ZipList (zipWith ($) fs xs)

    -- We say that the `pure` of some element x, is the infinite list of itself
    pure x = ZipList (repeat x)
Enter fullscreen mode Exit fullscreen mode

And if you think about it— applying an infinite list of id to a finite list of x, will always return you the original list! Cool!


It goes without saying; there are so many more applications for the Applicative pattern out there, I hope you find them and recognize them!

And that concludes the penultimate part of this series! I hope you're enjoying and are able to keep up, if not, feel free to contact me for clarifications. I hope you learned something new, and see you in the last part The Monad!


Reference: https://en.wikibooks.org/wiki/Haskell/Applicative_functors

💖 💪 🙅 🚩
if-els
tyrael

Posted on July 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related