Haskell. Монады. Монадные трансформеры. Игра в типы
Лучший способ понять монады — это начать их использовать. Нужно забить на монадические законы, теорию категорий, и просто начать писать код.
Написание кода на Haskell похоже на игру, в которой вы должны преобразовать объекты к нужному типу. Поэтому вам в первую очередь нужно понять правила этой игры. При написании кода вы должны четко понимать, какой тип имеет каждый конкретный кусок кода.
С обычными функциями все понятно. Если имеется функция типа «a→b», то подставив в неё аргумент типа «a», вы получите результат типа «b».
С монадами все не так очевидно. Под катом подробно расписано, как работать с do-конструкцией, как последовательно преобразуются типы, и зачем нужны монадные трансформеры.
Начнем с простого примера.
main = do
putStr "Enter your name\n"
name <- getLine
putStr $ "Hello " ++ name
Каждая do-конструкция имеет тип «m a», где «m» — это монада. В нашем случае это монада IO.
Каждая строчка в do-конструкции так же имеет тип «m a». Значение «a» в каждой строчке может быть разным.
Символ »<-", как бы, преобразует тип «IO String» в тип «String».
Если нам необходимо произвести в монаде некоторые вычисления, не связанные с данной монадой, то мы можем воспользоваться функцией return.
return :: a -> m a
main = do
text <- getLine
doubleText <- return $ text ++ text
putStr doubleText
Функция return заворачивает любой тип «a» в монадический тип «m a».
В данном примере, с помощью return выражение типа «String» преобразуется к типу «IO String», которое потом обратно разворачивается в «String». Как вариант, внутри do-конструкции можно использовать ключевое слово let.
main = do
text <- getLine
let doubleText = text ++ text
putStr doubleText
Вся do-конструкция принимает тип последней строчки.
Допустим, мы хотим прочитать содержимое файла. Для этого у нас имеется функция readFile.
readFile :: FilePath -> IO String
Как видим, функция возвращает «IO String». Но нам нужно содержимое файла в виде «String». Это значит, что мы должны выполнить нашу функцию внутри do-конструкции.
printFileContent = do
fileContent <- readFile "someFile.txt"
putStr fileContent
Здесь переменная fileContent имеет тип «String», и мы можем работать с ней, как с обычной строкой (например, вывести на экран). Обратите внимание, что получившаяся функция printFileContent имеет тип «IO ()»
printFileContent :: IO ()
2. Монады и монадные трансформерыЯ приведу следующую простую аналогию. Представьте, что монада — это пространство, внутри которого можно производить некоторые, специфичные для данного пространства, действия.
Например, в монаде «IO» можно выводить текст в консоль.
main = do
print "Hello"
В монаде «State» есть некоторое внешнее состояние, которое мы можем модифицировать.
main = do
let r = runState (do
modify (+1)
modify (*2)
modify (+3)
) 5
print r
-- OUTPUT:
-- ((), 15)
В этом примере мы взяли число 5, прибавили к нему 1, умножили результат на 2, затем прибавили еще 3. В результате получили число 15.
С помощью функции runState
runState :: State s a -> s -> (a, s)
мы «запускаем» нашу монаду.
На монаду можно посмотреть с двух сторон: изнутри и снаружи. Изнутри мы можем выполнить некоторые, специфичные для данной монады, действия. А снаружи — мы можем её «запустить», «распечатать», преобразовать к некоторому немонадическому типу.
Это позволяет нам вкладывать одну do-конструкцию в другую, как в приведенном выше примере. Монада IO — это единственная монада, на которую нельзя посмотреть «снаружи». Все в конечном итоге оказывается вложенным в IO. Монада IO — это наш фундамент.
Приведенный выше пример имеет определенные ограничения. Внутри монады State мы не можем выполнять действия, доступные в IO.
Мы оказались «подвешенными в воздухе», потеряли связь с землей.
Для решения этой проблемы существуют монадные трансформеры.
main = do
r <- runStateT (do
modify (+1)
modify (*2)
s <- get
lift $ print s
modify (+3)
) 5
print r
-- OUTPUT:
-- 12
-- ((), 15)
Данная программа делает то же самое, что и предыдущая. Мы заменили State на StateT и добавили две строчки,
s <- get
lift $ print s
с помощью которых выводим промежуточный результат в консоль. Обратите внимание, операция ввода/вывода выполняется внутри «вложенной» монады StateT.
Здесь runStateT запускает монаду StateT, а функция lift «поднимает» операцию, доступную в IO, до монады StateT.
runStateT :: StateT s m a -> s -> m (a, s)
lift :: IO a -> StateT s IO a
Изучите внимательно, как последовательно преобразуется тип в данном примере.
Операция «print s» имеет тип «IO ()».
С помощью lift мы «поднимаем» его до типа «StateT Int IO ()».
Внутренняя do-конструкция теперь имеет тип «StateT Int IO ()».
Мы «запускаем» её и получаем тип «Int → IO ((), Int)».
Затем мы подставляем значение »5» и получаем тип «IO ((), Int)».
Поскольку, мы получили тип «IO», то мы можем использовать его во внешней do-конструкции.
Стрелочка »<-" снимает монадический тип и возвращает "((), Int)".
В консоль выводится результат »((), 15)».
Внутри StateT мы можем менять внешнее состояние и выполнять операции ввода/вывода. Т.е. монада StateT не «болтается в воздухе», как State, а осталась связанной с внешней монадой IO.
Таким образом, в программе может быть куча монад, вложенных друг в друга. Некоторые из этих монад будут сцеплены друг с другом, некоторые — нет.
Надеюсь, моя аналогия помогла Вам взглянуть на вещи с новой точки зрения, и Вы сможете в будущем стать настоящим повелителем монад.