It sounds to me like you're running into some of the same conceptual issues I did when I tried to understand monads for the first time. For me, part of the issue was that I was trying to think about them in terms of a language which lacked the power necessary to create the abstraction of a monad.
That seems really abstract but (hopefully) this will help you or someone out there. Because the languages I knew couldn't express it, I had trouble understanding it. For just a minute, think about a language that lacks templates/generics (I will pick on C# 1 here). I can write a list of strings (implementation details omitted for brevity)
public class StringList
{
public void Add(string value);
public string this[int i] { get; set; }
public string RemoveAt(int i);
}
and I can also write a list of integers:
public class IntList
{
public void Add(int value);
public int this[int i] { get; set; }
public int RemoveAt(int i);
}
but I don't have any way to abstract over these two lists in a completely type-safe way. The best I can do would be to use object
everywhere and add runtime casts. So in terms of C# 1, there's no way to express just the concept of a List
itself, you can only express specific instances of that List
: StringList
, IntList
, etc. So while you or I could talk about List
in the abstract, there's no way we can do so with actual code. But we both understand what a List
looks like and we can use that knowledge to implement different concrete List
instances correctly according to how List
s behave.
Of course with C# 2, we got generic types and so this became trivial:
public class List<T>
{
public void Add(T value);
public T this[int i] { get; set; }
public T RemoveAt(int i);
}
And now the language has enough abstract power to talk about List
correctly. We can use this to implement functions that care only about List
but not exactly what is inside the list. For example:
public bool IsEmpty<T>(List<T> list);
public int BinarySearch<T>(List<T> list) where T: IComparison<T>
etc. The problem though is that even though we have added all of this expressiveness to the type system, we still lack the power to describe Monad
s in the language. We can of course create whatever concrete instances of them we want:
public class Option<T>
{
public Option<U> Map<U>(func: Func<T, U>);
public static Option<T> Empty { get; }
}
public class List<T>
{
public List<U> Map<U>(func: Func<T, U>);
public static List<T> Empty { get; }
}
and while we look at these types and see a similarity there, we don't have any way to express that similarity within the bounds of the C# type system. The thing we're missing is that we have no way to talk about or use a generic type T
which is itself generic. We want to be able to say something like
public TMonad<U> Map<TMonad<_>, T, U>(func: Func<T, U>) where TMonad: Monad { ... }
When calling this function, you would use a generic type for TMonad
like List
(which is still generic!):
List<string> strings = Map<List, int, string>([1, 2, 3], i => i.ToString());
If generic types let you abstract over a type used in your class or struct, then this goes one step further and lets you abstract over the generic version of that type. This is called higher-kinded types.
Neither C# nor Rust have that feature and without it, you can't write the definition of Monad
in those languages, you can only write concrete instances of it (which themselves might still be generic) much like how without generics, you can't write the definition of List
, you can only write instances of it.
However, just because we can't write that definition today, doesn't mean there isn't value in talking about Monad
s because they provide a guide to implementing concrete instances of them in our language.
@FedericoStra provided a great answer about what Monad
might look like in Rust hypothetically so I won't recreate that code here.
I will say though that a lot of the "monad functions" like map
, flatMap
, fold
, etc tend to be used to deal with wrapping or unwrapping values from monads:
-
map
takes a function that lets you use the value inside a Monad
as if it were just a regular T
value. If that doesn't sound useful, think about how ugly your code would be if you had to match
on every Option
everywhere or always use for
loops instead of the Iterator
methods.
-
flatMap
takes a function that converts the T
value inside your Monad
into a U
value inside your Monad
.
-
For Option
, that means fn flat_map<T, U>(opt: Option<T>, f: impl Fn(T) -> Option<U>) -> Option<U>
. Aka, now you can run an operation that can return None
inside your map
but instead of getting an Option<Option<U>>
, you just get an Option<U>
.
-
For Iterator
, that means fn flat_map<T, U>(iter: Iterator<Item = T>, f: impl Fn(T) -> Iterator<Item = U>) -> Iterator<Item = U>
. Aka, now you can run an operation for each element of an iterator and return 0, 1 or more items and instead of getting an Iterator<Item = Iterator<Item = U>>
, you just get an Iterator<Item = U>
.
etc...
You can look at these different instances of those functions and, if you squint a bit, you can see how they behave similarly even though Option
and Iterator
are pretty different things. That similarity is essentially what Monad
is.