Приглашаю всех любознательных новичков, как и я, на прогулку по классам^[В данном случае термины класс и экземляр, разумеется, не имеют ничего общего с созвучными терминами из объектно-ориентированного программирования.] типов стандартной библиотеки языка 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), то, разумеется, такая композиция будет функтором в силу определения композиции и того факта, что функторы не могут менять своей структуры.