Введение

Предположим, что у нас есть следующая структура данных, моделирующая некоторое абстрактное преобразование сущности 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%)"

Замечательно. Нужно плотнее изучать профункторы!