âEllo âello! Remember that Monad
thing we used to be afraid of, and how it just boiled down to a way for us to sequence our ideas? How Extend
was really just Chain
backwards? Well, today, weâll answer the question that Iâm sure has plagued you all week: what is a backwards Monad
?
First up, we should talk about the name. No, not the monad
bit - the co
bit. When we talk about structures like Monad
, we sometimes talk about the idea of the dual structure. Now, for our purposes, we can just think of this as, âThe same, but with all the arrows backwardsâ.
This is a surprisingly good intuition for dual structures. Seriously.
Hey, that was our first hint! Comonad
is Monad
with âthe arrows backwardsâ. When we boil it down, there are really only two interesting things that a Monad
can do:
-- For any monad `m`:
of :: a -> m a
chain :: m a -> (a -> m b) -> m b
From this, we can derive all the other fun stuff like join
, map
, ap
, and whatnot. So, letâs write this all backwards, turning our entire types the wrong way round:
-- Turn the arrows around...
coOf :: a <- m a
coChain :: m a <- (a <- m b) <- m b
-- Or, more familiarly...
-- For any Comonad `w`:
coOf :: w a -> a
coChain :: w a -> (w a -> b) -> w b
Well, hereâs the good news: we already know coChain
, and we call it extend
! That leaves us with that coOf
function, which the glorious Fantasy Land spec calls extract
.
When I first looked at extract
, I got a bit confused. Couldnât we do that with Monad
? If not, whatâs the point in a Monad
if we canât get a value back out? What helped me was looking a little closer at extract
:
extract :: w a -> a
That function takes any Comonad
and returns the value inside. We couldnât do that for Maybe
, because some of our values - Nothing
- donât have a value to return! We couldnât do it for Array
; what if itâs empty? We couldnât do it for Promise
; we donât know what the value is yet! It turns out that, for a lot of Monad
types, this function isnât as easy to write as we might think at first glance.
Letâs think about Maybe
for a second, though. Would it be a Comonad
if we removed the Nothing
option? Well, yes, but then it wouldnât be a Maybe
- it would be Identity
with a funny name!
What about Array
? What if we made a type like this:
//- An array with AT LEAST ONE element.
//+ data NonEmpty = NonEmpty a (Array a)
const NonEmpty = daggy.tagged(
'NonEmpty', ['head', 'tail']
)
// Extend would function the same way as it
// did for Array in the last article...
NonEmpty.prototype.extract = function () {
return this.head
}
// e.g.
NonEmpty(1, [2, 3, 4]).extract() // 1
Now we have a type for non-empty lists, we can guarantee a value to extract! This type, it transpires, forms a beautiful Comonad
.
A piece of good advice is to make illegal states unrepresentable. If we need an array somewhere in our code that must have at least one element, using the
NonEmpty
type gives us an API with that guarantee!
So, chain
gave us sequencing with write access to the output, and of
let us lift a value into the computation whenever we liked. extend
gives us sequencing with read access to the input, and extract
lets us extract a value out of the computation whenever we like!
If youâve followed the blog series up until now, the
Comonad
laws are going to be what youâve come to expect. No new ideas!
Now, before we start to assume that all Comonad
types are just bastardised Monad
types, letâs look at something very comonadic: Store
.
//+ data Store p s = Store (p -> s) p
const Store = daggy.tagged(
'Store', ['lookup', 'pointer']
)
The intuition here is that lookup
represents a function to get things out of a âstoreâ of s
-values, indexed by p
-values. So, if we pass a p
to the lookup
function, weâll get out its corresponding s
. The pointer
value represents the âcurrentâ value. Think of this like the read head on an old hard disk.
Now, to make this type more useful, we can stick a couple of functions onto this:
//- "Move" the current pointer.
//+ seek :: Store p s ~> p -> Store p s
Store.prototype.seek = function (p) {
return Store(this.lookup, p)
}
//- Have a look at a particular cell.
//+ peek :: Store p s ~> p -> s
Store.prototype.peek = function (p) {
return this.lookup(p)
}
And, wouldnât you know it, we can also make this a functor by mapping over the function!
//- Compose the functions! Yay!
Store.prototype.map = function (f) {
const { lookup, pointer } = this
return Store(lookup.map(f), pointer)
// Store(p => f(lookup(p)), pointer)
}
Now, if weâre going to make a Comonad
of our Store
, we first need to make it an Extend
instance. Remember: extend
should behave like map
, but with read-access to the input. Hereâs where Store
gets really sneaky.
//+ extend :: Store p s ~> (Store p s -> t)
//+ -> Store p t
Store.prototype.extend = function (f) {
return Store(
p => f(Store(this.lookup, p)),
this.pointer)
}
Our lookup
function now applies f
to a Store
identical to the original, but with the focus on the given index! Can you see the magic yet? Letâs build something exciting: Conwayâs Game of Life.
For this, weâre going to use a âboardâ of [[Bool]]
type to represent our âliveâ and âdeadâ cells. Something like this, perhaps:
let start = [
[ true, true, false, false ],
[ true, false, true, false ],
[ true, false, false, true ],
[ true, false, true, false ]
]
If we want to look up a value in this store, weâre going to need an x
and y
coordinate. What better choice of structure to hold two numbers than a Pair
?
const Game = Store(
({ _1: x, _2: y }) =>
// Return the cell OR false.
y in start && x in start[y]
? start[y][x]
: false,
// We don't care about `pointer` yet.
Pair(0, 0))
Now, we need to write out some logic! The rule for the Game of Life is that, if a false
cell has exactly three true
neighbours, make it true. If a true
cell has two or three true
neighbours, keep it as true. If neither apply, make it false
. We can work this out for any cell with eight sneaky peek
s!
// What will the current cell be next time?
//+ isSurvivor :: Store (Pair Int Int) Bool
//+ -> Bool
const isSurvivor = store => {
const { _1: x, _2: y } = store.pointer
// The number of `true` neighbours.
const neighbours =
[ Pair(x - 1, y - 1) // NW
, Pair(x, y - 1) // N
, Pair(x + 1, y - 1) // NE
, Pair(x - 1, y) // W
, Pair(x + 1, y) // E
, Pair(x - 1, y + 1) // SW
, Pair(x, y + 1) // S
, Pair(x + 1, y + 1) // SE
]
.map(x => store.peek(x)) // Look up!
.filter(x => x) // Ignore false cells
.length
// Exercise: simplify this.
return store.extract() // Is it true?
? neighbours === 2 || neighbours === 3
: neighbours === 2
}
Now, why did we go to all this trouble? Well, we now have a Store (Int, Int) Bool
to Bool
function, which is the exact shape that extend
needs⊠and extend
will (lazily!) apply this function to every cell on the board! By using extend
, we now get to see the entire board one step into the future. Isnât that magical?
I strongly recommend you look at the Gist for this article and be sure that this makes sense.
Store
is an unfamiliar beast.
Now, there are plenty of other Comonad
types, but theyâre not quite as popular as Monad
types, probably because their use isnât so obvious. After all, we can write our applications just using Monad
types, so this (unfairly) ends up in the advanced box. How rude!
For now, however, weâll stop here. I will come back to Comonad
in other posts - theyâre my latest obsession - but Store
gives a really clear idea about why these are useful. Incidentally, if you want to play the Game of Life, the articleâs Gist has a working demo!
Next time, weâll be looking at Bifunctor
and Profunctor
: so simple, weâre going to do both at the same time! I promise: these last two are going to be a bit of a cool-down session. Until then!
â„