Summary: The article provides examples of tasks for which monads may be needed.
Instead of entry (translation started):
In many “introductions to monads”, monads are presented as something difficult to explain. I want to show that it is in fact, they are not something difficult. In fact, when confronted with various problems in functional programming, you will inexorably come to different solutions, which are often examples of monads. And I hope that you learn to invent them, if you have not yet learned. Looking ahead, I will say that these decisions are essentially the same solution. After reading, you will probably better understand other works on monads, because you will recognize everything that you see as something that you have already invented.
Many problems that monads try to solve are related to the problem of "side effects". And we will start with them. (I note that monads allow much more than the ability to work with side effects, in particular, many of the container objects can be represented as monads. Some introductions to monads do not connect these 2 types and concentrate on one or another type.)
')
Side Effects: Debugging Clean Functions
In imperative programming languages such as C ++, functions behave differently from functions in the mathematical sense. For example, suppose a C ++ function takes 1 argument, a floating point number, and returns a floating point number.
Outwardly, it looks like a mathematical function acting from R to R, but a C ++ function can do something more than just returning a number that depends on the arguments. It can read and write values from global memory, as well as write output to the screen and receive input data from the user. In a pure functional language, functions can only read what is passed through arguments and there is only one way to influence the outside world - the value returned.
So, consider the following problem in a pure programming language: we have functions f and g acting R -> R, and we want to change these functions so that they also output debug information. In Haskell, f and g can be of the following types.
f,g :: Float -> Float
How can we change the types f and g in order to allow for side effects? Well, in fact, we have no choice. If we want f 'and g' to create strings and floating-point numbers, then the only possible way is to return the string along with the floating-point numbers. In other words, the functions f 'and g' must have types
f',g' :: Float -> (Float,String)
We can depict it in the diagram.
x
|
+ --- +
| f '|
+ --- +
| \
| |
fx "f was called."
We will call them "debug functions."
Suppose we want to debug the composition of two such functions. We can write a simple composition of the initial functions,
f and
g , in the form
f. g . But for debugging functions, this cannot be done directly. As we would like, that lines returned
f ' and
g' united in the general debug line (one of
g ' and behind it from
f' ). But we cannot directly combine
f ' and
g' because the type of the return value
g ' does not coincide with the type of the arguments
f' . We can write the solution code as follows:
let (y,s) = g' x
(z,t) = f' y in (z,s++t)
We will depict it on the diagram.
x
|
+ --- +
| g '|
+ --- +
| \
+ --- + | "g was called."
| f '| |
+ --- + |
| \ |
| \ |
| + ---- +
| | ++ |
| + ---- +
| |
f (gx) "g was called. was called."
Each time writing the composition of two functions in this way is difficult, and if we want to write such binding everywhere in our code, it will be a difficult task. What we have to do is define a higher order function in order to implement this binding. The difficulty lies in the fact that the output
g ' cannot be fed to the input
f' and we need to "improve"
f ' . To do this, we need to introduce a function, call it
bind , with the following types
bind f' :: (Float,String) -> (Float,String)
Which works so that
bind :: (Float -> (Float,String)) -> ((Float,String) -> (Float,String))
bind should perform 2 tasks: it should (1) apply
f ' to the desired part
g' x and (2) combine the string returned by
g ' with the string returned by
f' .
Exercise 1write
bind function.
Decisionbind f' (gx,gs) = let (fx,fs) = f' gx in (fx,gs++fs)
Now we can write a composition of debugging functions,
f ' and
g' . Denote their composition as
f '° g' . Even if the output
g 'is incompatible with the input
f' , we have a fairly simple way to combine these operations. And this leads to the following question: the existence of a “single” function. Usually 1 (the identity transformation) has the following properties
f × 1 = f and
1 × f = f . We can find a debug function (let's call unit) such that
unit ° f = f ° unit = f is executed. Obviously, we can write a function that displays empty debugging information, and otherwise works as an identity transform.
Exercise 2Enter
unit .
Decisionunit x = (x,"")
The
unit function allows us to “raise” functions to debug space. In fact:
lift fx = (fx,"")
or more simply
lift f = unit. f . The “lifted” function performs the same as the original one and, which is logical, displays the empty string as a side effect.
Exercise 3show that
lift f ° lift g = lift (f. g)To summarize: the
bind and
unit functions allow us to create debugging functions directly and combine normal functions with debugging functions in a natural way.
Believe it or not, by doing the exercises you defined your first monad. Now it is perhaps not very clear which of the structures is a monad or what other monads might look like. But instead of defining the monad now, I will use other, simpler exercises to lead to other monads in such a way that you yourself will understand their general structure worthy of its name. I'm also sure that most people who are faced with a problem can themselves guess the
bind function to combine debugging functions. I'm also sure that you could have already come up with a monad, even if you did not realize that it was a monad.
Container: multivalued functions
Consider the functions
sqrt and
cbrt , which compute the square and cube root of a real number, respectively. These functions operate from Float to Float (also
sqrt should throw an exception for negative arguments).
Now we will create complex versions of these functions. For every complex number other than zero, there must be 2 square roots. Similarly, every non-zero complex number has 3 cubic roots. Thus, we define the
sqrt ' and
cbrt' functions so that they return a list of numbers:
sqrt',cbrt' :: Complex Float -> [Complex Float]
We will call them multivalued functions.
Suppose we want to find a 6th root of a real number, in which case we can simply combine the square root and the cube root. In other words, we can define a root of 6th degree as
x = sqrt (cbrt x) .
Consider a function that returns all 6 roots of the 6th power of a complex number using
sqrt ' and
cbrt' . We cannot simply write the union of these functions. We must first calculate the cubic roots of a number, and then the square roots of the obtained values and combine all the results into one long list. To do this, we need to write a function (let's call it
bind ) with the definition
bind :: (Complex Double -> [Complex Double]) -> ([Complex Double] -> [Complex Double])
Here is a diagram describing the complete process. We want to write
cbrt ' once, but still want to apply
sqrt' to the resulting values.
64
|
+ ------ +
+ sqrt '+
+ ------ +
+8 / \ -8
+ ------ + + ------ +
| cbrt '| | cbrt '|
+ ------ + + ------ +
| | | | | |
2 . -2 .
Exercise 4Write a bind implementation.
Decisionbind fx = concat (map fx)
How can we write a unit for multivalued functions? The unit returns 1 argument, so in the multi-valued version of the function we must return a list consisting of one element. Let's call the function
unit .
Task 5Write down the unit.
Decisionunit x = [x]
And again we define
f ° g = bind f. g and
lift lift f = unit. f . Raising does what we intended — it explicitly translates a normal function into a multi-valued one.
Exercise 6Show that
f ° unit = unit ° f = f and lift f ° lift g = lift (fg)Again, the above problem adamantly led us to the
bind function.
After doing the exercises, you created your second monad. Perhaps you already understand the overall design pattern. It is strange that these two different looking problems lead to common constructions.
More complex side effect example: random numbers
Haskell random numbers look like this.
random :: StdGen → (a, StdGen)
The idea is that in order to generate a random number, we need a seed, and after generation we need to update it. In a non-pure language, we can make the grain a global variable and the user does not need to work with it directly. But in pure language, the grain must be transmitted and received explicitly (this is the so-called signature describing randomness). Note that this problem is similar to the debugging cases described above in that they return additional information using a pair. But in this issue, we also provide additional information.
So, a function that is a random function
a -> b can be rewritten as a function
a → StdGet → (b, StdGen) , where
StdGen is a grain.
Now we have to figure out how to make the composition of 2 random functions, f and g. The "real" return value f must be passed to the first argument g. Meanwhile, the grain returned by f by the second element of the pair must also be passed to g. So we can write the type of the bind function.
bind :: (a → StdGen → (b, StdGen)) → (StdGen → (a, StdGen)) → (StdGen → (b, StdGen))
Boom 7implement bind
Decision bind fx seed = let (x ', seed') = x seed in fx 'seed'
Now we have to come up with a unit. She must have type
unit :: a → (StdGen → (a, StdGen))
and leave the grain unmodified.
Exercise 8Implement unit.
Decision unit xg = (x, g)
or simply
unit = (,)
Again, we can define the operations f ° g = bind fg and lift lift f = unit. f. Raising does what it is supposed to do — it translates a normal function into a randomized one and leaves the grain unchanged.
Exercise 9Show that f ° unit = unit ° f = f and lift f ° lift g = lift (fg)
Monads
Now it's time to go back to the beginning and notice the general structure.
Define
type Debuggable a = (a, String)
type Multivalued a = [a]
type Randomized a = StdGen -> (a, StdGen)
We use the variable m to implement Debuggable, Multivalued, or Randomized. In each case, we encounter the same problem. We have a function
a -> mb , but we must be able to apply this function to an object type
ma instead of type
a . To do this, we define a binding function (called
bind ) of type
(a -> mb) -> (ma -> mb) and introduce the
unit transformation
unit :: a -> ma . In addition, we require that the requirements be met:
f ° unit = unit ° f = f and
lift f ° lift g = lift (fg) , where (
° ) and
lift are defined in terms of
unit and
bind .
Now, I can say what a monad is. The triple of objects
(m, unit, bind) is a monad, but in order to be a monad, they must satisfy the laws that you have proved above. And I think that in each of the three cases, you can invent the
bind function, even if you did not notice that all 3 cases are united by a common structure.
So, I have to connect this with the definition of Haskell monads. And the first thing I reflected and labeled with the word '
bind ' is the definition of the
bind function, which is written as
>> >> operator. Then the bunch
fx is rewritten as
x >> = f . Secondly, the
unit is called
return . And thirdly, to override functions
>> = and
return , we must use type classes. In Haskell, the Debuggable is the monad of the Writer, Multivalued is the monad of the List, and Randomized is the monad of the State. If you check the definitions of the following things
Control.Monad.Writer Control.Monad.List Control.Monad.Stateyou will see, up to syntax sugar, and these are the definitions you wrote above. This is how you were introduced to the monads!
Monad syntax
I don't want to spend a lot of time on this (and you can skip this section), because There are a lot of better injections.
You have already seen how the
bind function can provide a convenient way to associate functions and eliminates the need to write fairly ugly code. But Haskell goes further, and you don’t even need to directly use the
bind function, you can “ask” Haskell to insert them automatically.
Let's go back to the original debugging example using the official type classes. Where we used the pair
(a, s) we will use the
Writer (a, s) of type
Writer Char . To get this pair, use the
runWriter function. Suppose we want to add 1, multiply by 2 and subtract 7, and write down what we are doing at each step. Total we can write
return 7 >> = (\ x -> Writer (x + 1, "inc."))
>> = (\ x -> Writer (2 * x, "double."))
>> = (\ x -> Writer (x-1, "dec."))
If we apply the
runWriter function to this result, we get (15, “inc.double.dec.”). But the code is still not beautiful. To improve it, use the do syntax. The idea is that
do x <- y
more code
will be automatically rewritten by the compiler in
y >> = (\ x -> do
more code).
Same
do
let x = y
more code
will be rewritten to
(\ x -> do
more code) y
and
do
expression
and leaves just an expression.
We can rewrite the code as follows:
do
let x = 7
y <- Writer (x + 1, "inc \ n")
z <- Writer (2 * y, "double \ n")
Writer (z-1, "dec \ n")
This record is very indicative. When we write y <- ..., we can assume that the expression on the right side is just x + 1 with some side effects caused by the operation.
Another example. We write our function of finding the root of 6 degrees in a bulky form
return 64 >> = (\ x -> sqrt 'x) >> = (\ y -> cbrt' y)
We can rewrite the code in more readable
do
let x = 64
y <- sqrt 'x
z <- cbrt 'y
return z
We can write code that looks like ordinary non-multi-valued code and implicitly invoke
bind binding, which Haskell inserts automatically, making it multi-valued.
Creating such a syntax is the work of genius. Maybe you could invent it, but I’m sure I couldn’t. But these additions are actually only syntactic sugar on top of the monads. I’m sure you could come up with an underlying monad device.
Input Output
There remains one more thing that we must pay attention to before you fully understand the monads. Communication with the outside world. All that is written above can refer to any pure functional language. But now let's pay attention to lazy pure functional languages. In such languages, you have no idea in what order things will be calculated. So, if you have a function that displays the message “Enter a number” and another function that requests a number, you cannot guarantee that the message will be written before the number is requested! Look at an example of a random function, notice how a random grain is driven through all functions so that it can be used with every call to
random . Some sorting happens. Suppose we have
x >> = f >> = g since
g uses the grain returned by
f we can be sure that
f will generate a number before
g . And this shows that, in principle, monads can be used to streamline calculations.
Now we propose the implementation of a random number in the compiler. This is just a C or assembler code inserted into the Haskell executable. This code is modified for I / O execution. We can guarantee that
f is executed before
g . This is exactly as I / O works in Haskell, we do all I / O in the monad. In this case, the function having type a-> b and a side effect in the outside world becomes type a -> IO b. Type IO is a black box, we do not need to know what is in it. (Maybe it works as an example of random, maybe not) We just need to know that in x >> = f >> = gf it is executed before g.
Category Theory
And finally. Monads were invented in the context of category theory. And I will leave this connection for another day.
Addition: complete sample code using random numbers
import random
bind :: (a -> StdGen -> (b, StdGen)) -> (StdGen -> (a, StdGen)) -> (StdGen -> (b, StdGen))
bind fx seed = let (x ', seed') = x seed in fx 'seed'
unit xg = (x, g)
lift f = unit. f
And what we are going to do: we want to build 2 (decimal) numbers by following these steps
(1) create a random number in the range [0.9]
(2) multiply it by 10
(3) add another random number from the range [0.9]
In general, this is the composition of something like this
addDigit. (*ten). addDigit . But we know that we have to pass a random grain through the entire code. First, consider the definition of
addDigit :
addDigit ng = let (a, g ') = random g in (n + a `mod` 10, g')
it returns a pair consisting of n + random numbers and a new seed. Notice that it uses grain as an additional argument. One can argue that the function may look like
addDigit n = let a = random in n + a `mod` 10 and it is portable in a non-clean language.
Now we introduce the operation of multiplication by 10. This is a normal operation, which we can “improve” using lift. Get
shift = lift (* 10)
And this is the decisive moment. We cannot do composition, and instead we can use binding to convert functions to the format in which they can be combined. Total
addDigit. (* 10) .addDigit turns into
test :: Integer -> StdGen -> (Integer, StdGen)
test = bind addDigit. bind shift. addDigit
Now we will create a grain using my favorite number and run the code.
g = mkStdGen 666
main = print $ test 0 g
Note that when writing the examples I did not specifically use the Monad type class. Therefore, nothing was hidden from you and everything was done explicitly.