Контравариантные функторы, бифункторы, профункторы в Haskell
Введение⌗
Предположим, что у нас есть следующая структура данных, моделирующая некоторое абстрактное преобразование сущности i
в сущность o
:
newtype ConversionFromTo i o = ConverterFromTo { convertFromTo :: i -> o }
Типы i
и o
мыслятся входом и выходом соответственно: функция convertFromTo
просто запускает описанное в ConversionFromTo i o
преобразование над аргументом типа i
. Обратим внимание, что последовательность аргументов важна, и первым параметром к конструктору
значения типа ConversionFromTo i o
идёт тип входного сигнала.
Вот несколько простых преобразователей:
doubleToInt :: ConversionFromTo Double Int
doubleToInt = ConverterFromTo round
showToString :: Show a => ConversionFromTo a String
showToString = ConverterFromTo show
boolToInt :: ConversionFromTo Bool Int
boolToInt = ConverterFromTo (\b -> if b == True then 1 else 0)
Использование:
> convertFromTo doubleToInt 3.5
4
> convertFromTo boolToInt False
0
Управление выходом (Functor)⌗
Мы можем изменять выходной сигнал типа o1
на o2
перед тем, как тот “покинет” наш преобразователь:
-- i is fixed because we don't know anything else except o1 -> o2 ...
applyPost :: (o1 -> o2) -> ConversionFromTo i o1 -> ConversionFromTo i o2
applyPost f (ConverterFromTo io1) = ConverterFromTo $ \i -> (f . io1) i
Хмм. Похоже на то, что applyPost
просто “втягивает” функцию o1 -> o2
в вычислительный контекст типа ConversionFromTo i
(хорошо, что
входной сигнал идёт самым первым параметром – мы можем зафиксировать два контекста с однотипными входами). Действительно, это – функтор:
instance Functor (ConversionFromTo i) where
-- fmap :: (a -> b) -> f a -> f b
fmap = applyPost
Теперь у нас есть общий способ управления выходным значением (то есть простой и унифицированный способ постройки новых преобразователей):
> convertFromTo (fmap (\n -> n * 2) boolToInt) True
2
Управление входом (Contravariant)⌗
Можем ли мы подобным образом управлять входом (ещё до воздействия на него описанного в ConversionFromTo
преобразования)?
Удивительно, но да:
-- o is fixed (otherwise the composition won't work)
applyPre :: (i1 -> i2) -> ConversionFromTo i2 o -> ConversionFromTo i1 o
applyPre f (ConverterFromTo i2o) = ConverterFromTo $ \i1 -> (i2o . f) i1
Мы модифицируем вход предварительно (до поступления фактического сигнала в преобразователь):
> convertFromTo (applyPre not boolToInt) False
1
> convertFromTo (applyPre (+100.0) doubleToInt) 1.6
102
Функция applyPre
чем-то похожа на applyPost
(подарившую нам функтор в прошлом параграфе):
applyPost :: (o1 -> o2) -> ConversionFromTo i o1 -> ConversionFromTo i o2
applyPre :: (i1 -> i2) -> ConversionFromTo i2 o -> ConversionFromTo i1 o
Однако в данном случае мы управляем входом (хотим преобразовывать его из одного типа в другой), а значит не можем зафиксировать первый
параметр к конструктору ConverterFromTo i o
! Фактически, у нас не один вычислительный контекст, как ранее, а два разных: ConversionFromTo i2
и ConversionFromTo i1
. Никаких функторов так не построить. А всё из за порядка аргументов к конструктору типа.
Придётся создать новый тип, только для данного случая, с перевёрнутым порядком i
и o
и зафиксировать o
:
newtype ConversionToFrom o i = ConverterToFrom { convertToFrom :: i -> o }
-- o is fixed
applyPreRev :: (i1 -> i2) -> ConversionToFrom o i2 -> ConversionToFrom o i1
applyPreRev f (ConverterToFrom i2o) = ConverterToFrom $ \i1 -> (i2o . f) i1
Обобщим типы и сравним сигнатуры. Это функторы:
applyPost :: (a -> b) -> f a -> f b
applyPreRev :: (a -> b) -> f' b -> f' a
Данный функтор тоже переносит стрелку (a -> b)
в некоторый вычислительный контекст f'
, но изменяет её направление! Мы говорим, что
функтор ковариантен на своём аргументе, если направление стрелки сохранено. Напротив, функтор контравариантен тогда, когда он
переворачивает направление стрелки:
import Data.Functor.Contravariant
instance Contravariant (ConversionToFrom o) where
-- contramap :: (a -> b) -> f b -> f a
contramap = applyPreRev
Мы всё же оставлены фактически с другим функтором, контравариантным ConversionToFrom o
, а значит не можем использовать одни и те же
преобразователи с ковариантным ConversionFromTo i
:
boolToIntRev :: ConversionToFrom Int Bool
boolToIntRev = ConverterToFrom (\b -> if b == True then 1 else 0)
Функция contramap
является аналогом fmap
ковариантных функторов:
> convertToFrom (contramap not boolToIntRev) False
1
Теперь у нас два функтора. Как их соединить в один?
Бифункторы и профункторы⌗
Функторы от двух аргументов (т.е. бинарные функторы) называются бифункторами, и мы можем объединить оба функтора выше в один тайпкласс.
В Haskell тайпклассом Bifunctor
называется бифунктор, ковариантный на обоих аргументах. Тайпклассом Profunctor
называется бифунктор,
контравариантный на первом и ковариантный на втором аргументах:
import Data.Bifunctor
import Data.Profunctor
class Bifunctor p where
-- ковариантно на обеих аргументах
bimap :: (a -> b) -> (c -> d) -> p a c -> p b d
class Profunctor p where
-- контравариантно на первом, ковариантно на втором аргументе
dimap :: (a -> b) -> (c -> d) -> p b c -> p a d
Вместо определений bimap
и dimap
можно дать определения отображений для каждого конкретного входа first
и second
для ковариантного
бифунктора и lmap
и rmap
для профунктора.
Как нетрудно заметить, желаемый нами функционал по одновременному контролю за входом и выходом преобразования достигается профунктором:
instance Profunctor ConversionFromTo where
lmap = applyPre
rmap = applyPost
Вот функция, добавляющая одновременно ко входу и выходу соответствующего преобразования (профунктора) информацию о включении в стоимость потребительского налога:
addTax :: (Profunctor p) => Double -> p Double String -> p Double String
addTax taxrate = dimap (\price -> price + price * taxrate) (++ " (incl. taxes: " ++ show taxrate ++ "%)")
Накинем десять процентов от стоимости (вокруг преобразования doubleToString
):
> convertFromTo (addTax 0.1 doubleToString) 120
"132.0 (incl. taxes: 0.1%)"
Замечательно. Нужно плотнее изучать профункторы!