A couple of years after graduating college, I discovered functional programming. I started with LINQ and immutability in .NET, but I quickly found Haskell. I explored the FP rabbit hole for a few years before eventually deciding that while there are benefits to programming in this paradigm, there are also some pretty big downsides.
Today, I want to focus on one of the most (in)famous abstractions provided by FP: the Monad
.
Many have tried to explain monads in an easy to understand way.
Many have failed and few have succeeded.
In this article, I will try my hand at explaining them.
I will provide code examples in Haskell and C#.
Prerequisites
In this tutorial, I will assume you have some basic programming experience and a basic understanding of Haskell and it's syntax.
You should understand Haskell type signatures and function application syntax.
You should also understand basic Haskell typeclasses like Eq
and Monoid
.
You should be able to understand the C# examples if you've used any other object-oriented C-like language.
Motivation
The first question to answer is "Why Monads?".
Monads were first borrowed from an area of math called
Category Theory to enable I/O in Haskell in a
referentially transparent way.
Since there are no side effects in Haskell, the monad abstraction is used to build up a sequence of I/O operations without executing them until returning them from the main
function.
The key thing here is sequence.
Something is a Monad when you are able to string sequences of that thing together.
So, the IO
type in Haskell is a monad because of the ability to string a sequence of values of type IO a
together.
Before I explain Monads, I need to explain two other abstractions that Monads are built upon: Functors
and Applicative Functors
This is necessary because every Monad
is also an Applicative Functor
and a Functor
, and every Applicative Functor
is a Functor
.
Each abstraction builds on the next.
Functor → Applicative Functor → Monad
Also, Functors are much easier to understand.
Functors
Something is a Functor when you are able to map a function over it.
Some common examples are lists of items, an optional item, and I/O values, as mentioned above.
A good way to conceptualize a functor is as a container.
However, you should know that this is a simplification.
If you want to modify the items in the container without taking them out of the container, you can use the Functor's map function.
This function takes two parameters: the container containing items of type a
and a function which converts the items inside the container from type a
to type b
.
The function returns a new container containing items of type b
.
In Haskell this mapping function is called fmap
. Here is the formal definition of the Functor
typeclass in Haskell.
-- Simplified typeclass definition
class Functor f where
fmap :: (a -> b) -> f a -> f b -- infix operator is <&>
-- Functor laws
-- fmap id == id
-- fmap (f . g) == fmap f . fmap g -- '.' is function composition: (f . g) x == f (g x)
The above is a simplified definition. Here is the actual definition, if you're interested.
To let the Haskell compiler to know that a type is a Functor
, you must implement the fmap
function.
You will also need to ensure that the Functor laws hold true for your implementation.
The laws of a typeclass are just equations that are part of the mathematical definition of a typeclass.
There is no way to enforce laws at compile time in Haskell, so be careful.
So, for the three common examples mentioned above, you would need to implement fmap
for each, respectively.
-- [optional item]
instance Functor Maybe where
fmap :: (a -> b) -> Maybe a -> Maybe b
fmap = undefined
-- [list of items]
-- List has a special syntax
-- This is what the instance would look like if the syntax were consistent
--instance Functor List where
-- fmap :: (a -> b) -> List a -> List b
-- fmap = undefined
-- Actual List instance
instance Functor [] where
fmap :: (a -> b) -> [a] -> [b]
fmap = undefined
-- [I/O values]
instance Functor IO where
fmap :: (a -> b) -> IO a -> IO b
fmap = undefined
I have left the instances unimplemented as exercise (for Maybe
and List
, at least) for the reader.
Here are the implementations for
Maybe,
List,
and IO.
Now that the compiler knows that each of these types is a Functor, you can use fmap
to apply a function to each item in each container.
-- The function to apply
doubleValue :: Int -> Int
doubleValue x = x*2
------------------------------------
-- Maybe
optional :: Maybe Int
optional = Just 5
fmap doubleValue optional -- Just 10
------------------------------------
-- List
list :: [Int]
list = [1, 2, 3]
fmap doubleValue list -- [2, 4, 6]
------------------------------------
-- IO
userVal :: IO Int
userVal = readInt
fmap doubleValue userVal -- doubles the value the user entered
The power of Haskell shines with what I will demonstrate next.
Since Haskell understands that each of the types has the same Functor
structure, you can now write functions that operate on any Functor
!
doubleAll :: Functor f => f Int -> f Int
doubleAll = fmap doubleValue
doubleAll optional -- Just 10
doubleAll list -- [2, 4, 6]
doubleAll userVal -- doubles the value the user entered
You can do this in Haskell because of it's support for Higher-Kinded Types. Functors are higher-kinded types, and so are Applicative Functors and Monads. While you can implement the mapping function in C# where it is called Select, you aren't able to write functions that operate on any Functor because of a lack of support for HKTs. The language-ext library attempts to do this in C#, but it is a lot more involved, and I don't recommend it. Here's what a Functor looks like in C# using extension methods.
static class FunctorExtensions {
public static SomeFunctor<B> Select<A, B>(this SomeFunctor<A> source, Func<A, B> f) {
// implementation
}
// Functor laws
// items.Select(static x => x) == (static x => x)
// items.Select(static x => g(f(x))) == items.Select(static x => g(x)).Select(static x => f(x))
// Examples:
public static IEnumerable<B> Select<A, B>(this IEnumerable<A> source, Func<A, B> f) {}
public static B? Select<A, B>(this A? source, Func<A, B> f) {}
public static Task<B> Select<A, B>(this Task<A> source, Func<A, B> f) {}
// Usage:
static int DoubleValue(int x) => x*2;
int? optional = 5;
optional.Select(DoubleValue); // (int?)10
IEnumerable<int> items = [1, 2, 3];
items.Select(DoubleValue); // [2, 4, 6]
Task<int> taskValue = Get5FromDBAsync();
await taskValue.Select(DoubleValue); // 10
}
I have, again, left the instances unimplemented as exercise for the reader.
C#'s Task
type behaves similarly to Haskell's IO
type.
This will become relevant later when I finally explain Monads.
Applicative Functors and Monads
Now that you understand Functors, you are one step away from understanding both Applicative Functors and Monads. These two concepts are related, so I want to talk about the similarities and differences.
As I said before, Monads are used for building sequences of operations. Formally, this means that each operation has a dependency on the previous. In the same way, Applicative Functors are useful for building groups of operations that aren't sequential. In other words, each operation doesn't have a dependency on the others.
Before showing you the definition of Applicative Functors and Monads, I will show you their usage using Haskell's special do
syntax.
In Haskell, do
notation is special syntax that makes working with Applicative Functors and Monads more readable.
It allows you to write sequences of operations in a more imperative style, even though under the hood it's translated to normal function calls.
The left arrow <-
extracts a value from its context, and pure
wraps a value in a context.
Here are examples showing both Applicative Functors and Monads:
-- Applicative Functor
result :: Maybe Int
result = do
a <- Just 2
b <- Just 3 -- no dependency
pure (a+b)
-- result == Just 5
-- Monad
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (div x y)
result :: Maybe Int
result = do
a <- Just 100
b <- safeDiv a 5 -- this line depends on the previous
pure (b+1)
-- result == Just 21
In the first example, each operation is independent, therefore it is only necessary that the Maybe
type has the Applicative Functor
instance implemented for it.
In the second example, the second operation depends on the first, therefore it is necessary that the Maybe
type has the Monad
instance implemented for it.
Previously, I said Functors can be thought of as containers of values.
This is true of Applicative Functors and Monads, since they are both Functors.
However, it is better to view them as values with some associated context.
To build up the operations, you need to define a way to combine the contexts.
You do this in Haskell by implementing the Applicative
and Monad
typeclasses.
class Functor m => Applicative m where
pure :: a -> m a
ap :: m (a -> b) -> m a -> m b -- infix operator is <*>
-- Applicative laws
-- ap (pure id) x == x -- Identity
-- ap (pure f) (pure x) == pure (f x) -- Homomorphism
-- ap (ap (ap (pure (.)) u) v) w = ap u (ap v w) -- Composition
-- ap mf (pure v) == ap (pure (\f -> f v)) mf -- Interchange
class Applicative m => Monad m where
bind :: (a -> m b) -> m a -> m b -- infix operator is >>=
-- Monad laws
-- bind h (pure a) == h a -- Left identity
-- bind pure m == m -- Right identity
-- (bind h (bind g m)) == bind (\x -> bind h (g x)) m -- Associativity
I have taken liberties to simplify these definitions. Here are the actual definitions for Applicative and Monad.
I will now implement all three typeclasses for Maybe
, so you can see an example of how to combine the contexts.
instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just x) = Just (f x)
instance Applicative Maybe where
pure x = Just x
ap _ Nothing = Nothing
ap Nothing _ = Nothing
ap (Just f) (Just x) = Just (f x)
instance Monad Maybe where
bind _ Nothing = Nothing
bind f (Just x) = f x
Once again, here are the actual implementations of the Maybe
instances of Functor
, Applicative
, and Monad
.
For the Maybe
type, the context is presence or absence of a value.
For the IO
type, the context is the side effects or the interactions with the external world.
There are many types that are Monads, and each one has a different context.
Type | Context |
---|---|
Identity | None |
Maybe | Presence/Absence of a value |
IO | Input/Output |
Reader | Configuration |
Writer | List of strings |
State | A mutable value |
RWS | combination of Reader, Writer, and State |
Except | Errors |
Parser | Position and state of parser |
Here is the implementation of the three typeclasses for Nullable<T>
, C#'s equivalent of Maybe a
.
Note that the C# code isn't perfectly analogous to Haskell.
static class NullableInstances {
public static B? Select<A, B>(this A? source, Func<A, B> selector)
where A : struct
where B : struct =>
source.HasValue
? new B?(selector(source.Value))
: null;
public static B? SelectMany<A, B>(this A? source, Func<A, B?> selector)
where A : struct
where B : struct =>
source.HasValue
? selector(source.Value)
: null;
public static C? SelectMany<A, B, C>(this A? source, Func<A, B?> collectionSelector, Func<A, B, C> resultSelector)
where A : struct
where B : struct
where C : struct {
if (source.HasValue) {
var collection = collectionSelector(source.Value);
if (collection.HasValue) {
return resultSelector(source.Value, collection.Value);
}
}
return null;
}
}
The Select
method enables Functor
like behavior and the two SelectMany
overloads enable Applicative
and Monad
like behavior.
C# actually has two special syntaxes for Monads.
The first, LINQ query syntax, is similar to Haskell's do
syntax:
// Applicative Functor
int? result =
from a in new int?(2)
from b in new int?(3) // no dependency
select a + b;
// int? result == 5
// Monad
static int? safeDiv(int x, int y) =>
y == 0 ? null : x / y;
//result :: Maybe Int
int? result =
from a in new int?(100)
from b in safeDiv(a, 5) // this line depends on the previous
select b + 1;
// int? result == 21
And here is async/await syntax for monadic behavior for the Task
and ValueTask
types:
// Applicative Functor
int a = await Get2FromDBAsync();
int b = await Get3FromDBAsync();
int result = a + b;
// int result == 5
// Monad
// Simulating a lookup over the network
static async Task<int> LookupAsync(int x) => await Task.FromResult(x * 2);
//result :: Maybe Int
int a = await Get10FromDBAsync();
int b = await LookupAsync(a); // this line depends on the previous
int result = b + 1;
// int result == 21
Task
and async/await are used for asynchronous I/O in C#.
I hope you get the idea from these simplified examples.
Practical application
And that's pretty much it for an introduction to Monads.
Now I want to give my opinion on when to use what you've learned from this article in practice.
First, Functors are very useful and fairly ubiquitous.
Mapping over a sequence of values is a common operation in nearly every programming language, especially the ones that have generics.
Basically, anytime I have a class with a type variable in C#, I implement the Select
method because it's easy to implement and comes in handy frequently.
In Haskell, it's even easier to implement fmap
because if there is only one option, you can use deriving Functor
to automatically generate a Functor instance.
Monads and Applicative Functors, on the other hand, usually just aren't worth the trouble.
In C#, isolated uses from time to time could be useful.
Creating your own Monad isn't nearly as useful in C# because of the lack of HKT.
When you create your own Monad in Haskell, you get lots of functions that automatically work with your type.
You have to implement each one yourself in C#.
Task
and async/await
are becoming more common in C#, and it's useful to know about their monadic structure.
As you may have noticed, Monads also have a viral quality to them, so once you start using them, they start spreading all over your program via the type system.
This makes them somewhat of a commitment once you start using them.
That's pretty much the extent of my Monad use in C#.
I think this would apply to similar statically typed, object-oriented languages.
I should also mention Rust's error handling with it's Option
and Result
types is monadic.
In Haskell, you are forced to do I/O inside the IO
monad, at least at the top level of your program.
However, in larger programs, it is generally frowned upon to have all of the different types of I/O directly under IO
.
It is frowned upon because you generally want static type safety, especially if you are using Haskell.
Throwing everything into IO
is kind of like having dynamic typing for side effects.
Imagine you have a function with this type signature: getData :: String -> IO ResultType
.
Where does it get the data from?
The console, a file, a SQL database query, an HTTP request?
There are many solutions to this problem in Haskell, and, in my opinion, none of them are good enough. I haven't really followed Haskell for about 6 years, though, so take what I have to say on this topic with a grain of salt. Here is a table containing my view of the state of all of the solutions for dealing with effects in Haskell.
Technique | Description | Libraries | Perf. | BP | Ergo. | Mat. |
---|---|---|---|---|---|---|
IO | Everything in IO | ghc-prim | 🥇 | 🥇 | ❌ | 🥇 |
Transformers stack | Build up a stack and write polymorphic functions | transformers + mtl | 🥉 | 🥇 | 🥈 | 🥇 |
Free Monads | Build abstract syntax tree of effects | free | 🥈 | 🥉 | 🥈 | 🥇 |
ReaderT Env IO | Pass environment with effects through program | ReaderT from transformers | 🥇 | 🥈 | 🥈 | 🥈 |
Effect systems | Define effects and corresponding handlers | extensible-effects, freer-simple, fused-effects, polysemy, effectful | 🥈 | 🥈 | 🥇 | ❌ |
Scala ZIO like | FP ecosystem in Scala, rio was closest in Haskell | rio | 🥇 | 🥇 | 🥇 | ❌ |
As you can see, there are pros and cons to each solution and no definitively superior choice. In my research for this article, I came across a new effect system library called effectful. It seems to have the most activity surrounding it and seems to be the preferred solution at the moment.
I also wanted to mention a new TypeScript library called Effect. This seems to be an effect system similar to Scala ZIO in that it is a standard library-like ecosystem for programming with effects in TypeScript.
Conclusion
In conclusion, I don't think Monads are a great solution to the problem of I/O in pure functional programming, but it is good to understand them, since they pop up here and there in more popular programming languages. Functional programming brings some good ideas to the table, but it isn't a pure win that some make it out to be. Greater abstraction capabilities come at the cost of low-level understanding and, as currently implemented, runtime and compiler performance. Ironically, it can also become a distraction from the task at hand. Maybe, in the future, we will get to a solution that makes this way of programming much better than the alternatives, but we sadly aren't there yet.
My advice is to focus on building something cool no matter the tool, language, or paradigm and to not get too caught up in the hype.