I would argue that they require quite a different mindset to absorb - a mindset that is not fostered when primarily doing rote imperative programming work. During my university education I had to maintain some modicum of mathematical competence in order to complete the curriculum. Sadly that skillset was never challenged (and therefore maintained) during subsequent professional work. What I’m saying is that monads are abstract in a mathematical way, which is (at least to me) a different kind of abstract that you are dealing with when you design your in-the-wild software solutions.
So for the time being getting a more intuitive sense of what “monads” are really about is an ongoing process for me.
It was during my intensified exposure to pattern matching with Erlang that I started to see the relationship of Option to ({:ok, value} | :error)
and Either (or Result) to ({:ok, value} | {:error, reason})
. Shortly thereafter I looked at Kernel.with/1
and all of a sudden bind
popped into my head.
The point I’m trying to make is that I really like those ResultMonad
, OptionMonad
, ListMonad
modules - but I also still have enough of a beginner’s mind to realize that they are going to look absolutely alien to Elixir neophytes who don’t have any pre-exposure. (I still hate the name bind
because it never helped me uncover the mystery behind it - but it’s not like I have a more descriptive name for it).
Now one has to be careful, while beginner friendliness is important to foster adoption, it can go overboard (I’m looking at you JavaScript).
But in terms of fostering understanding why this approach is a good idea it may be necessary to “fill-in” some much more pedestrian steps in between (not for production use - just for learning/understanding).
For example from
content =
Enum.find_value(sources, fn source ->
File.exists?(source) && File.read!(source)
end) || raise "could not find #{source_file_path} in any of the sources"
to
{:ok, content} =
Enum.find_value(sources, &Option.from_result(File.read(&1)))
~> fn _ -> raise "could not find #{source_file_path} in any of the sources" end
# Or do `|> unwrap()` here and replace the binding above with just `content =`
there is still the danger of this happening.
So when I approached this in a more pedestrian manner I arrived at this:
defmodule M do
def bind_option({:ok, value}, fun), do: fun.(value)
def bind_option(:error, _fun), do: :error
def unwrap_option({:ok, value}, _on_err), do: value
def unwrap_option(:error, on_err), do: on_err.()
def bind_result({:ok, value}, fun), do: fun.(value)
def bind_result({:error, _reason} = err, _fun), do: err
def unwrap_result({:ok, value}, _on_err), do: value
def unwrap_result({:error, reason}, on_err), do: on_err.(reason)
def some_fun do
source_file_path = "whatever"
sources = []
on_err = fn (_reason) ->
raise "could not find #{source_file_path} in any of the sources"
end
sources
|> Enum.find_value({:error, :enoent}, &File.read/1)
|> unwrap_result(on_err)
end
end
Now unfortunately this example doesn’t benefit from bind
directly but I think the juxtaposition of bind
and unwrap
both using pattern matching helps towards building a more intuitive sense towards what bind
is all about (and understanding what those modules are about) - provided one has already accepted pattern matching as part of the everyday toolbox (something that I find one is driven to in Erlang - not so much in Elixir).
Ultimately I find that bind
makes these ubiquitous tagged tuples more “pipe friendly” (and Elixir developers love their pipes).
PS: I still believe that the readability of the following (alternate) code suffers more from the inline definition of the anonymous function than the if
conditional - I would find Enum.search(sources, &get_file/1)
much more readable.
res =
Enum.search(sources, fn source ->
if File.exists?(source) do
{:ok, File.read!(source)}
else
:error
end
end)
content =
case res do
:error -> raise "could not find #{source_file_path} in any of the sources"
{:ok, content} -> content
end
And I still cannot warm up to using either Kernel.&&/2
nor Kernel.||/2
- how would you even spec’ that in an “intention revealing” manner? This (non-valid) pseudo-spec
@spec || (l,r) :: r when l: (false|nil), r: as_boolean(term())
@spec || (l,_) :: l when l: as_boolean(term())
@spec && (l,_) :: l when l: (false|nil)
@spec && (_,r) :: r when r: as_boolean(term())
makes my skin crawl.
I felt this type of usage was a bad idea when I first encountered it in JavaScript and I haven’t come across an argument yet to convince me otherwise.