Understanding Monads (Re: Probable C# 6.0 features)
Timon Gehr
timon.gehr at gmx.ch
Wed Dec 11 16:04:51 PST 2013
On 12/11/2013 03:32 PM, Robert Clipsham wrote:
> They way I like to think of monads is simply a box that performs
> computations. ...
The term has a more general meaning in category theory. :)
What you are describing is a monad in the category of types and (pure!)
functions. Since there has been some interest on this topic recently,
I'll elaborate a little on that in a type theoretic setting. It would be
easy to generalize the notions I'll describe to an arbitrary category.
I think it is actually easier to picture the concept using the
definition that does not squash map and join into a single bind
implementation and then derives them from there.
Then a monad consists of an endofunctor, and two natural transformations
'return' (or 'η') and 'join' (or 'μ'). (I'll explain what each term
means, and there will be examples, so bear with me :o). Feel free to ask
questions if I lose you at some point.)
An endofunctor consists of:
m: Type -> Type
map: Π(a:Type)(b:Type). (a -> b) -> (m a -> m b)
I.e. a mapping on types and a mapping on functions. The type of 'map'
could be read as: for all types 'a' and 'b', map creates a function from
'm a' to 'm b' given a function from 'a' to 'b'. The closest concept in
D is a template parameter (but what we have here is cleaner.)
An example of an endofunctor is the following:
m: Type -> Type
m = list
map: Π(a:Type)(b:Type). (a -> b) -> (list a -> list b)
map a b f = list_rec [] ((::).f)
The details are not that important. This is just the map function on
lists, so for example:
map nat nat [1,2,3] (λx. x + 1) = [2,3,4]
(In case you are lost, a somewhat analogous statement in D would be
assert([1,2,3].map!(int,int)(x=>x+1) == [2,3,4]).)
The map function for any functor must satisfy some intuitive laws:
ftr-id: Π(a:Type). map a a (id a) = id (m a)
I.e. if we map an identity function, we should get back an identity
function.
ftr-compose: Π(a:Type)(b:Type)(c:Type)(f:b->c)(g:a->b).
map b c f . map a b g = map a c (f . g)
I.e. if we map twice in a row, we can just map the composition of both
functions once. Eg. instead of
map nat nat (λx. x + 1) (map nat nat (λx. x + 2) [1,2,3])
we can write
map nat nat (λx. x + 3) [1,2,3]
without changing the result.
Many polymorphic containers form endofunctors in the obvious way. You
could e.g. imagine mapping a tree (this corresponds to a functor (tree,
maptree)
1 2
/ \ / \
2 3 - maptree nat nat (λx. x + 1) -> 3 4
/ \ / \
4 5 5 6
A natural transformation between two functors (f, mapf) (g, mapg) is a
mapping of the form:
η: Π(a:Type). f a -> g a
There are additional restrictions (though it is redundant to state them
in a type theory where types arguments are parametric). Intuitively, if
your functors are containers, a natural transformation is only allowed
to reshape your data.
For example, a natural transformation from (tree, maptree) to (list,
map) might reshape a tree into a list as follows:
1
/ \
2 3 - inorder nat -> [2,1,4,3,5]
/ \
4 5
This example just performs an in-order traversal, but an arbitrary
reshaping would be a natural transformation. Note that it is valid to
lose or duplicate data, though usually one uses natural transformations
that just preserve your data.)
Formally, the restriction is
naturality: Π(a:Type)(b:Type)(h:a->b). η b. mapf h = mapg h . η h
I.e.: if we map h over an 'f' and then reshape it we should get the same
as if we had reshaped it first and then mapped on the reshaped
structure. For example, if we reshape a tree into a list using a natural
transformation, then it does not matter whether we increase all its
elements by one before reshaping, or if we increase all elements of the
resulting list by one.
We are now ready to state what a monad is (still in the restricted sense
where we just consider the category of types and functions):
A monad consists of:
an endofunctor (m, map)
together with two natural transformations:
return: Π(a:Type). a -> m a
join: Π(a:Type). m (m a) -> m a
Note that return is a natural transformation from the identity functor
(id Type, λa b. id (a->b)) to our endofunctor (m, map). And join is a
natural transformation from (m . m, map . map) to (m, map). It is easy
to see that those are indeed functors. The first one is an example of a
functor that is not a kind of container (mapping is just function
application on a single value.)
For implementing 'return', we should reshape a single value into an 'm'.
E.g. if 'm' is 'list', the most canonical implementation of return is:
return: Π(a:Type). a -> list a
return a x = [x]
I.e. we create a singleton list. This is clearly a natural
transformation. For the tree, we'd just create a single node.
For join, the most canonical implementation just preserves the order of
the elements, but forgets some of the structure, eg:
[[1,2],[3,4],[5]] - join nat -> [1,2,3,4,5]
This is also what the eponymous function in std.array does for D arrays.
It is a little hard to draw in ASCII, but it is also easy to see how one
could implement join for the tree example: Just join the root of every
tree in your tree to the outer tree.
1
/ \ 1
--- \ / \
|2| \ - jointree nat -> 2 3
--- \ / \
--------- 4 5
| 3 |
| / \ |
| 4 5 |
---------
Of course there are now some intuitive restrictions on what 'return' and
'join' operations constitute a valid monad, namely:
neutral_left: Π(a:Type). join a . map a (m a) (return a) = id
neutral_right: Π(a:Type). join a . return a = id
This is quite intuitive. I.e. if we reshape each element into the monad
structure using return and then merge the inner structure into the outer
one, we don't do anything. Analogously if we reshape the entire
structure into a new monad structure and then join. Examples for the
list case:
join nat (map nat (list nat) return [1,2,3]) =
join nat [[1],[2],[3]] = [1,2,3]
join nat (return nat [1,2,3]) =
join nat [[1,2,3]] = [1,2,3]
Furthermore we need:
associativity: Π(a:Type). join a . map (join a) = join a . join (m a)
I.e. it does not matter in which order we join.
These restrictions are also called the 'monad laws'.
Example for the list case:
join nat (map (join nat) [[[1,2],[3]],[[4],[5,6]]]) =
join nat [[1,2,3],[4,5,6]] = [1,2,3,4,5,6]
join nat (join (list nat) [[[1,2],[3]],[[4],[5,6]]]) =
join nat [[1,2],[3],[4],[5,6]] = [1,2,3,4,5,6]
At this point, this second restriction should feel quite intuitive as well.
Now what about bind? Bind is simply:
bind: Π(a:Type)(b:Type). m a -> (a -> m b) -> m b
bind a b x f = join b (map a (m b) f x)
i.e. bind is 'flatMap'.
Example with a list:
bind nat nat [1,2,3] (λx. [3*x-2,3*x-1,3*x]) =
join nat (map a (m b) (λx. [3*x-2,3*x-1,3*x] [1,2,3]) =
join nat [[1,2,3],[4,5,6],[7,8,9]] =
[1,2,3,4,5,6,7,8,9]
Now on to something completely different: The state monad.
First we'll describe the endofunctor:
Let 'state' be the type of some state we want the monad to thread through.
m: Type -> Type
m a = state -> (a, state)
I.e. the structure we are looking at is a function that computes a
result and a new state from some starting state. This is how we can
capture side-effects to the state with a pure function. 'm a' is hence
the type of a computation of a value of type 'a' that modifies a store
of type 'state'.
Note that now our structure may be huge: It 'stores' an 'a' for every
possible starting state. In order to map, we need to destructure down to
the point where we can reach a single value:
map: Π(a:Type)(b:Type). (a -> b) -> (m a -> m b)
map a b f x = λ(s:state). case x s of { (a,s') => (f a, s') }
Note how this is quite straightforward. We need to return a function, so
we just create a lambda and get a state. After we run x on the state we
get a tuple whose first component we can map.
'return' is even easier: Embedding a value into the state monad creates
a 'computation' with a constant result.
return: Π(a:Type). a -> m a
return a x = λ(s:state). (x,s)
Before we implement join, lets look at m (m nat):
state->(state->(a,state), state)
I.e. if we apply such a thing to a state, we get a function taking a
state and returning an (a,state) as well as a state. Since we are
looking to get an (a,state), the implementation writes itself:
join: Π(a:Type). m (m a) -> m a
join a x = λ(s:state). case x s of { (x',s') => x' s' }
Intuitively, to run a computation within the monad, just apply it to the
current state and update the state accordingly.
Why does this satisfy the monad laws?
neutral_left: Π(a:Type). join a . map a (m a) (return a) = id
This states that turning a value into a constant computation with that
result and then running that computation is a no-op. Check.
neutral_right: Π(a:Type). join a . return a = id
This states that if we wrap a computation in another one and then run
the computation inside, this is the same computation as the one we
started with. Check.
associativity: Π(a:Type). join a . map (join a) = join a . join (m a)
This states that composition of computations is associative. Check.
In case this last point is not so obvious, it says that if we have eg:
a=2;
b=a+3;
c=a+b;
Then it does not matter if we first execute a and then (b and then c) or
if we first execute (a and then b) and then c. This is obvious.
In order to fully grok monads, it is also useful to look at how they can
influence control flow. The monad with the most general effects on
control flow is the continuation monad. But it's getting late, so if
someone is interested I could explain this another time, or you might
google it.
More information about the Digitalmars-d
mailing list