Очень часто в статьях про Хаскель сплошь и рядом встречаются функторы и особенно монады.
Так часто, что порой не реже встречаются комментарии «сколько можно про какие-то новые монады» и «пишите о чём-либо полезном».
На мой взгляд это свидетельствует о том, что люди порой не понимают зачем же нужны все эти функторы и монады.
Это статья попытка показать, что сила функциональных языков и в первую очередь Хаскеля — это в том числе и силе функторов и монад.
Попытаюсь показать это на примере достаточно искусственном и наверняка бесполезном, однако акцент будет поставлен на важности использования общего кода и переиспользования.
Термин «чистый» перенагружено в программировании.
Например, фразу «Руби — чисто объектный язык» мы понимаем как «Руби — язык, где всё — объекты».
А вот фразу «Хаскель — это чистый функциональный язык» следует понимать как «Хаскель — функциональный язык без побочных эффектов».
В этой статье мы будем использовать термин «чистый» ещё в одном контексте.
«Чистые данные» — это данные, которые я хочу получить.
В основном примитивные типы — это числа, строки, иногда более сложные, например — картинка или несколько значений.
Соответственно, «грязные данные» — это данные, которые содержат, помимо того что я хочу, дополнительную информацию.
Вот сочиняем программку:
Программа проста до безобразия — мы просим ввести пользователю 2 строчки, а после выводим результат вычисления.
Видим, что наша функция foo ещё не определена (она всегда вызывает падение программы), хотя Хаскель уже может откомпилировать наш код.
Теперь перепишем более детально нашу функцию, используя только «чистые» данные:
Как видим, тут тоже понятно, функция
Большинство функциональных языков программирования позволяют легко и просто создавать функции, основанные на чистых данных.
Казалось бы всё замечательно — простая и элегантная программка. Но нетушки!
Результат функции намного сложнее, чем нам того хотелось бы.
Как мы понимаем, на
Императивный подход к разрешения подобных проблем делится на 2 группы: или использовать ветвления, или использовать исключения. Зачастую оба подхода комбинируется.
Эти подходы настолько эффективны, что в основном используются и в функциональных языках.
Скажем прямо — в Хаскеле присутствуют исключения, однако они недоразвиты, нуждаются в реформировании, не лучшим образом отлавливаются. Да и самое важное — в большинстве случаев они просто не нужны.
Но нем не менее — можно.
Поэтому попытаемся переписать наш код используя ветвления и исключения.
В Хаскеле (да и многих функциональных языках) есть достойный ответ на подобные задачи.
Основная сила заключена в Алгебраических Типах Данных.
Если мы рассматриваем вышеприведённый пример, видно, что наши функции могут падать.
Решение — пользоваться нулабельными типами данных.
В ML языках и Scala такой тип называется
Мы не обращаем внимание на
А именно,
Тип данных принимает значение
Как видим, тип данных — «грязный», так как содержит лишнюю информацию.
Давайте перепишем наши функции более правильно, более безопасно и без исключений.
Прежде всего заменим функции, которые вызывали падение на безопасные аналоги:
Теперь эти функции вместо падения дают результат
Но весь остальной код у нас зависит от этих функций. Нам придётся изменить почти все функции, в том числе и те, которые много раз тестировались.
Как видим простая программа превратилась в достаточно монстро-образный код.
Много обёрточных функций, много избыточного кода, много изменено.
Но именно на этом останавливаются многие функциональные языки программирования.
Теперь можно понять почему в тех языках, несмотря на возможность создания множества АТД, АТД не так уж часто используются в коде.
Можно жить с АТД, но без подобной вакханалии? Оказывается можно.
На помощь нам в начале приходят функторы.
Функторы — это такие типы данных, для которых существует функция
а так же его инфиксный синоним:
Функтор — это класс типов, где мы создали специальную функцию
Тип данных
Как нам использовать чистую функцию с функтором
Мы тут видим главное — мы не переписывали нашу функцию
Однако, если мы захотим применить функтор, пытаясь переписать
Функторы работают только с функциями с единственным функторно-«грязным» аргументом.
Что же делать для функций с несколькими параметрами?
Тут нам на помощь приходят аппликативные функторы:
Аппликативные функторы — такие функторы, для которых определены 2 функции:
Основное отличение функтора от аппликативного функтора состоит в том, что функтор протаскивает сквозь функторное значение чистую функцию, в то время как аппликативный фукнтор позволяет нам протаскивать сквозь функторное значение функторную функцию
Maybe является аппликативным функтором и определяется следующим образом:
Самое время переписать
В основном функцию переписывают, совмещая функторый
Но можно переписать функцию, пользуясь исключительно аппликативными функциями (монадный стиль) — вначале «чистую» функцию делаем чисто-аппликативной, и аппликативно нанизываем аргументы:
Замечательно!
Может можно заодно переписать функцию
Давайте обратим внимание на подпись функции
Функция берёт на вход «чистые» аргументы, и выдаёт на выходе «грязный» результат.
Так вот, в большинстве своём в реальном программировании, именно такие функции встречаются чаще всего — берут на вход «чистые» аргументы, и на выходе — «грязный» результат.
И когда у нас есть несколько таких функций, вместе совместить их помогают монады.
Монады — это такие типы данных, для которых существует функции
Мы понимаем, что тип
Кстати, если мы присмотримся внимательнее к внутреннему содержанию, и подписям, увидим, что:
Пришло время переписать функцию
Да уж, вышло не намного красивее. Это связано с тем, что монады красиво пишутся для одной переменной. К счастью существуют много дополнительных функций.
Можно написать функцию
Или использовать функцию
На крайний случай, можно воспользоваться синтаксическим сахаром для монад, используя
Если мы сведём основные функции к одному виду, то увидим:
Все используются для того, чтобы передавать функциям «грязные» значения, тогда как функции ожидают «чистые» значения на входе.
Функторы используют «чистую» функцию.
Аппликативные функторы — «чистую» функцию внутри «загрязнения».
Монады используют функции, которые на выходе имеют «грязное» значение.
Что ж, наконец, можно полностью и аккуратно переписать всю программу:
Код снова стал прост и понятен!
При этом мы не поступились ни пядью безопасности!
При этом мы почти не изменили код!
При этом чистые функции остались чистыми!
При этом избежали рутины!
Можно ли жить в функциональном мире без функторов и монад? Можно.
Но, если мы хотим вовсю использовать всю силу Алгебраических Типов Данных, нам для удобной функциональной композиции различных функций придётся использовать функторы и монады.
Ибо это отличное средство от рутины и путь к краткому, понятному и часто пере-используемому коду!
P.S. Следует понимать, что для различных типов данных, аналогия с «чистыми» и «грязными» типами данных не совсем уместна.
Например, для списков
А монада:
на самом деле является
Что не всегда очевидно с первого взгляда.
Так часто, что порой не реже встречаются комментарии «сколько можно про какие-то новые монады» и «пишите о чём-либо полезном».
На мой взгляд это свидетельствует о том, что люди порой не понимают зачем же нужны все эти функторы и монады.
Это статья попытка показать, что сила функциональных языков и в первую очередь Хаскеля — это в том числе и силе функторов и монад.
Чистые данные
Попытаюсь показать это на примере достаточно искусственном и наверняка бесполезном, однако акцент будет поставлен на важности использования общего кода и переиспользования.
Термин «чистый» перенагружено в программировании.
Например, фразу «Руби — чисто объектный язык» мы понимаем как «Руби — язык, где всё — объекты».
А вот фразу «Хаскель — это чистый функциональный язык» следует понимать как «Хаскель — функциональный язык без побочных эффектов».
В этой статье мы будем использовать термин «чистый» ещё в одном контексте.
«Чистые данные» — это данные, которые я хочу получить.
В основном примитивные типы — это числа, строки, иногда более сложные, например — картинка или несколько значений.
Соответственно, «грязные данные» — это данные, которые содержат, помимо того что я хочу, дополнительную информацию.
Вот сочиняем программку:
module Main where
foo = undefined -- функция неопределена
main :: IO ()
main = do
putStrLn "Input a: "
a <- getLine -- получаем 1 линию на вход
putStrLn "Input b: "
b <- getLine -- получаем 2 линию на вход
print (foo a b) -- печатаем вычисленное значение
Программа проста до безобразия — мы просим ввести пользователю 2 строчки, а после выводим результат вычисления.
Видим, что наша функция foo ещё не определена (она всегда вызывает падение программы), хотя Хаскель уже может откомпилировать наш код.
Теперь перепишем более детально нашу функцию, используя только «чистые» данные:
pure1arg :: Int -> Int
pure1arg = (+ 1) -- функция добавления единицы, чистая функция 1 аргумента
pure2args :: Int -> Int -> Int
pure2args = (+) -- функция сложения, чистая функция 2х аргументов
unsafe2args :: Int -> Int -> Int
unsafe2args = div -- фунция целочисленного деления, небезопасная функция 2 аргументов
foo :: String -> String -> Int
foo a b = unsafe2args extraUnsafeE unsafeC -- фарш из сложения и деления, степень небезопасности сложно определить
where
unsafeA :: Int
unsafeA = read a -- небезопасное значение при небезопасном применении функция перевода строк в числа
unsafeB :: Int
unsafeB = read b -- аналогично unsafeA с иным параметром
unsafeC :: Int
unsafeC = pure1arg unsafeB -- применяем чистую функцию 1 аргумента с небезопасным значением
reallyUnsafeD :: Int
reallyUnsafeD = pure2args unsafeA unsafeC -- применяем чистую функцию 2х аргументов с небезопасными значениями
extraUnsafeE :: Int
extraUnsafeE = unsafe2args unsafeA reallyUnsafeD -- применяем небезопасную функцию 2х арг. с 2мя совсем небезопасными зн.
Как видим, тут тоже понятно, функция
foo
по сути является [неважно какой] смесью целочисленного деления и сумм.Большинство функциональных языков программирования позволяют легко и просто создавать функции, основанные на чистых данных.
Казалось бы всё замечательно — простая и элегантная программка. Но нетушки!
Результат функции намного сложнее, чем нам того хотелось бы.
Как мы понимаем, на
0
делить нельзя, да и пользователь может ввести не числа, а левые строки, и при преобразовании строк в числа может выкинуть ошибку. Наш код получился небезопасным.Императивный подход к разрешения подобных проблем делится на 2 группы: или использовать ветвления, или использовать исключения. Зачастую оба подхода комбинируется.
Эти подходы настолько эффективны, что в основном используются и в функциональных языках.
Скажем прямо — в Хаскеле присутствуют исключения, однако они недоразвиты, нуждаются в реформировании, не лучшим образом отлавливаются. Да и самое важное — в большинстве случаев они просто не нужны.
Но нем не менее — можно.
Поэтому попытаемся переписать наш код используя ветвления и исключения.
module Main where
import Control.Exception (IOException, catch)
printError :: IOException -> IO ()
printError = print
pure2args :: Int -> Int -> Int
pure2args = (+)
pure1arg :: Int -> Int
pure1arg = (+ 1)
unsafe2args :: Int -> Int -> Int
unsafe2args a b = if b == 0
then error "Error 'unsafe2args' : wrong 2nd argument = 0" --unsafe source of IOException
else div a b
foo :: String -> String -> Int
foo a b = unsafe2args extraUnsafeE unsafeC
where
unsafeA :: Int
unsafeA = read a --unsafe source of IOException
unsafeB :: Int
unsafeB = read b --unsafe source of IOException
unsafeC :: Int
unsafeC = pure1arg unsafeB
reallyUnsafeD :: Int
reallyUnsafeD = pure2args unsafeA unsafeC
extraUnsafeE :: Int
extraUnsafeE = unsafe2args unsafeA reallyUnsafeD
main :: IO ()
main = do
putStrLn "Input a: "
a <- getLine
putStrLn "Input b: "
b <- getLine
catch (print (foo a b)) printError --пробуем вычислить значение или напечатать исключение IOException
Грязные данные
В Хаскеле (да и многих функциональных языках) есть достойный ответ на подобные задачи.
Основная сила заключена в Алгебраических Типах Данных.
Если мы рассматриваем вышеприведённый пример, видно, что наши функции могут падать.
Решение — пользоваться нулабельными типами данных.
В ML языках и Scala такой тип называется
Option
, в Хаскеле он называется Maybe a
.import Prelude hiding (Maybe) -- этот тип уже описан в стандартной библиотеке. Мы попробуем создать его с нуля
data Maybe a = Nothing | Just a
deriving Show
Мы не обращаем внимание на
deriving
часть, мы тут просто говорим, что просим компилятор самостоятельно уметь переводить в строку наш тип данных.А именно,
show Nothing == "Nothing"
show (Just 3) == "Just 3"
Тип данных принимает значение
Nothing
если у нас нет данных, и Just a
, если есть.Как видим, тип данных — «грязный», так как содержит лишнюю информацию.
Давайте перепишем наши функции более правильно, более безопасно и без исключений.
Прежде всего заменим функции, которые вызывали падение на безопасные аналоги:
maybeResult2args :: Int -> Int -> Maybe Int
maybeResult2args a b = if b == 0
then Nothing --safe
else Just (div a b)
...
maybeA :: Maybe Int
maybeA = readMaybe a --safe
maybeB :: Maybe Int
maybeB = readMaybe b --safe
Теперь эти функции вместо падения дают результат
Nothing
, если всё в порядке — то Just результат
.Но весь остальной код у нас зависит от этих функций. Нам придётся изменить почти все функции, в том числе и те, которые много раз тестировались.
pure2args :: Int -> Int -> Int
pure2args = (+)
safePure2args :: Maybe Int -> Maybe Int -> Maybe Int
safePure2args a b = case a of
Nothing -> Nothing
Just a' -> case b of
Nothing -> Nothing
Just b' -> Just (pure2args a' b')
pure1arg :: Int -> Int
pure1arg = (+ 1)
safePure1arg :: Maybe Int -> Maybe Int
safePure1arg a = case a of
Nothing -> Nothing
Just a' -> Just (pure1arg a')
maybeResult2args :: Int -> Int -> Maybe Int
maybeResult2args a b = if b == 0
then Nothing
else Just (div a b)
foo :: String -> String -> Maybe Int
foo a b = case maybeE of
Nothing -> Nothing
Just e -> case maybeC of
Nothing -> Nothing
Just c -> maybeResult2args e c
where
maybeA :: Maybe Int
maybeA = readMaybe a
maybeB :: Maybe Int
maybeB = readMaybe b
maybeC :: Maybe Int
maybeC = safePure1arg maybeB
maybeD :: Maybe Int
maybeD = safePure2args maybeA maybeC
maybeE = case maybeA of
Nothing -> Nothing
Just a1 -> case maybeD of
Nothing -> Nothing
Just d -> maybeResult2args a1 d
printMaybe :: Show a => Maybe a -> IO ()
printMaybe Nothing = print "Something Wrong"
printMaybe (Just a) = print a
main :: IO ()
main = do
putStrLn "Input a: "
a <- getLine
putStrLn "Input b: "
b <- getLine
printMaybe (foo a b)
Как видим простая программа превратилась в достаточно монстро-образный код.
Много обёрточных функций, много избыточного кода, много изменено.
Но именно на этом останавливаются многие функциональные языки программирования.
Теперь можно понять почему в тех языках, несмотря на возможность создания множества АТД, АТД не так уж часто используются в коде.
Можно жить с АТД, но без подобной вакханалии? Оказывается можно.
Функторы
На помощь нам в начале приходят функторы.
Функторы — это такие типы данных, для которых существует функция
fmap
class Functor f where
fmap :: (a -> b) -> f a -> f b
а так же его инфиксный синоним:
(<$>) :: Functor f => (a -> b) -> f a -> f b
(<$>) = fmap
Условия истинности
Для всех значений типа данных всегда выполняются следующие условия:
Условие идентичности:
Условие композиции:
Где
И
Условие идентичности:
fmap id == id
Условие композиции:
fmap (f . g) == fmap f . fmap g
Где
id
— функция идентичности id :: a -> a
id x = x
И
(.)
— функциональная композиция (.) :: (b -> c) -> (a -> b) -> a -> c
f . g = \x -> f (g x)
Функтор — это класс типов, где мы создали специальную функцию
fmap
. Посмотрим на её аргументы — она берёт одну «чистую» функцию a -> b
, берём «грязное» функторное значение f a
и получаем на выходе функторное значение f b
.Тип данных
Maybe
является функтором. Создадим инстанс (экземпляр) для типа Maybe
, так чтобы не нарушались законы функторов:instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just a) = Just (f a)
Как нам использовать чистую функцию с функтором
Maybe
? Очень просто:safePure1arg :: Maybe Int -> Maybe Int
safePure1arg = fmap pure1arg
Мы тут видим главное — мы не переписывали нашу функцию
pure1arg
, а значит нам не надо её ещё раз тестировать на баги и ко всему она осталась универсальной и чистой, зато мы с лёгкостью создали её безопасную версию, которая на вход принимает не числа, а нулабельные числа.Однако, если мы захотим применить функтор, пытаясь переписать
safePure2args
, мы потерпим фиаско.Функторы работают только с функциями с единственным функторно-«грязным» аргументом.
Что же делать для функций с несколькими параметрами?
Аппликативные функторы
Тут нам на помощь приходят аппликативные функторы:
Аппликативные функторы — такие функторы, для которых определены 2 функции:
pure
и (<*>)
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
Условия истинности
Для любых значений одного типа данных всегда выполняются следующие правила:
Условие идентичности:
Условие композиции:
Условие гомоморфизма:
Условие обмена:
Условие идентичности:
pure id <*> v == v
Условие композиции:
pure (.) <*> u <*> v <*> w == u <*> (v <*> w)
Условие гомоморфизма:
pure f <*> pure x == pure (f x)
Условие обмена:
u <*> pure y == pure ($ y) <*> u
Основное отличение функтора от аппликативного функтора состоит в том, что функтор протаскивает сквозь функторное значение чистую функцию, в то время как аппликативный фукнтор позволяет нам протаскивать сквозь функторное значение функторную функцию
f (a -> b)
.Maybe является аппликативным функтором и определяется следующим образом:
instance Applicative Maybe where
pure = Just
Nothing <*> _ = Nothing
_ <*> Nothing = Nothing
(Just f) <*> (Just a) = Just (f a)
Самое время переписать
safePure2args
.В основном функцию переписывают, совмещая функторый
fmap
для первого аргумента, и аппликативное нанизывание остальных аргументов:safePure2args :: Maybe Int -> Maybe Int -> Maybe Int
safePure2args a b = pure2args <$> a <*> b
Но можно переписать функцию, пользуясь исключительно аппликативными функциями (монадный стиль) — вначале «чистую» функцию делаем чисто-аппликативной, и аппликативно нанизываем аргументы:
safePure2args :: Maybe Int -> Maybe Int -> Maybe Int
safePure2args a b = (pure pure2args) <*> a <*> b
Замечательно!
Может можно заодно переписать функцию
maybeE
с помощью аппликативных функторов? Увы.Монады
Давайте обратим внимание на подпись функции
maybeResult2args
:maybeResult2args :: Int -> Int -> Maybe Int
Функция берёт на вход «чистые» аргументы, и выдаёт на выходе «грязный» результат.
Так вот, в большинстве своём в реальном программировании, именно такие функции встречаются чаще всего — берут на вход «чистые» аргументы, и на выходе — «грязный» результат.
И когда у нас есть несколько таких функций, вместе совместить их помогают монады.
Монады — это такие типы данных, для которых существует функции
return
и (>>=)
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
Условия истинности
Выполняются правила для любых значений типа:
Левой идентичности:
Правой идентичности:
Ассоциативности:
Для удобства, есть дополнительная функция с обратным порядком аргументов:
Где
Левой идентичности:
return a >>= k == k a
Правой идентичности:
m >>= return == m
Ассоциативности:
m >>= (\x -> k x >>= h) == (m >>= k) >>= h
Для удобства, есть дополнительная функция с обратным порядком аргументов:
(=<<) :: Monad m => (a -> m b) -> m a -> m b
(=<<) = flip (>>=)
Где
flip :: (a -> b -> c) -> b -> a -> c
flip f a b = f b a
Мы понимаем, что тип
Maybe
является монадой, а значит можно определить его инстанс (экземпляр):instance Monad Maybe where
return = Just
(Just x) >>= k = k x
Nothing >>= _ = Nothing
Кстати, если мы присмотримся внимательнее к внутреннему содержанию, и подписям, увидим, что:
pure == return
fmap f xs == xs >>= return . f
Пришло время переписать функцию
maybeE
maybeE = maybeA >>= (\a1 -> maybeD >>= (maybeResult2args a1))
Да уж, вышло не намного красивее. Это связано с тем, что монады красиво пишутся для одной переменной. К счастью существуют много дополнительных функций.
Можно написать функцию
bind2
bind2 :: Monad m => (a -> b -> m c) -> m a -> m b -> m c
bind2 mf mx my = do
x <- mx
y <- my
mf x y
maybeE = bind2 maybeResult2args maybeA maybeD
Или использовать функцию
liftM2
и join
liftM2 :: Monad m => (a1 -> a2 -> r) -> m a1 -> m a2 -> m r
join :: Monad m => m (m a) -> m a
maybeE = join $ liftM2 maybeResult2args maybeA maybeD
На крайний случай, можно воспользоваться синтаксическим сахаром для монад, используя
do
нотацию: maybeE = do
a1 <- maybeA
d <- maybeD
maybeResult2args a1 d
Различие в применении функторов и монад
Если мы сведём основные функции к одному виду, то увидим:
(<$>) :: Functor f => (a -> b) -> f a -> f b
(<*>) :: Applicative f => f (a -> b) -> f a -> f b
(=<<) :: Monad f => (a -> f b) -> f a -> f b
Все используются для того, чтобы передавать функциям «грязные» значения, тогда как функции ожидают «чистые» значения на входе.
Функторы используют «чистую» функцию.
Аппликативные функторы — «чистую» функцию внутри «загрязнения».
Монады используют функции, которые на выходе имеют «грязное» значение.
Программа без рутины
Что ж, наконец, можно полностью и аккуратно переписать всю программу:
module Main where
import Control.Monad
import Control.Applicative
import Text.Read (readMaybe)
bind2 :: Monad m => (a -> b -> m c) -> m a -> m b -> m c
bind2 mf mx my = do
x <- mx
y <- my
mf x y
pure2args :: Int -> Int -> Int
pure2args = (+)
pure1arg :: Int -> Int
pure1arg = (+ 1)
maybeResult2args :: Int -> Int -> Maybe Int
maybeResult2args a b = if b == 0
then Nothing --safe
else Just (div a b)
foo :: String -> String -> Maybe Int
foo a b = bind2 maybeResult2args maybeE maybeC
where
maybeA :: Maybe Int
maybeA = readMaybe a --safe
maybeB :: Maybe Int
maybeB = readMaybe b --safe
maybeC :: Maybe Int
maybeC = fmap pure1arg maybeB
maybeD :: Maybe Int
maybeD = pure2args <$> maybeA <*> maybeC
maybeE :: Maybe Int
maybeE = bind2 maybeResult2args maybeA maybeD
printMaybe :: Show a => Maybe a -> IO ()
printMaybe Nothing = print "Something Wrong"
printMaybe (Just a) = print a
main :: IO ()
main = do
putStrLn "Input a: "
a <- getLine
putStrLn "Input b: "
b <- getLine
printMaybe (foo a b)
Код снова стал прост и понятен!
При этом мы не поступились ни пядью безопасности!
При этом мы почти не изменили код!
При этом чистые функции остались чистыми!
При этом избежали рутины!
Вывод
Можно ли жить в функциональном мире без функторов и монад? Можно.
Но, если мы хотим вовсю использовать всю силу Алгебраических Типов Данных, нам для удобной функциональной композиции различных функций придётся использовать функторы и монады.
Ибо это отличное средство от рутины и путь к краткому, понятному и часто пере-используемому коду!
P.S. Следует понимать, что для различных типов данных, аналогия с «чистыми» и «грязными» типами данных не совсем уместна.
Например, для списков
fmap = map
А монада:
a = do
c <- cs
d <- ds
return (zet c d)
на самом деле является
a = [zet c d | c <- cs, d <- ds]
Что не всегда очевидно с первого взгляда.