Монады Reader и Writer
InВ partчасти 3 ofэтой thisсерии, series,мы weнаконец finallyзатронули tackledидею theмонад. conceptualМы ideaизучили ofчто monads.они Weтакое, learnedи whatувидели theyкак are,некоторые andобщие sawтипы, howнапример someIO
commonи typesMaybe
, likeработают в качестве монад. В этой части, мы посмотрим на некоторые другие полезные монады. В частности мы рассмотрим монады Reader
и Writer
.
Глобальные переменные(или их нехватка)
В Haskell, наш код в общем "чистый", что значит, что функции могут только взаимодействовать с аргументами переданными им. Смысл в том, чтобы мы не могли имметь глобальных переменных. Мы можем ипеть глобальные выражения, но они фиксируются во время компиляции. Если поведение пользователя может изменить их, нам нужно обернуть их в IO
andмонаду, Maybeчто workзначит, asчто monads.мы Nowне inможем thisиспользовать part,её we'llв start"чистом" looking at some other useful monads. In particular, we'll consider the Reader and Writer monads.коде.
IfПредставим youследующий haven'tпример. startedМы writingхотим yourиметь ownEnvironment
Haskellсодержащее yet,параметры youв haveкачестве allглобальных theпеременных. tools and skills you need to do so! Just download our Beginners Checklist to learn how to get started! Writing your own code will help a lot with understanding the examples in these articles!
This article has quite a bit of code that goes along with it. In the Github Repository for this series, you can follow along with it! You can take a look at the ReaderWriter module and fill in the "TODOs" as you're reading this article. If you're just looking for the final reference, you can also open up ReaderWriteComplete.
GLOBAL VARIABLES (OR A LACK THEREOF)
In Haskell, our code is generally "pure"Однако, meaningмы functionsдолжны canих onlyзагрузить interactчерез withконфигурационный theфайл argumentsили passedкомандную toстроку, them.что Thisтрубует effectivelyIO
means we cannot have global variables. We can have global expressions, but these are fixed at compile time. If user behavior might change them, we have to wrap them in the IO monad, which means they can't be used from pure code.
Consider this example. Here, we want to have an Environment containing different parameters as a global variable. However, we might have to load these from a config file or a command line interface, which requires the IO monad.монаду.
main1 :: IO ()
main1 = do
env <- loadEnv
let str = func1 env
print str
data Environment = Environment
{ param1 :: String
, param2 :: String
, param3 :: String
}
loadEnv :: IO Environment
loadEnv = ...
func1 :: Environment -> String
func1 env = "Result: " ++ (show (func2 env))
func2 :: Environment -> Int
func2 env = 2 + floor (func3 env)
func3 :: Environment -> Float
func3 env = (fromIntegral $ l1 + l2 + l3) * 2.1
where
l1 = length (param1 env)
l2 = length (param2 env) * 2
l3 = length (param3 env) * 3
TheФункция onlyна functionсамом actuallyделе usingиспользуется thefunc3
. environmentОднако isfunc3
func3.чистая Howeverфункцияю. func3Это isзначит, aона pureне function.может Thisвызывать meansнапрямую itloadenv
, cannotтак directlyкак callона loadEnv, an impure function in the IO monad. This means the environment has to be passed through as a variable to the other functions, just so they can ultimately pass it to func3. In a language with global variables, we could save env as a global value in main. Then func3 could access it directly. There would be no need to have it as a parameter to func1 and func2. In larger programs, theseне "pass-through"чистая" variablesфункция. canЭто causeзначит, aчто lotокружение ofдолжно headaches.быть передано через переменную в другую функцию, чтобы можно было передать её в функцию func3
. В языке с глобальными переменными, мы должны сохранить env
в качестве глобальной переменой в main
. Функция func3
должна иметь доступ напрямую. Не нужно иметь парметра для func1
и func2
. В больших программах эта передача переменных может устроить головную боль.
THEРешение READER SOLUTION
TheМонада Reader
monadрешает solvesэту thisпроблему. problem.Она Itсоздает effectivelyглобальное createsтолько aдля globalчтения read-onlyзначение valueопределенного ofтипа. aВсе specifiedфункции type.внутри Allмонады functionsмогут within the monad canпрочитать "read"тип". theДавайте type.взглянем Let'sна lookто atкак howмонада theReader
Readerменяет monadформу changesнашего theкода. shapeНаши ofфункции ourбольше code.не Ourтрубуют functionsEnvironment
в noкачесте longerобязательного needпараметра, theтак Environmentкак asони anмогут explicitполучить parameter,доступ asк theyней canчерез access it through the monad.монаду.
main :: IO ()
main = do
env <- loadEnv
let str = runReader func1' env
print str
func1' :: Reader Environment String
func1' = do
res <- func2'
return ("Result: " ++ show res)
func2' :: Reader Environment Int
func2' = do
env <- ask
let res3 = func3 env
return (2 + floor res3)
-- as above
func3 :: Environment -> Float
...
TheФункция ask
functionразвертывает unwrapsокружение theдля environmentтого, soчтобы weмы canмогли useего it.исопльзовать. TheПривязывание monad'sдействий bindк actionмоанадам allowsпозволяет usнам toсвязать glueразличные differentReader
Readerдействия. actionsДля togetherтого, together.чтобы Inвызвать orderдействие toчтения callиз aчистого readerкода, actionнужно fromвызвать purerunReader
code,функцию allи weподать needокружение toв doкачестве isпараметра. callВсе theфункции runReaderвнутри functionдействия andбудут supplyобращаться theкак environmentк asглобальной a parameter. All functions within the action will be able to treat it like a global variable.переменной.
TheКод aboveвыше codeтак alsoже introducesвводит anважное importantпонятие. idea.Каждый Wheneverраз, youкогда learnвы aboutвводите aпонятие monadмонада "X", there'sвсегда oftenесть aсоответстующая corresponding functionфункция "runX", thatкоторая tellsговорит youвам howкак toзапустить runоперации operationsнад ofмонадой thatиз monadчистого from a pure context контекста(IO is an exception)исключение). ThisЭта functionфункция willбудет oftenчасто requireтребоваться someпри kindопределенном ofвводе, input,так asже wellкак asи theсами computationвычисления. itself.Затем Thenоно itбудет willпроизводить produceвывод theэтим finalсамых outputвычислений. ofВ theэтом computation.случае InReader
, theу caseнас ofесть Reader,runReader
weфункция. haveОна theтребует runReaderзначение, function.которое Itмы requiresбудем theчитать, valueи we'llсами useвычисления to read from, as well as the Reader
. computation.
runReader :: Reader r a -> r -> a
ItМожет mightбыть notне seemпохоже, likeчто we'veнам accomplishedмногое much,удалось, butно ourнаш codeкод isболее muchпонятен moreтеперь. intuitiveМы now.сохранили Wefunc3
, keepтак func3как asона itесть. was.Она Itимеет makesсмысл, senseчтобы toописать describeеё itв asкачестве aпеременной functionиз fromEnvironment
anс Environmentпомощью toфункции. aОднако, value.наши However,другие ourдве otherфункции twoбольше functionsне noпринимают longerокружение takeкак theобязательные environmentпараметры. asОни anпросто explicitсуществуют parameter.в Theyконтексте simplyгде existокружение in- aглобальная context where the environment is a global variable.переменная.
ACCUMULATINGСбор VALUESзначений
Чтобы понять монаду Winter
, давайте поговорим о проблеме сбора. Предположим у нас есть несколько различных функций. Каждая делает строковые операции, которые чего-то стоят. Мы хотим сохранить
Now, to motivate the Writer monad, let's talk about the accumulation problem. Suppose we have a few different functions. Each will perform some string operations we've assigned an arbitrary "cost" to. We want to keep track of how "expensive" it was to run the full computation. We can do this by using accumulator arguments to keep track of the cost we've seen so far. We then keep passing the accumulated value along with the final String result.
-- Calls func2 if even length, func3 and func4 if odd
func1 :: String -> (Int, String)
func1 input = if length input `mod` 2 == 0
then func2 (0, input)
else (i1 + i2, str1 ++ str2)
where
(i1, str1) = func3 (0, tail input)
(i2, str2) = func4 (0, take 1 input)
-- Calls func4 on truncated version
func2 :: (Int, String) -> (Int, String)
func2 (prev, input) = if (length input) > 10
then func4 (prev + 1, take 9 input)
else (10, input)
-- Calls func2 on expanded version if a multiple of 3
func3 :: (Int, String) -> (Int, String)
func3 (prev, input) = if (length input) `mod` 3 == 0
then (prev + f2resI + 3, f2resStr)
else (prev + 1, tail input)
where
(f2resI, f2resStr) = func2 (prev, input ++ "ab")
func4 :: (Int, String) -> (Int, String)
func4 (prev, input) = if (length input) < 10
then (prev + length input, input ++ input)
else (prev + 5, take 5 input)
First of all, we can notice that this function structure is a little bit cumbersome. Once again, we're passing around extra parameters. In particular, we're tracking the accumulated cost, which shows up as an input and an output to each function. The Writer monad provides us with an easier way to track this value. It would also make it easier for us to represent the cost with a different type. But to understand how, we should first learn two typeclasses, Semigroup and Monoid, that help us generalize accumulation.
SEMIGROUPS AND MONOIDS
A Semigroup is any type that we accumulate, via an "append" operation. This function uses the operator <>. It combines two elements of the type into a new, third element.
class Semigroup a where
(<>) :: a -> a -> a
For our first basic example, we can think of the Int type as being a Semigroup under the operation of addition:
instance Semigroup Int where
a <> b = a + b
A Monoid extends the definition of a Semigroup to include an identity element. This element is called mempty, since it is an "empty" element of sorts. Notice how a constraint of a Monoid is that it should already be a Semigroup.
class (Semigroup a) => Monoid a where
mempty :: a
This identity element should have the property that if we append any other element a to it, in either direction, the result should be a. That is, a <> mempty == a and mempty <> a == a should always be true. We can extend our definition of the Int Semigroup by adding "0" as the identity element of the Monoid.
instance Monoid Int where
memty = 0
We can now effectively use Int as an accumulation class. The mempty function provides an initial value for our monoid. Then with mappend, we can combine two values of this type into a result. It is quite easy to how we can make a monoid instance for Int. Our accumulator starts at 0, and we combine values by adding them.
This Int instance isn't available by default though! This is because we could equally well provide a Monoid from Int using multiplication instead of addition. In this case, 1 becomes the identity:
instance Semigroup Int where
a <> b = a * b
instance Monoid Int where
mempty = 1
In both these Int examples, our "append" function is commutative. In general though, this doesn't have to be the case. The base libraries include an Monoid instance for any List type. The "append" operation uses the list append operator (++), which isn't commutative! Then the identity element is the empty list.
instance Semigroup [a] where
xs <> ys = xs ++ ys
instance Monoid [a] where
mempty = []
-- Not commutative!
-- [1, 2] <> [3, 4] == [1, 2, 3, 4]
-- [3, 4] <> [1, 2] == [3, 4, 1, 2]
USING WRITER TO TRACK THE ACCUMULATOR
So how does this help us with our accumulation problem from before?
The Writer monad is parameterized by some monoidal type. Its job is to keep track of an accumulated value of this type. So its operations live in the context of having a global value that they can modify in this particular way. So while Reader has a global value we could read from, but not modify, Writer allows us to modify a value by appending, through we can't directly read it during the compuation. We can call the appending operation by using the tell function in the course of our Writer expression:
tell :: a -> Writer a ()
Just as with Reader and runReader, there is a runWriter function. It looks a little different:
runWriter :: Writer w a -> (a, w)
We don't need to provide an extra input besides the computation to run. But runWriter produces 2 outputs! The first is the final result of our computation. The second is the final accumulated value of the writer. We provide no initial accumulation value, because it will automatically use mempty from the Monoid!
Let's explore how to change our code from above to use this monad. We'll start with acc2:
acc2' :: String -> Writer Int String
acc2' input = if (length input) > 10
then do
tell 1
acc4' (take 9 input)
else do
tell 10
return input
We branch on the length of the input, and then each branch is a "do" statement. We'll use tell to provide the appropriate value to increment the accumulator, and then move on and call the next function, or return our answer. Then acc3 and acc4 are similar.
acc3' :: String -> Writer Int String
acc3' input = if (length input) `mod` 3 == 0
then do
tell 3
acc2' (input ++ "ab")
else do
tell 1
return $ tail input
acc4' :: String -> Writer Int String
acc4' input = if (length input) < 10
then do
tell (length input)
return (input ++ input)
else do
tell 5
return (take 5 input)
Finally, we don't change the type signature of our original function, but we instead use runWriter to call our helpers, as appropriate.
acc1' :: String -> (String, Int)
acc1' input = if length input `mod` 2 == 0
then runWriter (acc2' input)
else runWriter $ do
str1 <- acc3' (tail input)
str2 <- acc4' (take 1 input)
return (str1 ++ str2)
Notice we no longer need to actually explicitly keep track of the accumulator. It is now wrapped by the Writer monad. We can increase it in any of our functions by calling "tell". Now our code is much simpler and our types are cleaner.
CONCLUSION
Now that we know about the Reader and Writer monads, it's time to move on to part 5. There, we'll discuss the State monad. This monad combines these two concepts into a read/write state, essentially allowing the full privilege of a global variable. If these concepts were still a little confusing, don't be afraid to take another look at part 3 to solidify your understanding of monads.
Maybe you've never programmed in Haskell before, and the realization that we can get all this cool functionality makes you want to try it! Download our Beginners Checklist to get started!