Maybe А и Maybe Б сидели на трубе,
А упало, Б пропало, что осталось на трубе?
На днях
заметил, как полезны могут быть аппликативные функторы для избавления от леса вложенных условных конструкций (
if
-ов и
case
-ов). А именно, как удобно, что тип
Maybe
можно использовать как аппликативный функтор.
О функторах, пирогах и яблоках
Речь пойдёт о языке Хаскель, но постараюсь рассказать так, чтобы пользователям других языков было тоже понятно. Кто Хаскель знает, этот раздел может пропустить и сразу перейти к следующему.
Итак, для представления такого результата, которого может и не быть, в Хаскеле используется тип
Maybe a
, где
a
— любой тип. У
Maybe a
бывают значения двух видов:
Just a
(«просто а», если значение есть) и
Nothing
(«ничего», если значения нет).
Maybe
является функтором, то есть из любой функции из
a
в
b
(тип
:: a -> b
) можно сделать функцию из
Maybe a
в
Maybe b
(тип
:: Maybe a -> Maybe b
). В общем, функтором является любой тип
f
, для которого определена
*:
fmap :: (a -> b) -> (f a -> f b)
* определение fmap
должно удовлетворять при этом двум условиям: 1) fmap id = id
, где id
— функция, возвращаюая свой аргумент, 2) fmap
должна быть дистрибутивна слева относительно композиции функций... [ссылка]
Например, пусть у нас есть типы
Яблоко
и
Пирог
и функция
испечь :: Яблоко -> Пирог
, превращающая одно в другое. Тогда мы легко можем взять яблоко,
которого может и не быть, и испечь из него пирог, ,
которого может и не быть. То есть если будет просто яблоко, то выйдет просто пирог, а если ничего не было, то ничего и не выйдет. Делается это проще простого:
fmap испечь
.
data Яблоко = Яблоко deriving Show
data Пирог = Пирог deriving Show
испечь :: Яблоко -> Пирог
испечь Яблоко = Пирог
испечьЕслиЕсть :: (Maybe Яблоко) -> (Maybe Пирог)
испечьЕслиЕсть = fmap испечь
После этого мы можем печь пироги, которых может и не быть:
ghci> испечьЕслиЕсть (Just Яблоко)
Just Пирог
ghci> испечьЕслиЕсть Nothing
Nothing
А что делать, если самой функции из
a
в
b
может и не быть? Как применить
Maybe (a -> b)
к
Maybe a
? Такая операция определена для аппликативных функторов,
Maybe
— один из них. В Хаскеле для этого подключают модуль
Control.Applicative
, саму же операцию последовательного применения называют звучным словом
(<*>)
.
Поясняю на яблоках. Что делать случае на входе у нас
Maybe (Яблоко -> Пирог)
— рецепт, которого может и не оказаться? Естественно, что пирог у нас получится тогда и только тогда, когда есть и яблоко, и сам рецепт. Если хоть что-то отсутствует, то и пирога не будет, будет
Nothing
. Итак, дано:
безРецепта = Nothing
сРецептом = Just испечь
Проверяем, как работает с такими «рецептами» аппликативный функтор
Maybe
:
ghci> :m + Control.Applicative
ghci> сРецептом <*> Just Яблоко
Just Пирог
ghci> сРецептом <*> Nothing
Nothing
ghci> безРецепта <*> Just Яблоко
Nothing
ghci> безРецепта <*> Nothing
Nothing
Это всё, что нам сейчас потребуется из «теории». И ещё пара замечаний: 1) с аппликативными функторами часто применяется ещё операция
(<$>)
, которая является для них синонимом
fmap
и в инфиксной записи выглядит короче; 2) любая монада является также аппликативным функтором (обратное неверное), и для неё
ap
и
(<*>)
совпадают (об этом полезно знать, хотя нам сейчас и не потребуется).
Избавление от условных конструкций
Перехожу к практическим вопросам. На днях писал небольшой парсер для файлов формата Matrix Market. Это несложный формат, состоящий из заголовка в котором указывается, как хранится структура матрицы (покоординатно или сплошным массивом) и тип значений её элементов (целые, действительные или комплексные). Возможны разные комбинации.
Логику разбора такого формата на императивном псевдокоде можно описать так:
if (структура == координатная) {
if (тип == целые) {
читатьЦелыеПоКоординатам;
} else if (тип == действительные) {
читатьДействительныеПоКоординатам;
} else if (тип == комплексные) {
читатьКомплексныеПоКоординатам;
} else {
ошибка;
}
} else if (структура == массив) {
if (тип == целые) {
читатьМассивЦелых;
} else if (тип == действительные) {
читатьМассивДействительных;
} else if (тип == комплексные) {
читатьМассивКомплексных;
} else {
ошибка;
}
} else {
ошибка;
}
Естественно, что такого развесистого дерева выбора можно избежать, потому что выбор типа элементов и выбор структуры матрицы независимы друг от друга. В функциональном стиле можно просто составить «словарь» поддерживаемых функций для разбора структуры и отдельный «словарь» функций для чтения значений разных типов, выбирать подходящие, и потом передавать вторые в качестве параметра первым.
Так я и сделал.
Опять псевдокод для пояснения этой идеи:
let читатьСтруктуру = lookup структура словарьФункцийРазбора
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in читатьСтруктуру читатьЧисло исходныеДанные
На практике дело обстоит немного сложнее. Операция поиска в словаре может ничего не найти. Как легко догадаться,
lookup
в Хаскеле возвращает
Maybe a
. Итак, у нас есть
Maybe функция
,
Maybe аргумент
, а мы хотим получить
Maybe результат
. Ничего не напоминает? Да-да, тут самое время применить
(<*>)
.
Однако представим на минуту, как эта задача решается без операции последовательного применения
Maybe a
. И для функции, и для её аргумента нужно проверять являются ли они чем-то (
Just a
) или ничем (
Nothing
), и в результате получается тот же лес проверок, что и в императивном псевдокоде, только в этот раз с проверкой на результат поиска. Можно, правда, все проверки объединить в одной единственной условной конструкции на каждое применение функции, но все проверки надо всё равно записывать явно. То есть без
(<*>)
получается что-то такое:
let читатьСтруктуру = lookup структура словарьФункцийРазбора
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in case (читатьСтруктуру, читатьЧисло) of
(Just f, Just g) -> Just (f g исходныеДанные)
_ -> Nothing
Код склонен паталогически ветвиться, если таких
Maybe
-аргументов у функции больше одного, или если полученный
Maybe
-результат нужно использовать в качестве аргумента ещё одной функции (тут не обойтись без вложенных проверок).
К счастью, все эти проверки тривиальны, и код можно записать гораздо короче:
let читатьСтруктуру = lookup структура словарьФункцийРазбора
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in читатьСтруктуру <*> читатьЧисло <*> (Just исходныеДанные)
Здесь нет ни одной явной условной конструкции, но код, тем не менее, делает все необходимые проверки, и вернёт
Just результат
только если нашлись обе функции.
У меня получилось
немного длиннее, но суть та же:
let p = lookup fmt parsers :: Maybe FormatReader
nr = lookup fieldq readers :: Maybe (Int, ValueReader)
sy = lookup symq symmetries :: Maybe Symmetry
p' = uncurry <$> p <*> nr :: Maybe ([String] -> Maybe MatrixData)
d = join $ p' <*> (Just toks) :: Maybe MatrixData
m = MM <$> d <*> sy :: Maybe Matrix
in ...
Что тут можно заметить. Во-первых,
(<$>)
практически так же часто используется, как и
(<*>)
. Эта операция позволяет применять обычные функции к
Maybe
-значениям. Вместо
(<$>)
можно поднимать значения в функтор с помощью
pure
(для любых аппликативных функторов) или
Just
(конкретно для
Maybe
) и использовать только
(<*>)
.
Во-вторых, если у нас кругом функции, возвращающие
Maybe
, и они сами оказываются внутри
Maybe
(см.
p'
в моём примере), то рано или поздно появляются «вложенные» значения типа
Maybe (Maybe a)
. Тут помогает монадный
join
, превращающий их в просто
Maybe a
.
join
определён в
Control.Monad
. И кстати, это тоже помогает избавиться от вложенных тривиальных проверок (
join (Just Nothing)
даёт
Nothing
).
В-третьих, при таком подходе мы, естественно, теряем информацию о том, какое именно вычисление не дало результата (дало
Nothing
). Для передачи «исключения» по цепочке можно использовать другие типы, например
Either e
, определив для них
Applicative
.
Заключение
Мне такой способ комбинировать вычисления очень понравился. По-моему, случай, когда все условные проверки сводятся к «если в порядке, то считать дальше, а если нет, то прервать» — довольно распространённый. Тип
Maybe
с операцией последовательного применения
(<*>)
позволяет такие проверки, любой степени вложенности, не писать вообще.