Прогулки с тайпклассами Haskell: функторы
Содержание
Приглашаю всех любознательных новичков, как и я, на прогулку по классам^[В данном случае термины класс и экземляр, разумеется, не имеют ничего общего с созвучными терминами из объектно-ориентированного программирования.] типов стандартной библиотеки языка Haskell. На wiki сообщества хакеров Haskell есть страница Typeclassopedia, в которой подробно рассказывается про самые фундаментальные классы языка, приводятся интересные примеры интуиции (метафор, которыми можно понимать для себя тот или иной класс), а также даются полезные упражнения.
Я бы хотел пройтись по каждому из описанных в Typeclassopedia классов и понять их все; потратить несколько минут благодарных размышлений над красивейшими абстракциями и глубокими математическими идеями, стоящими за каждым из них, а также выполнить все упражнения из параграфов Exercises.
Начнём наше путешествие по дивному саду с класса Functor
.
Functor⌗
Класс Functor
– один из самых фундаментальных классов. Представляет собой вычислительный контекст. Экземпляр^[Являться экземпляром класса типов значит реализовывать
необходимый и достаточный интерфейс этого ласса] класса Functor
является в некотором роде контейнером, к элементам внутри которого можно равномерно применять
некоторую функцию.
Определение⌗
class Functor f where
fmap :: (a -> b) -> f a -> f b
(<$) :: a -> f b -> f a
(<$) = fmap . const
Как видно из сигнатуры fmap
, тип f
, претендующий на звание функтора, это не конкретный тип вроде Int
, а тип рода * -> *
, то есть унарный конструктор типов вроде Maybe
(не существует значений типа Maybe
, а есть
значения типа Maybe Int
) или []
(не существует значений типа []
, но есть значения типа [] Int
или, в снитаксическом сахаре, [Int]
).
Например, полиморфический тип обыкновенного списка []
– это функтор, поэтому мы можем применять функцию к его содержимому:
> fmap (+ 100) [1, 2, 3, 4]
[101, 102, 103, 104]
Как нетрудно догадаться, реализация fmap
для списка соответствует функции map
над списками.
Труднее но полезнее думать о списке как о вычислительном контексте с недетерминированным результатом, то есть при выполнении принимающим сразу все значения одновременно.
Функтор Maybe
при этом представляет собой контекст, вычисление в котором может произойти (вычислиться в Just a
) или не произойти вовсе (то есть вычислиться в Nothing
).
Например, стандартная функция lookup
над списками производит поиск значения по указанному ключу и выдаёт результат в таком контексте Maybe
(указанного ключа в списке может и не существовать, отсюда и негарантированность вычисления). Рождественские скидки на фрукты:
> fmap christmasSale $ lookup "apple" [("orange", 36), ("apple", 32), ("banana", 24)]
Just 28.8
> fmap christmasSale $ lookup "peach" [("orange", 36), ("apple", 32), ("banana", 24)]
Nothing
> fmap (christmasSale . snd) [("orange", 36), ("apple", 32), ("banana", 24)]
[32.4,28.8,21.6]
Нетрудно догадаться, что реализация fmap
для Functor Maybe
представляет собой применение заданной функции к значению a
, если результат контекста Just a
и возвращение
Nothing
без какого-либо применения, если в контексте Nothing
.
Ту же самую функцию можно применить и к другому контексту (функтору []
); сама функция при этом ничего не знает и не должна знать об устройстве этого контейнера.
По сути, fmap
как-бы втягивает функцию в контейнер и применяет к содержимому. Это допустимо лишь если совпадёт тип входного параметра функции и тип внутри контейнера,
что очевидно из сигнатуры.
Функторы⌗
Функторы из Prelude стандартной библиотеки:
instance Functor (Either a) -- Defined in ‘Data.Either’
instance Functor [] -- Defined in ‘GHC.Base’
instance Functor Maybe -- Defined in ‘GHC.Base’
instance Functor IO -- Defined in ‘GHC.Base’
instance Functor ((->) r) -- Defined in ‘GHC.Base’
instance Functor ((,) a) -- Defined in ‘GHC.Base’
Меня особенно возбуждают последние два функтора.
Тип ((->) r)
(иначе записывается как r ->
) представляет собой тип функций, которые принимают параметр типа r
. Конкретный же тип ((->) r a)
(иначе записывается r -> a
) таким образом представляет собой тип всевозможных функций из значений типа r
в значения типа a
(т.н. функциональное пространтсво), то есть все возможные способы
индексировать значения a
значениями r
. Как вычислительный контекст, интуитивно, Functor ((->) r)
представляет из себя некоторое окружение, в котором значение типа
r
доступно вычисляемой сущности для чтения (консультации). Например, можно представить, как (Int ->)
предстаёт таким абстрактным контекстом, где для “консультации”
всегда будет доступно значение типа Int
, то есть некоторое число (как входной параметр): тип (Int -> Char)
обыкновенных функций из чисел в символы ASCII, фактически,
есть не что иное, как символы, помещённые в такой вычислительный контекст.
Крутейшим образом реализован fmap
для Functor ((->) r)
(на самом деле это упражнение). Вот мой вариант:
instance Functor ((->) r) where
fmap :: (a -> b) -> (r -> a) -> (r -> b)
fmap fromAtoB ctxWithR = fromAtoB . ctxWithR
Выполнение вычисления в “окружении r
” есть обыкновенная композиция функций (в Haskell обозначается .
)! Довольно тривиальный пример:
> let ctx = (* 2) -- контекст "умножения всего на два"
> let f x = 100 - x -- обычная функция над числом (вычитаем его из ста)
> ctx 3 -- помещаем тройку в контекст
6
> (fmap f ctx) 3 -- втягиваем функцию в контекст
94
> fmap f ctx 3 == (f . ctx) 3
True
Тип ((,) e)
представляет собой контейнер с аннотацией, который наряду со своим полезным содержимом (может быть, некоторого типа a
), обязательно содержит
“аннотацию” типа e
. Интересно, что таким образом в Haskell осмысливаются гетерогенные пары вроде ("john", 65536)
, которые являются одним из базовых типов языка. Кстати,
запись ((,) e)
, по аналогии выше, можно представлять себе как неграмматичное в стандартном языке (e, )
. Собственно, обычные пары вроде (123, False)
на самом деле
конструируются (,) 123 False
.
Одно из упражнений ниже предлагает сделать ((,) e)
функтором. Элементарно:
instance Functor ((,) e) where
fmap f ((,) e c) = (,) e (f c)
То есть fmap
применяет функцию к содержимому, не нарушая аннотации контейнера.
> fmap (+100) (3, 29)
(3,129)
Упражнения⌗
1. Implement Functor
instances for Either e
and ((->) e)
.⌗
Напомню определение типа Either
(контейнера, который может содержать либо значение типа a
, либо значение типа b
):
data Either a b = Left a | Right b -- Defined in ‘Data.Either’
Этот тип часто используется для сигнализации некоторой сбойной ситуации (но в отличии от Maybe
могут нести причину сбоя), при этом значения, построенные конструктором Left
, считаются неординарными. Втягиваем функцию только если был использован конструктор Right
:
instance Functor (Either e) where
fmap f (Left e) = Left e
fmap f (Right a) = Right (f a)
Тривиально.
2. Implement Functor
instances for ((,) e)
and for Pair
, defined as⌗
data Pair a = Pair a a
Explain their similarities and differences.
Просто,
instance Functor ((,) e) where
fmap f ((,) e c) = (,) e (f c)
instance Functor Pair where
fmap f (Pair a b) = Pair (f a) (f b)
Мы видим, что первый функтор сохраняет первый элемент пары (аннотацию), в то время как второй трактует пару равноправно и применяет функцию к обеим элементам.
При некоторой похожести на первый взгляд, у них разная семантика: оба типа данных хранят пару элементов ((,) a b
гетерогенен, Pair a
гомогенен), но приведённые
определения функторов вкладывают разные смыслы в понятия вычислительного контекста.
3. Implement a Functor instance for the type ITree, defined as⌗
data ITree a = Leaf (Int -> a)
| Node [ITree a]
Заметим, что аргумент в конструкторе Leaf
– функтор (см. Пункт 1). Аргумент в конструкторе Node
тоже функтор (список), внутри которого помещён тип нашего дерева
ITree a
, т.е. определение данного типа данных рекурсивно (правая часть ссылается на левую часть).
Математическая индукция: пусть мы определили fmap
над деревом из конструктора Leaf
, тогда fmap
над конструктором Node
есть применение fmap
к каждому дереву из списка (по определению типа ITree
). Но списки суть функторы; мы имеем
три разных вычислительных контекста с единым интерфейсом тайпкласса Functor
:
instance Functor ITree where
fmap f (Leaf x) = Leaf $ fmap f x
fmap f (Node x) = Node $ fmap (fmap f) x
fmap
для списков – это синоним map
. В нашей реализации видно, как функторы вкладываются друг в друга. Что может быть приятнее?
4. Give an example of a type of kind * -> * which cannot be made an instance of Functor (without using undefined).⌗
Такой тип должен иметь какие-либо ограничения на параметрический полиморфизм в определении типа. Например, функтором не может стать “список поверх чисел” ниже:
{-# LANGUAGE DatatypeContexts #-}
data Num a => List a = End | Cons a (List a) deriving Show
instance Functor List where
fmap f End = End
fmap f (Cons a as) = Cons (f a) (fmap f as) -- но в сигнатуре fmap только абстрактные типы!
No instance for (Num a) arising from a use of ‘Cons’
Possible fix:
add (Num a) to the context of
the type signature for fmap :: (a -> b) -> List a -> List b
In the pattern: Cons a as
In an equation for ‘fmap’:
fmap f (Cons a as) = Cons (f a) (fmap f as)
In the instance declaration for ‘Functor List’
Не получится также сделать функтором следующий странный тип:
data Flip a b = Flip b a
Функция fmap
не должна менять структуру контекста, но любое конструирование типа данных выше, фактически, это делает.
Если кто-нибудь сможет дать лучшее объяснение или даже опровергнуть моё утверждение, то пожалуйста напишите мне.
5. Is this statement true or false?⌗
The composition of two Functors is also a Functor.
If false, give a counterexample; if true, prove it by exhibiting some appropriate Haskell code.
Если честно, то мне не совсем понятно, что автор имеет в виду под “композицией функторов”… Если речь идёт о двух функторах f1 a
и f2 (f1 a)
, то, разумеется,
такая композиция будет функтором в силу определения композиции и того факта, что функторы не могут менять своей структуры.