I keep being confused about what a monad is. I have previously convinced myself that I understand, but the feeling of understanding doesn't seem to last very long. So I thought I'd just write it down here in a way that is easy for me to understand should I get confused again in the future.
Monads are a mathematical thing defined in terms of operations and properties that need to hold. Haskell and some other languages have typesystems advanced enough to specify a monad as construct in the language. While other languages don't necessarily have sufficiently advanced typesystems to be able to represent a monad as an interface you can implement, any combination of a type and functions that satisfy the right properties is still a monad.
In Haskell the Monad typeclass looks like this:
class Monad m where
(>>=) :: m a -> ( a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
If you're like me this looks like absolute gibberish, so let's decode it a little bit.
>>=, >> and return are functions.
>>= is called bind and >> is sometimes called then.
>> is just a shortcut for >>= with a value instead of a function for when m b doesn't depend
on a.
With that sorted out, let's transform this typeclass definition into a pseudo syntax that a curly-bracket-brained person like me can read more easily:
class Monad m {
fn bind(m<a>, fn(a) -> m<b>) -> m<b>;
fn then(m<a>, m<b>) -> m<b>;
fn return(a) -> m<a>;
}
There are also rules associated with this interface: https://wiki.haskell.org/index.php?title=Monad_laws.
Let's translate these to our pseudo-syntax as well:
Left identity: bind(return(a), h) == h(a)
Right identity: bind(m, return) == m
Associativity: bind(bind(m, g), h) == bind(m, fn(x) -> bind(g(x), h))
Monadic I/O
A while ago I made a very simple Rust implementation of an I/O monad to help me understand how monads are helpful for purely functional I/O. This isn't practically usable because bind accepts a function pointer and so you can't use closures there. And closures are an important part of making monadic I/O actually work of course.
enum IO<T> {
GetChar(Box<dyn FnOnce(char) -> IO<T>>),
PutChar(char, Box<IO<T>>),
Done(T),
}
impl<A: 'static> IO<A> {
fn bind<B: 'static>(self, f: fn(A) -> IO<B>) -> IO<B> {
match self {
IO::GetChar(g) => IO::GetChar(Box::new(move |c| g(c).bind(f))),
IO::PutChar(c, x) => IO::PutChar(c, Box::new(x.bind(f))),
IO::Done(x) => f(x),
}
}
fn then<B: 'static>(self, b: IO<B>) -> IO<B> {
match self {
IO::GetChar(g) => IO::GetChar(Box::new(move |c| g(c).then(b))),
IO::PutChar(c, x) => IO::PutChar(c, Box::new(x.then(b))),
IO::Done(_) => b,
}
}
fn ret(a: A) -> Self {
Self::Done(a)
}
fn eval(self) -> A {
match self {
IO::GetChar(g) => {
let c = std::io::stdin().bytes().next().unwrap().unwrap() as char;
let next = g(c);
next.eval()
}
IO::PutChar(c, x) => {
print!("{c}");
std::io::stdout().flush().unwrap();
x.eval()
}
IO::Done(x) => x,
}
}
}
fn put_char(c: char) -> IO<()> {
IO::PutChar(c, Box::new(IO::Done(())))
}
fn get_char() -> IO<char> {
IO::GetChar(Box::new(|c| IO::Done(c)))
}
This can then be used like this:
get_char()
.bind(|c| put_char(c))
.then(put_char('h'))
.then(get_char())
.then(get_char())
.then(IO::ret('r'))
.bind(|c| put_char(c.to_ascii_uppercase()))
.eval();