Монады
Добро пожаловать в часть 3 нашей серии абстрактных структур! Мы, наконец, коснемся идеи монад! Множежство людей пытаются изучить монады без попытки заиметь понимания того. как абстрактные структуры типов класса работают. Это главная причина борьбы. Если вы всё еще этого не пониматете, обратитесь к 1 и 2 части этой серии.
После этой статьи вы будете готовы к тому, чтобы писать свой собственный код Haskell.
Букварь монад
Есть множество инструкций и поисаний монад в интернете. Количество аналогий просто смешно. Но вот мои 5 копеек в определении: Монада - обертка значения или вычисления с определенным контекстом. Монада должна определять и смысл обернутого значения в контексте и способ объединения вычислений в контексте.
Это определение достатоно широко. Давайте взглянем на конкретный пример, и попробуем понять.
Классы типы монад
Так же как с функторами и аппликативными функторами, Haskell отражает монады с помощью тип класса. На это есть две функции:
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
Эти две функции отвечают двум идеям выше. Функция возвращения определяем как обернуть значения в контексте монад. Оператор >>=
, который мы назовем его функцией "связывания", определяет как объединить две операции с контекстом. Давайте проясним это далее узучив несколько определенным экземпляров монад.
Монада Maybe
Just as Maybe is a functor and an applicative functor, it is also a monad. To motivate the Maybe monad, let's consider this code.
maybeFunc1 :: String -> Maybe Int
maybeFunc1 "" = Nothing
maybeFunc1 str = Just $ length str
maybeFunc2 :: Int -> Maybe Float
maybeFunc2 i = if i `mod` 2 == 0
then Nothing
else Just ((fromIntegral i) * 3.14159)
maybeFunc3 :: Float -> Maybe [Int]
maybeFunc3 f = if f > 15.0
then Nothing
else Just [floor f, ceiling f]
runMaybeFuncs :: String -> Maybe [Int]
runMaybeFuncs input = case maybeFunc1 input of
Nothing -> Nothing
Just i -> case maybeFunc2 i of
Nothing -> Nothing
Just f -> maybeFunc3 f
We can see we're starting to develop a hideous triangle pattern as we continue pattern matching on the results of successive function calls. If we were to add more Maybe functions onto this, it would keep getting worse. When we consider Maybe as a monad, we can make the code much cleaner. Let's take a look at how Haskell implements Maybe as a monad to see how.
instance Monad Maybe where
return = Just
Nothing >>= _ = Nothing
Just a >>= f = f a
The context the Maybe monad describes is simple. Computations in Maybe can either fail or succeed with a value. We can take any value and wrap it in this context by calling the value a "success". We do this with the Just constructor. We represent failure by Nothing.
We combine computations in this context by examining the result of the first computation. If it succeeded, we takes its value, and pass it to the second computation. If it failed, then we have no value to pass to the next step. So the total computation is a failure. Let's look at how we can use the bind operator to combine our operations:
runMaybeFuncsBind :: String -> Maybe [Int]
runMaybeFuncsBind input = maybeFunc1 input >>= maybeFunc2 >>= maybeFunc3
This looks much cleaner! Let's see why the types work out. The result of maybeFunc1 input is simply Maybe Int. Then the bind operator allows us to take this Maybe Int value and combine it with maybeFunc2, whose type is Int -> Maybe Float. The bind operator resolves these to a Maybe Float. Then we pass this similarly through the bind operator to maybeFunc3, resulting in our final type, Maybe [Int].
Your functions will not always combine so cleanly though. This is where do notation comes into play. We can rewrite the above as:
runMaybeFuncsDo :: String -> Maybe [Int]
runMaybeFuncsDo input = do
i <- maybeFunc1 input
f <- maybeFunc2 i
maybeFunc3 f
The <- operator is special. It effectively unwraps the value on the right-hand side from the monad. This means the value i has type Int, even though the result of maybeFunc1 is Maybe Int. The bind operation happens under the hood. If the function returns Nothing, then the entire runMaybeFuncs function will return Nothing.
At first glance, this looks more complicated than the bind example. However, it gives us a lot more flexibility. Consider if we wanted to add 2 to the integer before calling maybeFunc2. This is easy to deal with in do notation, but more difficult when simply using binds:
runMaybeFuncsDo2 :: String -> Maybe [Int]
runMaybeFuncsDo2 input = do
i <- maybeFunc1 input
f <- maybeFunc2 (i + 2)
maybeFunc3 f
-- Not so nice
runMaybeFuncsBind2 :: String -> Maybe [Int]
runMaybeFuncsBind2 input = maybeFunc1 input
>>= (\i -> maybeFunc2 (i + 2))
>>= maybeFunc3
The gains are even more obvious if we want to use multiple previous results in a function call. Using binds, we would have to continually accumulate arguments in anonymous functions. One note about do notation: we never use <- to unwrap the final operation in a do-block. Our call to maybeFunc3 has the type Maybe [Int]. This is our final type (not [Int]) so we do not unwrap it.
THE EITHER MONAD
Now, let's examine the Either monad, which is quite similar to the Maybe monad. Here's the definition:
instance Monad (Either a) where
return r = Right r
(Left l) >>= _ = Left l
(Right r) >>= f = f r
Whereas the Maybe either succeeds with a value or fails, the Either monad attaches information to failures. Just like Maybe, it wraps values in its context by calling them successful. The monadic behavior also combines operations by short-circuiting on the first failure. Let's see how we can use this to make our code from above more clear.
eitherFunc1 :: String -> Either String Int
eitherFunc1 "" = Left "String cannot be empty!"
eitherFunc1 str = Right $ length str
eitherFunc2 :: Int -> Either String Float
eitherFunc2 i = if i `mod` 2 == 0
then Left "Length cannot be even!"
else Right ((fromIntegral i) * 3.14159)
eitherFunc3 :: Float -> Either String [Int]
eitherFunc3 f = if f > 15.0
then Left "Float is too large!"
else Right [floor f, ceiling f]
runEitherFuncs :: String -> Either String [Int]
runEitherFuncs input = do
i <- eitherFunc1 input
f <- eitherFunc2 i
eitherFunc3 f
Before, every failure just gave us a Nothing value:
>> runMaybeFuncs ""
Nothing
>> runMaybeFuncs "Hi"
Nothing
>> runMaybeFuncs "Hithere"
Nothing
>> runMaybeFuncs "Hit"
Just [9,10]
Now when we run our code, we can look at the resulting error string, and this will tell us which function actually failed.
>> runMaybeFuncs ""
Left "String cannot be empty!"
>> runMaybeFuncs "Hi"
Left "Length cannot be even!"
>> runMaybeFuncs "Hithere"
Left "Float is too large!"
>> runMaybeFuncs "Hit"
Right [9,10]
Notice we parameterize the Either monad by the error type. If we have:
data CustomError = CustomError
maybeFunc2 :: Either CustomError Float
...
This function is in a different monad now. It won't be quite as simple to combine this with our other functions. If you're curious how we might do this, check out this answer on quora.
THE IO MONAD
The IO Monad is perhaps the most important monad in Haskell. It is also one of the hardest monads to understand starting out. Its actual implementation is a bit too intricate to discuss when first learning monads. So we'll learn by example.
The IO monad wraps computations in the following context: "This computation can read information from or write information to the terminal, file system, operating system, and/or network". If you want to get user input, print a message to the user, read information from a file, or make a network call, you'll need to do so within the IO Monad. These are "side effects". We cannot perform them from "pure" Haskell code.
The most important job of pretty much any computer program is to interact with the outside world in some way. For this reason, the root of all executable Haskell code is a function called main, with the type IO (). So every program starts in the IO monad. From here you can get any input you need, call into relatively "pure" code with the inputs, and then output the result in some way. The reverse does not work. You cannot call into IO code from pure code like you can call into a Maybe function from pure code.
Let's look at a simple program showing a few of the basic IO functions. We'll use do-notation to illustrate the similarity to the other monads we've discussed. We list the types of each IO function for clarity.
main :: IO ()
main = do
-- getLine :: IO String
input <- getLine
let uppercased = map Data.Char.toUpper input
-- print :: String -> IO ()
print uppercased
So we see once again each line of our program has type IO a. (A let statement can occur in any monad). Just as we could unwrap i in the maybe example to get an Int instead of a Maybe Int, we can use <- to unwrap the result of getLine as a String. We can then manipulate this value using string functions, and pass the result to the print function.
This is a simple echo program. It reads a line from the terminal, and then prints the line back out in all caps. Hopefully it gives you a basic understanding of how IO works. We'll get into more details in the next couple articles.
SUMMARY
At this point, we should finally have a decent grasp on what monads are. But if they don't make sense yet, don't fret! It took me a few different tries before I really understood them! Don't be afraid to take another look at part 1 and part 2 to give yourself a refresher on Haskell structure basics. And definitely feel free to read this article again!
But if you are feeling good, then you're ready to move on to part 4, where you'll learn about the Reader and Writer monads. These start to bring us access to some of the functionality you think Haskell might be "missing".
If you've never programmed in Haskell before, hopefully I've convinced you that it's not that scary and you're ready to check it out! Download our Beginners Checklist to learn how to get started.