Monads are used to build a function (function composition) and get rid of the tedious monotony associated with it. After seven years of programming on Go, the need to repeat if err != nil
becomes a routine. Every time I write this line, I thank the Gophers for a readable language with excellent tools, but at the same time curse me for feeling punished by Bart Simpson.
I suspect that this feeling is shared by many , but
if err != nil { log.Printf("This should still be interesting to a Go programmer " + "considering using a functional language, despite %v.", err) }
Monads are needed not only to hide error handling from us, but also for list inclusions, and for consistency, and for other tasks.
Eric Meyer (Erik Meijer) in his article Introduction to Functional Programming Course on Edx asks you not to write about monads anymore, because a lot has been said about them already.
In addition to reading this article, I recommend watching Bartosz Milevski’s series of category theory videos , which ends with a presentation with a better explanation of the monads I’ve met.
Do not read further!
Okay (sigh) ... Just remember: I warned you.
Before monads, we need to deal with functors. A functor is a superclass of a monad, that is, all monads are functors. I will use functors in further explaining the essence of monads, so do not skip this section.
A functor can be considered a container containing one type of element.
For example:
[]T
- container, the elements of which are arranged in a list.type Node<T> struct { Value T; Children: []Node<T> }
type Node<T> struct { Value T; Children: []Node<T> }
- a container whose elements are arranged in the form of a tree.<-chan T
is a container and looks like a pipe through which water flows.*T
pointer is a container that can be empty or contain one element.func(A) T
is a container like a safe that you have to open with a key to see the item stored in it.func() (T, error)
is a container that may contain one element. The error can be viewed as part of the container. Hereinafter, we will call (T, error)
tuple.Programmers who do not own Go, but other languages: Go does not have algebraic data types or union types. This means that instead of returning a function value or an error, a value and an error are returned here, and one of them is usually nil. Sometimes we break the agreement and return value and error, both not nil, just to try to confuse each other. Or have some fun.
The most popular way to get combined types in Go is to have an interface (abstract class) and a type switch in the interface type.
Another criterion, without which the container is not considered a functor, is the need to implement the fmap
function for this type of container. The fmap
function applies a function to each element in a container without changing the container or structure.
func fmap(f func(A) B, aContainerOfA Container<A>) Container<B>
Using the map
function as a slice is a classic example that you could see in mapreduce in Hadoop, Python, Ruby, and almost any other language:
func fmap(f func(A) B, as []A) []B { bs := make([]b, len(as)) for i, a := range as { bs[i] = f(a) } return bs }
We can also implement fmap
for the tree:
func fmap(f func(A) B, atree Node<A>) Node<B> { btree := Node<B>{ Value: f(atree.Value), Children: make([]Node<B>, len(atree.Children)), } for i, c := range atree.Children { btree.Children[i] = fmap(f, c) } return btree }
Or for the channel:
func fmap(f func(A) B, in <-chan A) <-chan B { out := make(chan B, cap(in)) go func() { for a := range in { b := f(a) out <- b } close(out) }() return out }
Or for the pointer:
func fmap(f func(A) B, a *A) *B { if a == nil { return nil } b := f(*a) return &b }
Or for the function:
func fmap(f func(A) B, g func(C) A) func(C) B { return func(c C) B { a := g(c) return f(a) } }
Or for the function that returns an error:
func fmap(f func(A) B, g func() (*A, error)) func() (*B, error) { return func() (*B, error) { a, err := g() if err != nil { return nil, err } b := f(*a) return &b, nil } }
All these containers with the corresponding implementations of fmap
are functors.
Now we know that the functor is the abstract name of the container and that we can apply the function to the elements inside the container. We now turn to the essence of the article - to the abstract concept of the monad.
A monad is simply a "decorated" type. Hmm, I suppose it did not become clearer, too abstract. This is the typical problem of all explanations of the essence of monads. It is like an attempt to explain what a side effect is: the description will be too general. So let's better deal with the cause of the abstractness of the monad. The reason is to compose the functions that return these decorated types.
Let's start with the usual layout of functions, without decorated types. In this example, we build the functions f
and g
and return a function that takes the input data that is expected by f
and returns the output data from g
:
func compose(f func(A) B, g func(B) C) func(A) C { return func(a A) C { b := f(a) c := g(b) return c } }
Obviously, this will only work if the resulting type f
matches the input type g
.
Another version: the layout of functions that return errors.
func compose( f func(*A) (*B, error), g func(*B) (*C, error), ) func(*A) (*C, error) { return func(a *A) (*C, error) { b, err := f(a) if err != nil { return nil, err } c, err := g(b) return c, err } }
Now we will try to abstract this error in the form of an embellishment (M) and see what remains:
func compose(f func(A) M<B>, g func(B) M<C>) func(A) M<C> { return func(a A) M<C> { mb := f(a) // ... return mc } }
We need to return a function that takes A
as an input parameter, so let's start with the declaration of the return function. Since we have A
, we can call f
and get the value of mb
type M<b>
, but what next?
We did not reach the goal, because it turned out too abstract. I want to say that we have mb
, just what to do with it?
When we knew that this was a mistake, we could test it, but now we cannot, because it is too abstract.
But ... if we know that our decoration M
is also a functor, then we can apply fmap
to it:
type fmap = func(func(A) B, M<A>) M<B>
The function g
, to which we want to apply fmap
, does not return a simple type like C
, it returns M<C>
. Fortunately, for fmap
this is not a problem, but the type signature changes:
type fmap = func(func(B) M<C>, M<B>) M<M<C>>
Now we have a mmc
value of type M<M<C>>
:
func compose(f func(A) M<B>, g func(B) M<C>) func(A) M<C> { return func(a A) M<C> { mb := f(a) mmc := fmap(g, mb) // ... return mc } }
We want to go from M<M<C>>
to M<C>
.
For this, we need our decoration M
not just to be a functor, but to have another property. This property is the join
function, which is defined for each monad, as fmap
was defined for each functor.
type join = func(M<M<C>>) M<C>
Now we can write:
func compose(f func(A) M<B>, g func(B) M<C>) func(A) M<C> { return func(a A) M<C> { mb := f(a) mmc := fmap(g, mb) mc := join(mmc) return mc } }
This means that if fmap
and join
defined during decoration, then we can build two functions that return decorated types. In other words, it is necessary to define these two functions so that the type is a monad.
Monads are functors, so we don’t need to define fmap
for them again. It is necessary to define only join
.
type join = func(M<M<C>>) M<C>
Now we define join
:
The easiest way to get started is to apply join
to slices. This function simply concatenates all slices.
func join(ss [][]T) []T { s := []T{} for i := range ss { s = append(s, ss[i]...) } return s }
Let's see why we needed join
again, but this time we will focus on slices. Here is the compose
function for them:
func compose(f func(A) []B, g func(B) []C) func(A) []C { return func(a A) []C { bs := f(a) css := fmap(g, bs) cs := join(css) return cs } }
If we pass a
to f
, we get bs
type []B
Now we can apply fmap
to []B
with g
, which will give us a value of type [][]C
, not []C
:
func fmap(g func(B) []C, bs []B) [][]C { css := make([][]C, len(bs)) for i, b := range bs { css[i] = g(b) } return css }
That's what we need to join
. We move from css
to cs
, or from [][]C
to []C
Let's look at a more specific example.
If we replace types:
A
to int
,B
on int64
,C
on string
.Then our function becomes:
func compose(f func(int) []int64, g func(int64) []string) func(int) []string func fmap(g func(int64) []string, bs []int64) [][]string func join(css [][]string) []string
Now we can use them in our example:
func upto(n int) []int64 { nums := make([]int64, n) for i := range nums { nums[i] = int64(i+1) } return nums } func pair(x int64) []string { return []string{strconv.FormatInt(x, 10), strconv.FormatInt(-1*x, 10)} } c := compose(upto, pair) c(3) // "1","-1","2","-2","3","-3"
It turns out a slice of our first monad.
Curiously, this is exactly what Haskell list expressions work with:
[ y | x <- [1..3], y <- [show x, show (-1 * x)] ]
But you could recognize them by the example of Python:
def pair (x): return [str(x), str(-1*x)] [y for x in range(1,4) for y in pair(x) ]
We can also define join
for functions that return a value and an error. To do this, you first need to go back to the fmap
function due to some idiosyncrasy in Go:
type fmap = func(f func(B) C, g func(A) (B, error)) func(A) (C, error)
We know that our layout function is going to call fmap
with function f
, which also returns an error. As a result, the fmap
signature looks like this:
type fmap = func( f func(B) (C, error), g func(A) (B, error), ) func(A) ((C, error), error)
Unfortunately, the tuples in Go do not belong to the first level objects, so we cannot simply write:
((C, error), error)
There are several ways around this. I prefer the function because the functions that return tuples are first-level objects:
(func() (C, error), error)
Now, using our tricks, we can define fmap
for functions that return a value and an error:
func fmap( f func(B) (C, error), g func(A) (B, error), ) func(A) (func() (C, error), error) { return func(a A) (func() (C, error), error) { b, err := g(a) if err != nil { return nil, err } c, err := f(b) return func() (C, error) { return c, err }, nil } }
This brings us back to the main point: the join
function with reference to (func() (C, error), error)
. The solution is simple and performs one of the error checks for us.
func join(f func() (C, error), err error) (C, error) { if err != nil { return nil, err } return f() }
Now we can use the compose
function, since we have already defined join
and fmap
:
func unmarshal(data []byte) (s string, err error) { err = json.Unmarshal(data, &s) return } getnum := compose( unmarshal, strconv.Atoi, ) getnum(`"1"`) // 1, nil
As a result, we need to perform fewer error checks, because the monad does it for us in the background using the join
function.
Here is another example where I feel like Bart Simpson:
func upgradeUser(endpoint, username string) error { getEndpoint := fmt.Sprintf("%s/oldusers/%s", endpoint, username) postEndpoint := fmt.Sprintf("%s/newusers/%s", endpoint, username) req, err := http.Get(genEndpoint) if err != nil { return err } data, err := ioutil.ReadAll(req.Body) if err != nil { return err } olduser, err := user.NewFromJson(data) if err != nil { return err } newuser, err := user.NewUserFromUser(olduser), if err != nil { return err } buf, err := json.Marshal(newuser) if err != nil { return err } _, err = http.Post( postEndpoint, "application/json", bytes.NewBuffer(buf), ) return err }
Technically, compose
can take as parameters more than two functions. Therefore, we collect all the above functions in one call and rewrite our example:
func upgradeUser(endpoint, username string) error { getEndpoint := fmt.Sprintf("%s/oldusers/%s", endpoint, username) postEndpoint := fmt.Sprintf("%s/newusers/%s", endpoint, username) _, err := compose( http.Get, func(req *http.Response) ([]byte, error) { return ioutil.ReadAll(req.Body) }, newUserFromJson, newUserFromUser, json.Marshal, func(buf []byte) (*http.Response, error) { return http.Post( postEndpoint, "application/json", bytes.NewBuffer(buf), ) }, )(getEndpoint) return err }
There are many other monads. Imagine two functions that return the same type of decoration that you want to put together. Let's sort one more example.
You can define join
for channels.
func join(in <-chan <-chan T) <-chan T { out := make(chan T) go func() { wait := sync.WaitGroup{} for c := range in { wait.Add(1) go func(inner <-chan T) { for t := range inner { out <- t } wait.Done() }(c) } wait.Wait() close(out) }() return out }
Here we have the channel in
, which supplies us with channels of type T
First, we will create an out
channel, run Gorutina to service the channel, and then return it. Inside the gorutiny we will launch new gorutins for each of the channels reading from in
. These gorutins send out
their inbound events, combining the input data into one stream. Finally, with the help of a wait group, make sure to close the out
channel with all the input data.
In other words, we read all the T
channels from in
and transfer them to the out
channel.
For programmers not on Go: I need to pass c
as a parameter to internal gorutina, because c
is the only variable that takes the value of each element in the channel. This means that if instead of creating a copy of a value by passing it as a parameter, we simply use it inside the circuit, then we can probably read only from the freshest channel. This is a common error among Go programmers .
We can define the compose
function for a function that returns channels.
func compose(f func(A) <-chan B, g func(B) <-chan C) func(A) <-chan C { return func(a A) <-chan C { chanOfB := f(a) return join(fmap(g, chanOfB)) } }
And because of the way we implement join
we get consistency almost for nothing.
func toChan(lines []string) <-chan string { c := make(chan string) go func() { for _, line := range lines { c <- line } close(c) }() return c } func wordsize(line string) <-chan int { removePunc := strings.NewReplacer( ",", "", "'", "", "!", "", ".", "", "(", "", ")", "", ":", "", ) c := make(chan int) go func() { words := strings.Split(line, " ") for _, word := range words { c <- len(removePunc.Replace(word)) } close(c) }() return c } sizes := compose( toChan([]string{ "Bart: Eat my monads!", "Monads: I don't think that's a very good idea.", "Lisa: If anyone wants monads, I'll be in my room.", "Homer: Mmm monads", "Maggie: (Pacifier Suck)", }), wordsize, ) total := 0 for _, size := range sizes { if size == 6 { total += 1 } } // total == 6
This explanation of the monads was made practically on the fingers, and, to make it easier to understand, I deliberately dropped many things. But there is something else that I want to talk about.
Technically, our layout function defined in the previous chapter is called Kleisli Arrow
.
type kleisliArrow = func(func(A) M<B>, func(B) M<C>) func(A) M<C>
When people talk about monads, they rarely mention Kleisli Arrow
, but for me this was the key to understanding the essence of monads. If you're lucky, they will explain you the essence with fmap
and join
, but if you're unlucky, like me, they will explain it to you with the bind
function.
type bind = func(M<B>, func(B) M<C>) M<C>
Why?
Because bind
is such a function in Haskell that you need to implement for your type, if you want it to be considered a monad.
Repeat our implementation of the compose
function:
func compose(f func(A) M<B>, g func(B) M<C>) func(A) M<C> { return func(a A) M<C> { mb := f(a) mmc := fmap(g, mb) mc := join(mmc) return mc } }
If the bind
function were implemented, we could just call it instead of fmap
and join
.
func compose(f func(A) M<B>, g func(B) M<C>) func(A) M<C> { return func(a A) M<C> { mb := f(a) mc := bind(mb, g) return mc } }
This means that bind(mb, g) = join(fmap(g, mb))
.
The role of the bind
function for lists will be performed depending on the language concatMap
or flatMap
.
func concatMap([]A, func(A) []B) []B
I found that in Go, the distinction between bind
and Kleisli Arrow
began to blur for me. Go returns an error in the tuple, but the tuple is not a first-level object. For example, this code will not be compiled because you cannot inline the results of f
to g
by inlining:
func f() (int, error) { return 1, nil } func g(i int, err error, j int) int { if err != nil { return 0 } return i + j } func main() { i := g(f(), 1) println(i) }
We'll have to write this:
func main() { i, err := f() j := g(i, err, 1) println(j) }
Or make g
take a function as input, because functions are first-level objects.
func f() (int, error) { return 1, nil } func g(ff func() (int, error), j int) int { i, err := ff() if err != nil { return 0 } return i + j } func main() { i := g(f, 1) println(i) }
But that means our bind
function:
type bind = func(M<B>, func(B) M<C>) M<C>
defined for errors:
type bind = func(b B, err error, g func(B) (C, error)) (C, error)
it will be unpleasant to use until we convert this tuple into a function:
type bind = func(f func() (B, error), g func(B) (C, error)) (C, error)
If you look closely, you can see that our returned tuple is also a function:
type bind = func(f func() (B, error), g func(B) (C, error)) func() (C, error)
And if you look again, it turns out that this is our compose
function, in which f
simply receives zero parameters:
type compose = func(f func(A) (B, error), g func(B) (C, error)) func(A) (C, error)
Tadam! We got our Kleisli Arrow
, just a few times carefully looking.
type compose = func(f func(A) M<B>, g func(B) M<C>) func(A) M<C>
With the help of decorated types, monads hide the routine logic of the arrangement of functions from us, so that we can feel not punished by Bart Simpson, but by skating and throwing a ball aptly.
If you want to try monads and other functional programming concepts in Go, you can do it with my GoDerive code generator .
A warning. One of the key concepts of functional programming is immutability. This not only simplifies the work of programs, but also allows you to optimize the compilation. In Go, to emulate immutability you have to copy a lot of structures, which will decrease performance. Functional languages get away with it, because they can rely on immutability and always refer to the old values, rather than copying them again.
If you really want to do functional programming, then pay attention to Elm . This is a statically typed functional programming language for front-end development. For a functional language, it is easy to learn, as Go is simple for imperative. I wrote a guide during the day and was able to start working productively that evening. The creator of the language tried to make learning it easy, even eliminating the need to deal with monads. Personally, I like to write frontend on Elm in combination with the backend on Go. And if both languages have already bored you, do not worry, there is still a lot of interesting things ahead, Haskell is waiting for you.
Source: https://habr.com/ru/post/339426/
All Articles