Изучай Haskell во имя добра!

Липовача Миран

11

Аппликативные функторы

 

 

Сочетание чистоты, функций высшего порядка, параметризованных алгебраических типов данных и классов типов в языке Haskell делает реализацию полиморфизма более простой, чем в других языках. Нам не нужно думать о типах, принадлежащих к большой иерархии. Вместо этого мы изучаем, как могут действовать типы, а затем связываем их с помощью подходящих классов типов. Тип Int может вести себя как множество сущностей – сравниваемая сущность, упорядочиваемая сущность, перечислимая сущность и т. д.

Классы типов открыты – это означает, что мы можем определить собственный тип данных, обдумать, как он может действовать, и связать его с классами типов, которые определяют его поведение. Также можно ввести новый класс типов, а затем сделать уже существующие типы его экземплярами. По этой причине и благодаря прекрасной системе типов языка Haskell, которая позволяет нам знать многое о функции только по её объявлению типа, мы можем определять классы типов, которые описывают очень общее, абстрактное поведение.

Мы говорили о классах типов, которые определяют операции для проверки двух элементов на равенство и для сравнения двух элементов по размещению их в каком-либо порядке. Это очень абстрактное и элегантное поведение, хотя мы не воспринимаем его как нечто особенное, поскольку нам доводилось наблюдать его большую часть нашей жизни. В главе 7 были введены функторы – они являются типами, значения которых можно отобразить. Это пример полезного и всё ещё довольно абстрактного свойства, которое могут описать классы типов. В этой главе мы ближе познакомимся с функторами, а также с немного более сильными и более полезными их версиями, которые называются аппликативными функторами.

 

Функторы возвращаются

 

Как вы узнали из главы 7, функторы – это сущности, которые можно отобразить, как, например, списки, значения типа Maybe и деревья. В языке Haskell они описываются классом типов Functor, содержащим только один метод fmap. Функция fmap имеет тип fmap :: (a –> b) –> f a –> f b, который говорит: «Дайте мне функцию, которая принимает a и возвращает b и коробку, где содержится a (или несколько a), и я верну коробку с b (или несколькими b) внутри». Она применяет функцию к элементу внутри коробки.

Мы также можем воспринимать значения функторов как значения с добавочным контекстом. Например, значения типа Maybe обладают дополнительным контекстом того, что вычисления могли окончиться неуспешно. По отношению к спискам контекстом является то, что значение может быть множественным либо отсутствовать. Функция fmap применяет функцию к значению, сохраняя его контекст.

Если мы хотим сделать конструктор типа экземпляром класса Functor, он должен иметь сорт * –> *; это значит, что он принимает ровно один конкретный тип в качестве параметра типа. Например, конструктор Maybe может быть сделан экземпляром, так как он получает один параметр типа для произведения конкретного типа, как, например, Maybe Int или Maybe String. Если конструктор типа принимает два параметра, как, например, конструктор Either, мы должны частично применять конструктор типа до тех пор, пока он не будет принимать только один параметр. Поэтому мы не можем написать определение Functor Either where, зато можем написать определение Functor (Either a) where. Затем, если бы мы вообразили, что функция fmap предназначена только для работы со значениями типа Either a, она имела бы следующее описание типа:

fmap :: (b –> c) –> Either a b –> Either a c

Как видите, часть Either a – фиксированная, потому что частично применённый конструктор типа Either a принимает только один параметр типа.

 

Действия ввода-вывода в качестве функторов

К настоящему моменту вы изучили, каким образом многие типы (если быть точным, конструкторы типов) являются экземплярами класса Functor: [] и Maybe, Either a, равно как и тип Tree, который мы создали в главе 7. Вы видели, как можно отображать их с помощью функций на всеобщее благо. Теперь давайте взглянем на экземпляр типа IO.

Если какое-то значение обладает, скажем, типом IO String, это означает, что перед нами действие ввода-вывода, которое выйдет в реальный мир и получит для нас некую строку, которую затем вернёт в качестве результата. Мы можем использовать запись <– в синтаксисе do для привязывания этого результата к имени. В главе 8 мы говорили о том, что действия ввода-вывода похожи на ящики с маленькими ножками, которые выходят наружу и приносят нам какое-то значение из внешнего мира. Мы можем посмотреть, что они принесли, но после просмотра нам необходимо снова обернуть значение в тип IO. Рассматривая эту аналогию с ящиками на ножках, вы можете понять, каким образом тип IO действует как функтор.

Давайте посмотрим, как же это тип IO является экземпляром класса Functor… Когда мы используем функцию fmap для отображения действия ввода-вывода с помощью функции, мы хотим получить обратно действие ввода-вывода, которое делает то же самое, но к его результирующему значению применяется наша функция. Вот код:

instance Functor IO where

   fmap f action = do

      result <– action

      return (f result)

Результатом отображения действия ввода-вывода с помощью чего-либо будет действие ввода-вывода, так что мы сразу же используем синтаксис do для склеивания двух действий и создания одного нового. В реализации для метода fmap мы создаём новое действие ввода-вывода, которое сначала выполняет первоначальное действие ввода-вывода, давая результату имя result. Затем мы выполняем return (f result). Вспомните, что return – это функция, создающая действие ввода-вывода, которое ничего не делает, а только возвращает что-либо в качестве своего результата.

Действие, которое производит блок do, будет всегда возвращать результирующее значение своего последнего действия. Вот почему мы используем функцию return, чтобы создать действие ввода-вывода, которое в действительности ничего не делает, а просто возвращает применение f result в качестве результата нового действия ввода-вывода. Взгляните на этот кусок кода:

main = do

   line <– getLine

   let line' = reverse line

   putStrLn $ "Вы сказали " ++ line' ++ " наоборот!"

   putStrLn $ "Да, вы точно сказали " ++ line' ++ " наоборот!"

У пользователя запрашивается строка, и мы отдаём её обратно пользователю, но в перевёрнутом виде. А вот как можно переписать это с использованием функции fmap:

main = do

   line <– fmap reverse getLine

   putStrLn $ "Вы сказали " ++ line ++ " наоборот!"

   putStrLn $ "Да, вы точно сказали " ++ line ++ " наоборот!"

Так же как можно отобразить Just "уфф" с помощью отображения fmap reverse, получая Just "ффу", мы можем отобразить и функцию getLine с помощью отображения fmap reverse. Функция getLine – это действие ввода-вывода, которое имеет тип IO String, и отображение его с помощью функции reverse даёт нам действие ввода-вывода, которое выйдет в реальный мир и получит строку, а затем применит функцию reverse к своему результату. Таким же образом, как мы можем применить функцию к тому, что находится внутри коробки Maybe, можно применить функцию и к тому, что находится внутри коробки IO, но она должна выйти в реальный мир, чтобы получить что-либо. Затем, когда мы привязываем результат к имени, используя запись <–, имя будет отражать результат, к которому уже применена функция reverse.

Действие ввода-вывода fmap (++"!") getLine ведёт себя в точности как функция getLine, за исключением того, что к её результату всегда добавляется строка "!" в конец!

Если бы функция fmap работала только с типом IO, она имела бы тип fmap :: (a –> b) –> IO a –> IO b. Функция fmap принимает функцию и действие ввода-вывода и возвращает новое действие ввода-вывода, похожее на старое, за исключением того, что к результату, содержащемуся в нём, применяется функция.

Предположим, вы связываете результат действия ввода-вывода с именем лишь для того, чтобы применить к нему функцию, а затем даёте очередному результату какое-то другое имя, – в таком случае подумайте над использованием функции fmap. Если вы хотите применить несколько функций к некоторым данным внутри функтора, то можете объявить свою функцию на верхнем уровне, создать анонимную функцию или, в идеале, использовать композицию функций:

import Data.Char

import Data.List

main = do

   line <– fmap (intersperse '-' . reverse . map toUpper) getLine

   putStrLn line

Вот что произойдёт, если мы сохраним этот код в файле fmapping_io.hs, скомпилируем, запустим и введём "Эй, привет":

$ ./fmapping_io

Эй, привет

Т-Е-В-И-Р-П- -,-Й-Э

Выражение intersperse '-' . reverse . map toUpper берёт строку, отображает её с помощью функции toUpper, применяет функцию reverse к этому результату, а затем применяет к нему выражение intersperse '-'. Это более красивый способ записи следующего кода:

(\xs –> intersperse '-' (reverse (map toUpper xs)))

 

Функции в качестве функторов

Другим экземпляром класса Functor, с которым мы всё время имели дело, является (–>) r. Стойте!.. Что, чёрт возьми, означает (–>) r? Тип функции r –> a может быть переписан в виде (–>) r a, так же как мы можем записать 2 + 3 в виде (+) 2 3. Когда мы воспринимаем его как (–>) r a, то (–>) представляется немного в другом свете. Это просто конструктор типа, который принимает два параметра типа, как это делает конструктор Either.

Но вспомните, что конструктор типа должен принимать в точности один параметр типа, чтобы его можно было сделать экземпляром класса Functor. Вот почему нельзя сделать конструктор (–>) экземпляром класса Functor; однако, если частично применить его до (–>) r, это не составит никаких проблем. Если бы синтаксис позволял частично применять конструкторы типов с помощью сечений – подобно тому как можно частично применить оператор +, выполнив (2+), что равнозначно (+) 2, – вы могли бы записать (–>) r как (r –>).

Каким же образом функции выступают в качестве функторов? Давайте взглянем на реализацию, которая находится в модуле Control.Monad.Instances.

instance Functor ((–>) r) where

   fmap f g = (\x –> f (g x))

Сначала подумаем над типом метода fmap:

fmap :: (a –> b) –> f a –> f b

Далее мысленно заменим каждое вхождение идентификатора f, являющегося ролью, которую играет наш экземпляр функтора, выражением (–>) r. Это позволит нам понять, как функция fmap должна вести себя в отношении данного конкретного экземпляра. Вот результат:

fmap :: (a –> b) –> ((–>) r a) –> ((–>) r b)

Теперь можно записать типы (–>) r a и (–>) r b в инфиксном виде, то есть r –> a и r –> b, как мы обычно поступаем с функциями:

fmap :: (a –> b) –> (r –> a) –> (r –> b)

Хорошо. Отображение одной функции с помощью другой должно произвести функцию, так же как отображение типа Maybe с помощью функции должно произвести тип Maybe, а отображение списка с помощью функции – список. О чём говорит нам предыдущий тип? Мы видим, что он берёт функцию из a в b и функцию из r в a и возвращает функцию из r в b. Напоминает ли это вам что-нибудь? Да, композицию функций!.. Мы присоединяем выход r –> a ко входу a –> b, чтобы получить функцию r –> b, чем в точности и является композиция функций. Вот ещё один способ записи этого экземпляра:

instance Functor ((–>) r) where

   fmap = (.)

Код наглядно показывает, что применение функции fmap к функциям – это просто композиция функций.

В исходном коде импортируйте модуль Control.Monad.Instances, поскольку это модуль, где определён данный экземпляр, а затем загрузите исходный код и попробуйте поиграть с отображением функций:

ghci> :t fmap (*3) (+100)

fmap (*3) (+100) :: (Num a) => a –> a

ghci> fmap (*3) (+100) 1

303

ghci> (*3) `fmap` (+100) $ 1

303

ghci> (*3) . (+100) $ 1

303

ghci> fmap (show . (*3)) (*100) 1

"300"

Мы можем вызывать fmap как инфиксную функцию, чтобы сходство с оператором . было явным. Во второй строке ввода мы отображаем (+100) с помощью (*3), что даёт функцию, которая примет ввод, применит к нему (+100), а затем применит к этому результату (*3). Затем мы применяем эту функцию к значению 1.

Как и все функторы, функции могут восприниматься как значения с контекстами. Когда у нас есть функция вроде (+3), мы можем рассматривать значение как окончательный результат функции, а контекстом является то, что мы должны применить эту функцию к чему-либо, чтобы получить результат. Применение fmap (*3) к (+100) создаст ещё одну функцию, которая действует так же, как (+100), но перед возвратом результата к этому результату будет применена функция (*3).

Тот факт, что функция fmap является композицией функций при применении к функциям, на данный момент не слишком нам полезен, но, по крайней мере, он вызывает интерес. Это несколько меняет наше сознание и позволяет нам увидеть, как сущности, которые действуют скорее как вычисления, чем как коробки (IO и (–>) r), могут быть функторами. Отображение вычисления с помощью функции возвращает тот же самый тип вычисления, но результат этого вычисления изменён функцией.

Перед тем как перейти к законам, которым должна следовать fmap, давайте ещё раз задумаемся о типе fmap:

fmap :: (a –> b) –> f a –> f b

Если помните, введение в каррированные функции в главе 5 началось с утверждения, что все функции в языке Haskell на самом деле принимают один параметр. Функция a –> b –> c в действительности берёт только один параметр типа a, после чего возвращает функцию b –> c, которая принимает один параметр типа b и возвращает значение типа c. Вот почему вызов функции с недостаточным количеством параметров (её частичное применение) возвращает нам обратно функцию, принимающую несколько параметров, которые мы пропустили (если мы опять воспринимаем функции так, как если бы они принимали несколько параметров). Поэтому a –> b –> c можно записать в виде a –> (b –> c), чтобы сделать каррирование более очевидным.

Аналогичным образом, записав fmap :: (a –> b) –> (f a –> f b), мы можем воспринимать fmap не как функцию, которая принимает одну функцию и значение функтора и возвращает значение функтора, но как функцию, которая принимает функцию и возвращает новую функцию, которая такая же, как и прежняя, за исключением того, что она принимает значение функтора в качестве параметра и возвращает значение функтора в качестве результата. Она принимает функцию типа a –> b и возвращает функцию типа f a –> f b. Это называется «втягивание функции». Давайте реализуем эту идею, используя команду :t в GHCi:

ghci> :t fmap (*2)

fmap (*2) :: (Num a, Functor f) => f a –> f a

ghci> :t fmap (replicate 3)

fmap (replicate 3) :: (Functor f) => f a –> f [a]

Выражение fmap (*2) – это функция, которая получает функтор f над числами и возвращает функтор над числами. Таким функтором могут быть список, значение Maybe, Either String или что-то другое. Выражение fmap (replicate 3) получит функтор над любым типом и вернёт функтор над списком элементов данного типа. Это становится ещё очевиднее, если мы частично применим, скажем, fmap (++"!"), а затем привяжем её к имени в GHCi.

Вы можете рассматривать fmap двояко:

• как функцию, которая принимает функцию и значение функтора, а затем отображает это значение функтора с помощью данной функции;

• как функцию, которая принимает функцию и втягивает её в функтор, так чтобы она оперировала значениями функторов.

Обе точки зрения верны.

Тип fmap (replicate 3) :: (Functor f) => f a –> f [a] означает, что функция будет работать с любым функтором. Что именно она будет делать, зависит от функтора. Если мы применим fmap (replicate 3) к списку, будет выбрана реализация fmap для списка, то есть просто map. Если мы применим её к Maybe a, она применит replicate 3 к значению внутри Just. Если это значение равно Nothing, то оно останется равным Nothing. Вот несколько примеров:

ghci> fmap (replicate 3) [1,2,3,4]

[[1,1,1],[2,2,2],[3,3,3],[4,4,4]]

ghci> fmap (replicate 3) (Just 4)

Just [4,4,4]

ghci> fmap (replicate 3) (Right "ля")

Right ["ля","ля","ля"]

ghci> fmap (replicate 3) Nothing

Nothing

ghci> fmap (replicate 3) (Left "фуу")

Left "фуу"

 

Законы функторов

 

Предполагается, что все функторы проявляют определённые свойства и поведение. Они должны надёжно вести себя как сущности, которые можно отобразить. Применение функции fmap к функтору должно только отобразить функтор с помощью функции – ничего более. Это поведение описано в законах функторов. Все экземпляры класса Functor должны следовать этим двум законам. Язык Haskell не принуждает, чтобы эти законы выполнялись автоматически, поэтому вы должны проверять их сами, когда создаёте функтор. Все экземпляры класса Functor в стандартной библиотеке выполняют эти законы.

 

Закон 1

Первый закон функторов гласит, что если мы применяем функцию id к значению функтора, то значение функтора, которое мы получим, должно быть таким же, как первоначальное значение функтора. В формализованной записи это выглядит так: fmap id = id. Иными словами, если мы применим fmap id к значению функтора, это должно быть то же самое, что и просто применение функции id к значению. Вспомните, что id – это функция тождества, которая просто возвращает свой параметр неизменным. Она также может быть записана в виде \x –> x. Если воспринимать значение функтора как нечто, что может быть отображено, то закон fmap id = id представляется довольно очевидным.

Давайте посмотрим, выполняется ли он для некоторых значений функторов:

ghci> fmap id (Just 3)

Just 3

ghci> id (Just 3)

Just 3

ghci> fmap id [1..5]

[1,2,3,4,5]

ghci> id [1..5]

[1,2,3,4,5]

ghci> fmap id []

[]

ghci> fmap id Nothing

Nothing

Если посмотреть на реализацию функцию fmap, например, для типа Maybe, мы можем понять, почему выполняется первый закон функторов:

instance Functor Maybe where

   fmap f (Just x) = Just (f x)

   fmap f Nothing= Nothing

Мы представляем, что функция id играет роль параметра f в этой реализации. Нам видно, что если мы применяем fmap id к значению Just x, то результатом будет Just (id x), и поскольку id просто возвращает свой параметр, мы можем сделать вывод, что Just (id x) равно Just x. Теперь нам известно, что если мы применим функцию id к значению типа Maybe, созданному с помощью конструктора данных Just, обратно мы получим то же самое значение.

Видно, что применение функции id к значению Nothing возвращает то же самое значение Nothing. Поэтому из этих двух равенств в реализации функции fmap нам видно, что закон fmap id = id соблюдается.

 

Закон 2

Второй закон гласит, что композиция двух функций и последующее применение результирующей функции к функтору должны давать тот же результат, что и применение первой функции к функтору, а затем применение другой. В формальной записи это выглядит так: fmap (f . g) = fmap f . fmap g. Или если записать по-другому, то для любого значения функтора x должно выполняться следующее: fmap (f . g) x = fmap f (fmap g x).

Если мы выявили, что некоторый тип подчиняется двум законам функторов, надо надеяться, что он обладает такими же фундаментальными поведениями, как и другие функторы, когда дело доходит до отображения. Мы можем быть уверены, что когда мы применяем к нему функцию fmap, за кулисами ничего не произойдёт, кроме отображения, и он будет действовать как сущность, которая может быть отображена – то есть функтор.

Можно выяснить, как второй закон выполняется по отношению к некоторому типу, посмотрев на реализацию функции fmap для этого типа, а затем использовав метод, который мы применяли, чтобы проверить, подчиняется ли тип Maybe первому закону. Итак, чтобы проверить, как второй закон функторов выполняется для типа Maybe, если мы применим выражение fmap (f . g) к значению Nothing, мы получаем то же самое значение Nothing, потому что применение любой функции к Nothing даёт Nothing. Если мы выполним выражение fmap f (fmap g Nothing), то получим результат Nothing по тем же причинам.

Довольно просто увидеть, как второй закон выполняется для типа Maybe, когда значение равно Nothing. Но что если это значение Just? Ладно – если мы выполним fmap (f . g) (Just x), из реализации нам будет видно, что это реализовано как Just ((f . g) x); аналогичной записью было бы Just (f (g x)). Если же мы выполним fmap f (fmap g (Just x)), то из реализации увидим, что fmap g (Just x) – это Just (g x). Следовательно, fmap f (fmap g (Just x)) равно fmap f (Just (g x)), а из реализации нам видно, что это равнозначно Just (f (g x)).

Если вы немного смущены этим доказательством, не волнуйтесь. Убедитесь, что вы понимаете, как устроена композиция функций. Часто вы можете интуитивно понимать, как выполняются эти законы, поскольку типы действуют как контейнеры или функции. Вы также можете просто проверить их на нескольких разных значениях типа – и сумеете с определённой долей уверенности сказать, что тип действительно подчиняется этим законам.

 

Нарушение закона

Давайте посмотрим на «патологический» пример конструктора типов, который является экземпляром класса типов Functor, но не является функтором, потому что он не выполняет законы. Скажем, у нас есть следующий тип:

data CMaybe a = CNothing | CJust Int a deriving (Show)

Буква C здесь обозначает счётчик. Это тип данных, который во многом похож на тип Maybe a, только часть Just содержит два поля вместо одного. Первое поле в конструкторе данных CJust всегда имеет тип Int; оно будет своего рода счётчиком. Второе поле имеет тип a, который берётся из параметра типа, и его тип будет зависеть от конкретного типа, который мы выберем для CMaybe a. Давайте поэкспериментируем с нашим новым типом:

ghci> CNothing

CNothing

ghci> CJust 0 "ха-ха"

CJust 0 "ха-ха"

ghci> :t CNothing

CNothing :: CMaybe a

ghci> :t CJust 0 "ха-ха"

CJust 0 "ха-ха" :: CMaybe [Char]

ghci> CJust 100 [1,2,3]

CJust 100 [1,2,3]

Если мы используем конструктор данных CNothing, в нём нет полей. Если мы используем конструктор данных CJust, первое поле является целым числом, а второе может быть любого типа. Давайте сделаем этот тип экземпляром класса Functor, так чтобы каждый раз, когда мы используем функцию fmap, функция применялась ко второму полю, а первое поле увеличивалось на 1:

instance Functor CMaybe where

   fmap f CNothing= CNothing

   fmap f (CJust counter x) = CJust (counter+1) (f x)

Это отчасти похоже на реализацию экземпляра для типа Maybe, только когда функция fmap применяется к значению, которое не представляет пустую коробку (значение CJust), мы не просто применяем функцию к содержимому, но и увеличиваем счётчик на 1. Пока вроде бы всё круто! Мы даже можем немного поиграть с этим:

ghci> fmap (++"-ха") (CJust 0 "хо")

CJust 1 "хо-ха"

ghci> fmap (++"-хе") (fmap (++"-ха") (CJust 0 "хо"))

CJust 2 "хо-ха-хе"

ghci> fmap (++"ля") CNothing

CNothing

Подчиняется ли этот тип законам функторов? Для того чтобы увидеть, что что-то не подчиняется закону, достаточно найти всего одно исключение.

ghci> fmap id (CJust 0 "ха-ха")

CJust 1 "ха-ха"

ghci> id (CJust 0 "ха-ха")

CJust 0 "ха-ха"

Как гласит первый закон функторов, если мы отобразим значение функтора с помощью функции id, это должно быть то же самое, что и просто вызов функции id с тем же значением функтора. Наш пример показывает, что это не относится к нашему функтору CMaybe. Хотя он и имеет экземпляр класса Functor, он не подчиняется данному закону функторов и, следовательно, не является функтором.

Поскольку тип CMaybe не является функтором, хотя он и притворяется таковым, использование его в качестве функтора может привести к неисправному коду. Когда мы используем функтор, не должно иметь значения, производим ли мы сначала композицию нескольких функций, а затем с её помощью отображаем значение функтора, или же просто отображаем значение функтора последовательно с помощью каждой функции. Но при использовании типа CMaybe это имеет значение, так как он следит, сколько раз его отобразили. Проблема!.. Если мы хотим, чтобы тип CMaybe подчинялся законам функторов, мы должны сделать так, чтобы поле типа Int не изменялось, когда используется функция fmap.

Вначале законы функторов могут показаться немного запутанными и ненужными. Но если мы знаем, что тип подчиняется обоим законам, мы можем строить определённые предположения о том, как он будет действовать. Если тип подчиняется законам функторов, мы знаем, что вызов функции fmap со значением этого типа только применит к нему функцию – ничего более. В результате наш код становится более абстрактным и расширяемым, потому что мы можем использовать законы, чтобы судить о поведении, которым должен обладать любой функтор, а также создавать функции, надёжно работающие с любым функтором.

В следующий раз, когда вы будете делать тип экземпляром класса Functor, найдите минутку, чтобы убедиться, что он удовлетворяет законам функторов. Вы всегда можете пройти по реализации строка за строкой и посмотреть, выполняются ли законы, либо попробовать найти исключение. Изучив функторы в достаточном количестве, вы станете узнавать общие для них свойства и поведение и интуитивно понимать, следует ли тот или иной тип законам функторов.

 

Использование аппликативных функторов

 

В этом разделе мы рассмотрим аппликативные функторы, которые являются расширенными функторами.

До настоящего времени мы были сосредоточены на отображении функторов с помощью функций, принимающих только один параметр. Но что происходит, когда мы отображаем функтор с помощью функции, которая принимает два параметра? Давайте рассмотрим пару конкретных примеров.

Если у нас есть Just 3, и мы выполняем выражение fmap (*) (Just 3), что мы получим? Из реализации экземпляра типа Maybe для класса Functor мы знаем, что если это значение Just, то функция будет применена к значению внутри Just. Следовательно, выполнение выражения fmap (*) (Just 3) вернёт Just ((*) 3), что может быть также записано в виде Just (3 *), если мы используем сечения. Интересно! Мы получаем функцию, обёрнутую в конструктор Just!

Вот ещё несколько функций внутри значений функторов:

ghci> :t fmap (++) (Just "эй")

fmap (++) (Just "эй") :: Maybe ([Char] –> [Char])

ghci> :t fmap compare (Just 'a')

fmap compare (Just 'a') :: Maybe (Char –> Ordering)

ghci> :t fmap compare "A LIST OF CHARS"

fmap compare "A LIST OF CHARS" :: [Char –> Ordering]

ghci> :t fmap (\x y z –> x + y / z) [3,4,5,6]

fmap (\x y z –> x + y / z) [3,4,5,6] :: (Fractional a) => [a –> a –> a]

Если мы отображаем список символов с помощью функции compare, которая имеет тип (Ord a) => a –> a –> Ordering, то получаем список функций типа Char –> Ordering, потому что функция compare частично применяется с помощью символов в списке. Это не список функций типа (Ord a) => a –> Ordering, так как первый идентификатор переменной типа a имел тип Char, а потому и второе вхождение a обязано принять то же самое значение – тип Char.

Мы видим, как, отображая значения функторов с помощью «многопараметрических» функций, мы получаем значения функторов, которые содержат внутри себя функции. А что теперь с ними делать?.. Мы можем, например, отображать их с помощью функций, которые принимают эти функции в качестве параметров – поскольку, что бы ни находилось в значении функтора, оно будет передано функции, с помощью которой мы его отображаем, в качестве параметра.

ghci> let a = fmap (*) [1,2,3,4]

ghci> :t a

a :: [Integer –> Integer]

ghci> fmap (\f –> f 9) a

[9,18,27,36]

Но что если у нас есть значение функтора Just (3 *) и значение функтора Just 5, и мы хотим извлечь функцию из Just (3 *) и отобразить с её помощью Just 5? С обычными функторами у нас этого не получится, потому что они поддерживают только отображение имеющихся функторов с помощью обычных функций. Даже когда мы отображали функтор, содержащий функции, с помощью анонимной функции \f –> f 9, мы делали именно это и только это. Но используя то, что предлагает нам функция fmap, мы не можем с помощью функции, которая находится внутри значения функтора, отобразить другое значение функтора. Мы могли бы произвести сопоставление конструктора Just по образцу для извлечения из него функции, а затем отобразить с её помощью Just 5, но мы ищем более общий и абстрактный подход, работающий с функторами.

 

Поприветствуйте аппликативные функторы

Итак, встречайте класс типов Applicative, находящийся в модуле Control.Applicative!.. Он определяет две функции: pure и <*>. Он не предоставляет реализации по умолчанию для какой-либо из этих функций, поэтому нам придётся определить их обе, если мы хотим, чтобы что-либо стало аппликативным функтором. Этот класс определён вот так:

class (Functor f) => Applicative f where

   pure :: a –> f a

   (<*>) :: f (a –> b) –> f a –> f b

Простое определение класса из трёх строк говорит нам о многом!.. Первая строка начинается с определения класса Applicative; также она вводит ограничение класса. Ограничение говорит, что если мы хотим определить для типа экземпляр класса Applicative, он, прежде всего, уже должен иметь экземпляр класса Functor. Вот почему, когда нам известно, что конструктор типа принадлежит классу Applicative, можно смело утверждать, что он также принадлежит классу Functor, так что мы можем применять к нему функцию fmap.

Первый метод, который он определяет, называется pure. Его сигнатура выглядит так: pure :: a –> f a. Идентификатор f играет здесь роль нашего экземпляра аппликативного функтора. Поскольку язык Haskell обладает очень хорошей системой типов и притом всё, что может делать функция, – это получать некоторые параметры и возвращать некоторое значение, мы можем многое сказать по объявлению типа, и данный тип – не исключение.

Функция pure должна принимать значение любого типа и возвращать аппликативное значение с этим значением внутри него. Словосочетание «внутри него» опять вызывает в памяти нашу аналогию с коробкой, хотя мы и видели, что она не всегда выдерживает проверку. Но тип a –> f a всё равно довольно нагляден. Мы берём значение и оборачиваем его в аппликативное значение, которое содержит в себе это значение в качестве результата. Лучший способ представить себе функцию pure – это сказать, что она берёт значение и помещает его в некий контекст по умолчанию (или чистый контекст) – минимальный контекст, который по-прежнему возвращает это значение.

Оператор <*> действительно интересен. У него вот такое определение типа:

f (a –> b) –> f a –> f b

Напоминает ли оно вам что-нибудь? Оно похоже на сигнатуру fmap :: (a –> b) –> f a –> f b. Вы можете воспринимать оператор <*> как разновидность расширенной функции fmap. Тогда как функция fmap принимает функцию и значение функтора и применяет функцию внутри значения функтора, оператор <*> принимает значение функтора, который содержит в себе функцию, и другой функтор – и извлекает эту функцию из первого функтора, затем отображая с её помощью второй.

 

Аппликативный функтор Maybe

Давайте взглянем на реализацию экземпляра класса Applicative для типа Maybe:

instance Applicative Maybe where

   pure = Just

   Nothing <*> _ = Nothing

   (Just f) <*> something = fmap f something

Опять же из определения класса мы видим, что идентификатор f, который играет роль аппликативного функтора, должен принимать один конкретный тип в качестве параметра. Поэтому мы пишем instance Applicative Maybe where вместо instance Applicative (Maybe a) where.

Далее, у нас есть функция pure. Вспомните, что функция должна что-то принять и обернуть в аппликативное значение. Мы написали pure = Just, потому что конструкторы данных вроде Just являются обычными функциями. Также можно было бы написать pure x = Just x.

Наконец, у нас есть определение оператора <*>. Извлечь функцию из значения Nothing нельзя, поскольку внутри него нет функции. Поэтому мы говорим, что если мы пробуем извлечь функцию из значения Nothing, результатом будет то же самое значение Nothing.

В определении класса Applicative есть ограничение класса Functor – значит, мы можем считать, что оба параметра оператора <*> являются значениями функтора. Если первым аргументом выступает не значение Nothing, а Just с некоторой функцией внутри, то мы говорим, что с помощью данной функции хотим отобразить второй параметр. Этот код также заботится о случае, когда вторым аргументом является значение Nothing, потому что его отображение с помощью любой функции при использовании метода fmap вернёт всё то же Nothing. Итак, в случае с типом Maybe оператор <*> извлекает функцию из значения слева, если это Just, и отображает с её помощью значение справа. Если какой-либо из параметров является значением Nothing, то и результатом будет Nothing.

Теперь давайте это опробуем:

ghci> Just (+3) <*> Just 9

Just 12

ghci> pure (+3) <*> Just 10

Just 13

ghci> pure (+3) <*> Just 9

Just 12

ghci> Just (++"ха-ха") <*> Nothing Nothing

ghci> Nothing <*> Just "во-от"

Nothing

Вы видите, что выполнение выражений pure (+3) и Just (+3) в данном случае – одно и то же. Используйте функцию pure, если имеете дело со значениями типа Maybe в аппликативном контексте (если вы используете их с оператором <*>); в противном случае предпочитайте конструктор Just.

Первые четыре введённых строки демонстрируют, как функция извлекается, а затем используется для отображения; но в данном случае этого можно было добиться, просто применив не обёрнутые функции к функторам. Последняя строка любопытна тем, что мы пытаемся извлечь функцию из значения Nothing, а затем отображаем с её помощью нечто, что в результате даёт Nothing.

Когда вы отображаете функтор с помощью функции при использовании обычных функторов, вы не можете извлечь результат каким-либо общим способом, даже если результатом является частично применённая функция. Аппликативные функторы, с другой стороны, позволяют вам работать с несколькими функторами, используя одну функцию.

 

Аппликативный стиль

При использовании класса типов Applicative мы можем последовательно задействовать несколько операторов <*> в виде цепочки вызовов, что позволяет легко работать сразу с несколькими аппликативными значениями, а не только с одним. Взгляните, например, на это:

ghci> pure (+) <*> Just 3 <*> Just 5

Just 8

ghci> pure (+) <*> Just 3 <*> Nothing

Nothing

ghci> pure (+) <*> Nothing <*> Just 5

Nothing

Мы обернули оператор + в аппликативное значение, а затем использовали оператор <*>, чтобы вызвать его с двумя параметрами, оба из которых являются аппликативными значениями.

Давайте посмотрим, как это происходит, шаг за шагом. Оператор <*> левоассоциативен; это значит, что

pure (+) <*> Just 3 <*> Just 5

то же самое, что и вот это:

(pure (+) <*> Just 3) <*> Just 5

Сначала оператор + помещается в аппликативное значение – в данном случае значение типа Maybe, которое содержит функцию. Итак, у нас есть pure (+), что, по сути, равно Just (+). Далее происходит вызов Just (+) <*> Just 3. Его результатом является Just (3+). Это из-за частичного применения. Применение только значения 3 к оператору + возвращает в результате функцию, которая принимает один параметр и добавляет к нему 3. Наконец, выполняется Just (3+) <*> Just 5, что в результате возвращает Just 8.

Ну разве не здорово?! Аппликативные функторы и аппликативный стиль вычисления pure f <*> x <*> y <*> … позволяют взять функцию, которая ожидает параметры, не являющиеся аппликативными значениями, и использовать эту функцию для работы с несколькими аппликативными значениями. Функция может принимать столько параметров, сколько мы захотим, потому что она всегда частично применяется шаг за шагом между вхождениями оператора <*>.

Это становится ещё более удобным и очевидным, если мы примем во внимание тот факт, что выражение pure f <*> x равно fmap f x. Это один из законов аппликативных функторов, которые мы более подробно рассмотрим чуть позже; но давайте подумаем, как он применяется здесь. Функция pure помещает значение в контекст по умолчанию. Если мы просто поместим функцию в контекст по умолчанию, а затем извлечём её и применим к значению внутри другого аппликативного функтора, это будет то же самое, что просто отобразить этот аппликативный функтор с помощью данной функции. Вместо записи pure f <*> x <*> y <*> …, мы можем написать fmap f x <*> y <*> … Вот почему модуль Control.Applicative экспортирует оператор, названный <$>, который является просто синонимом функции fmap в виде инфиксного оператора. Вот как он определён:

(<$>) :: (Functor f) => (a –> b) –> f a –> f b

f <$> x = fmap f x

ПРИМЕЧАНИЕ. Вспомните, что переменные типов не зависят от имён параметров или имён других значений. Здесь идентификатор f в сигнатуре функции является переменной типа с ограничением класса, которое говорит, что любой конструктор типа, который заменяет f , должен иметь экземпляр класса Functor . Идентификатор f в теле функции обозначает функцию, с помощью которой мы отображаем значение x . Тот факт, что мы использовали f для представления обеих вещей, не означает, что они представляют одну и ту же вещь.

При использовании оператора <$> аппликативный стиль проявляет себя во всей красе, потому что теперь, если мы хотим применить функцию f к трем аппликативным значениям, можно просто написать f <$> x <*> y <*> z. Если бы параметры были обычными значениями, мы бы написали f x y z.

Давайте подробнее рассмотрим, как это работает. Предположим, что мы хотим соединить значения Just "johntra" и Just "volta" в одну строку, находящуюся внутри функтора Maybe. Сделать это вполне в наших силах!

ghci> (++) <$> Just "johntra" <*>

Just "volta" Just "johntravolta"

Прежде чем мы увидим, что происходит, сравните предыдущую строку со следующей:

ghci> (++) "johntra" "volta"

"johntravolta"

Чтобы использовать обычную функцию с аппликативным функтором, просто разбросайте вокруг несколько <$> и <*>, и функция будет работать с аппликативными значениями и возвращать аппликативное значение. Ну не здорово ли?

Возвратимся к нашему выражению (++) <$> Just "джонтра" <*> Just "волта": сначала оператор (++), который имеет тип (++) :: [a] – > [a] –> [a], отображает значение Just "джонтра". Это даёт в результате такое же значение, как Just ("джонтра"++), имеющее тип Maybe ([Char] –> [Char]). Заметьте, как первый параметр оператора (++) был «съеден» и идентификатор a превратился в тип [Char]! А теперь выполняется выражение Just ("джонтра"++) <*> Just "волта", которое извлекает функцию из Just и отображает с её помощью значение Just "волта", что в результате даёт новое значение – Just "джонтраволта". Если бы одним из двух значений было значение Nothing, результатом также было бы Nothing.

 

Списки

Списки (на самом деле конструктор типа списка, []) являются аппликативными функторами. Вот так сюрприз! Вот как [] является экземпляром класса Applicative:

instance Applicative [] where

   pure x = [x]

   fs <*> xs = [f x | f <– fs, x <– xs]

Вспомните, что функция pure принимает значение и помещает его в контекст по умолчанию. Другими словами, она помещает его в минимальный контекст, который всё ещё возвращает это значение. Минимальным контекстом для списков был бы пустой список, но пустой список означает отсутствие значения, поэтому он не может содержать в себе значение, к которому мы применили функцию pure. Вот почему эта функция принимает значение и помещает его в одноэлементный список. Подобным образом минимальным контекстом для аппликативного функтора Maybe было бы значение Nothing – но оно означает отсутствие значения вместо самого значения, поэтому функция pure в реализации экземпляра для типа Maybe реализована как вызов конструктора данных Just.

Вот функция pure в действии:

ghci> pure "Эй" :: [String]

["Эй"]

ghci> pure "Эй" :: Maybe String

Just "Эй"

Что насчёт оператора <*>? Если бы тип оператора <*> ограничивался только списками, мы получили бы (<*>) :: [a –> b] –> [a] –> [b]. Этот оператор реализован через генератор списков. Он должен каким-то образом извлечь функцию из своего левого параметра, а затем с её помощью отобразить правый. Но левый список может не содержать в себе функций или содержать одну либо несколько функций, а правый список также может содержать несколько значений. Вот почему мы используем генератор списков для извлечения из обоих списков. Мы применяем каждую возможную функцию из левого списка к каждому возможному значению из правого. Результирующий список содержит все возможные комбинации применения функции из левого списка к значению из правого.

Мы можем использовать оператор <*> со списками вот так:

ghci> [(*0),(+100),( 2)] <*> [1,2,3]

[0,0,0,101,102,103,1,4,9]

Левый список содержит три функции, а правый – три значения, поэтому в результирующем списке будет девять элементов. Каждая функция из левого списка применяется к каждому элементу из правого. Если у нас имеется список функций, принимающих два параметра, то мы можем применить эти функции между двумя списками.

В следующем примере применяются две функции между двумя списками:

ghci> [(+),(*)] <*> [1,2] <*> [3,4]

[4,5,5,6,3,4,6,8]

Оператор <*> левоассоциативен, поэтому сначала выполняется [(+),(*)] <*> [1,2], результатом чего является такой же список, как [(1+),(2+),(1*),(2*)], потому что каждая функция слева применяется к каждому значению справа. Затем выполняется [(1+),(2+),(1*),(2*)] <*> [3,4], что возвращает окончательный результат.

Как здорово использовать аппликативный стиль со списками!

ghci> (++) <$> ["хa","хeх","хм"] <*> ["?","!","."]

["хa?","хa!","хa.","хeх?","хeх!","хeх.","хм?","хм!","хм."]

Ещё раз: мы использовали обычную функцию, принимающую две строки, между двумя списками строк, просто вставляя соответствующие аппликативные операторы.

Вы можете воспринимать списки как недетерминированные вычисления. Значение вроде 100 или "что" можно рассматривать как детерминированное вычисление, которое имеет только один результат. В то же время список вроде [1,2,3] можно рассматривать как вычисление, которое не в состоянии определиться, какой результат оно желает иметь, поэтому возвращает нам все возможные результаты. Поэтому когда вы пишете что-то наподобие (+) <$> [1,2,3] <*> [4,5,6], то можете рассматривать это как объединение двух недетерминированных вычислений с помощью оператора + только для того, чтобы создать ещё одно недетерминированное вычисление, которое ещё меньше уверено в своём результате.

Использование аппликативного стиля со списками часто является хорошей заменой генераторам списков. В главе 1 мы хотели вывести все возможные комбинации произведений [2,5,10] и [8,10,11] и с этой целью предприняли следующее:

ghci> [x*y | x <– [2,5,10], y <– [8,10,11]]

[16,20,22,40,50,55,80,100,110]

Мы просто извлекаем значения из обоих списков и применяем функцию между каждой комбинацией элементов. То же самое можно сделать и в аппликативном стиле:

ghci> (*) <$> [2,5,10] <*> [8,10,11]

[16,20,22,40,50,55,80,100,110]

Для меня такой подход более понятен, поскольку проще понять, что мы просто вызываем оператор * между двумя недетерминированными вычислениями. Если бы мы захотели получить все возможные произведения элементов, больших 50, мы бы использовали следующее:

ghci> filter (>50) $ (*) <$> [2,5,10] <*> [8,10,11]

[55,80,100,110]

Легко увидеть, что вызов выражения pure f <*> xs при использовании списков эквивалентен выражению fmap f xs. Результат вычисления pure f – это просто [f], а выражение [f] <*> xs применит каждую функцию в левом списке к каждому значению в правом; но в левом списке только одна функция, и, следовательно, это похоже на отображение.

 

Тип IO – тоже аппликативный функтор

Другой экземпляр класса Applicative, с которым мы уже встречались, – экземпляр для типа IO. Вот как он реализован:

instance Applicative IO where

   pure = return

   a <*> b = do

      f <– a

      x <– b

      return (f x)

Поскольку суть функции pure состоит в помещении значения в минимальный контекст, который всё ещё содержит значение как результат, логично, что в случае с типом IO функция pure – это просто вызов return. Функция return создаёт действие ввода-вывода, которое ничего не делает. Оно просто возвращает некое значение в качестве своего результата, не производя никаких операций ввода-вывода вроде печати на терминал или чтения из файла.

Если бы оператор <*> ограничивался работой с типом IO, он бы имел тип (<*>) :: IO (a –> b) –> IO a –> IO b. В случае с типом IO он принимает действие ввода-вывода a, которое возвращает функцию, выполняет действие ввода-вывода и связывает эту функцию с идентификатором f. Затем он выполняет действие ввода-вывода b и связывает его результат с идентификатором x. Наконец, он применяет функцию f к значению x и возвращает результат этого применения в качестве результата. Чтобы это реализовать, мы использовали здесь синтаксис do. (Вспомните, что суть синтаксиса do заключается в том, чтобы взять несколько действий ввода-вывода и «склеить» их в одно.)

При использовании типов Maybe и [] мы могли бы воспринимать применение функции <*> просто как извлечение функции из её левого параметра, а затем применение её к правому параметру. В отношении типа IO извлечение остаётся в силе, но теперь у нас появляется понятие помещения в последовательность, поскольку мы берём два действия ввода-вывода и «склеиваем» их в одно. Мы должны извлечь функцию из первого действия ввода-вывода, но для того, чтобы можно было извлечь результат из действия ввода-вывода, последнее должно быть выполнено. Рассмотрите вот это:

myAction :: IO String

myAction = do

   a <– getLine

   b <– getLine

   return $ a ++ b

Это действие ввода-вывода, которое запросит у пользователя две строки и вернёт в качестве своего результата их конкатенацию. Мы достигли этого благодаря «склеиванию» двух действий ввода-вывода getLine и return, поскольку мы хотели, чтобы наше новое «склеенное» действие ввода-вывода содержало результат выполнения a ++ b. Ещё один способ записать это состоит в использовании аппликативного стиля:

myAction :: IO String

myAction = (++) <$> getLine <*> getLine

Это то же, что мы делали ранее, когда создавали действие ввода-вывода, которое применяло функцию между результатами двух других действий ввода-вывода. Вспомните, что функция getLine – это действие ввода-вывода, которое имеет тип getLine :: IO String. Когда мы применяем оператор <*> между двумя аппликативными значениями, результатом является аппликативное значение, так что всё это имеет смысл.

Если мы вернёмся к аналогии с коробками, то можем представить себе функцию getLine как коробку, которая выйдет в реальный мир и принесёт нам строку. Выполнение выражения (++) <$> getLine <*> getLine создаёт другую, бо́льшую коробку, которая посылает эти две коробки наружу для получения строк с терминала, а потом возвращает конкатенацию этих двух строк в качестве своего результата.

Выражение (++) <$> getLine <*> getLine имеет тип IO String. Это означает, что данное выражение является совершенно обычным действием ввода-вывода, как и любое другое, тоже возвращая результирующее значение, подобно другим действиям ввода-вывода. Вот почему мы можем выполнять следующие вещи:

main = do

   a <– (++) <$> getLine <*> getLine

   putStrLn $ "Две строки, соединённые вместе: " ++ a

 

Функции в качестве аппликативных функторов

Ещё одним экземпляром класса Applicative является тип (–>) r, или функции. Мы нечасто используем функции в аппликативном стиле, но концепция, тем не менее, действительно интересна, поэтому давайте взглянем, как реализован экземпляр функции.

instance Applicative ((–>) r) where

   pure x = (\_ –> x)

   f <*> g = \x –> f x (g x)

Когда мы оборачиваем значение в аппликативное значение с помощью функции pure, результат, который оно возвращает, должен быть этим значением. Минимальный контекст по умолчанию по-прежнему возвращает это значение в качестве результата. Вот почему в реализации экземпляра функция pure принимает значение и создаёт функцию, которая игнорирует передаваемый ей параметр и всегда возвращает это значение. Тип функции pure для экземпляра типа (–>) r выглядит как pure :: a –> (r –> a).

ghci> (pure 3) "ля"

3

Из-за каррирования применение функции левоассоциативно, так что мы можем опустить скобки:

ghci> pure 3 "ля"

3

Реализация экземпляра <*> немного загадочна, поэтому давайте посмотрим, как использовать функции в качестве аппликативных функторов в аппликативном стиле:

ghci> :t (+) <$> (+3) <*> (*100)

(+) <$> (+3) <*> (*100) :: (Num a) => a –> a

ghci> (+) <$> (+3) <*> (*100) $ 5

508

Вызов оператора <*> с двумя аппликативными значениями возвращает аппликативное значение, поэтому если мы вызываем его с двумя функциями, то получаем функцию. Что же здесь происходит? Когда мы выполняем (+) <$> (+3) <*> (*100), мы создаём функцию, которая применит оператор + к результатам выполнения функций (+3) и (*100) и вернёт это значение. При вызове выражения (+) <$> (+3) <*> (*100) $ 5 функции (+3) и (*100) сначала применяются к значению 5, что в результате даёт 8 и 500; затем оператор + вызывается со значениями 8 и 500, что в результате даёт 508.

Следующий код аналогичен:

ghci> (\x y z –> [x,y,z]) <$> (+3) <*> (*2) <*> (/2) $ 5

[8.0,10.0,2.5]

Мы создаём функцию, которая вызовет функцию \x y z –> [x, y, z] с окончательными результатами выполнения, возвращёнными функциями (+3), (*2) и (/2). Значение 5 передаётся каждой из трёх функций, а затем с этими результатами вызывается анонимная функция \x y z –> [x, y, z].

ПРИМЕЧАНИЕ. Не так уж важно, поняли ли вы, как работает экземпляр типа (–>) r для класса Applicative , так что не отчаивайтесь, если вам это пока не ясно. Поработайте с аппликативным стилем и функциями, чтобы получить некоторое представление о том, как использовать функции в качестве аппликативных функторов.

 

Застёгиваемые списки

Оказывается, есть и другие способы для списков быть аппликативными функторами. Один способ мы уже рассмотрели: вызов оператора <*> со списком функций и списком значений, который возвращает список всех возможных комбинаций применения функций из левого списка к значениям в списке справа.

Например, если мы выполним [(+3),(*2)] <*> [1,2], то функция (+3) будет применена и к 1, и к 2; функция (*2) также будет применена и к 1, и к 2, а результатом станет список из четырёх элементов: [4,5,2,4]. Однако [(+3),(*2)] <*> [1,2] могла бы работать и таким образом, чтобы первая функция в списке слева была применена к первому значению в списке справа, вторая была бы применена ко второму значению и т. д. Это вернуло бы список с двумя значениями: [4,4]. Вы могли бы представить его как [1 + 3, 2 * 2].

Экземпляром класса Applicative, с которым мы ещё не встречались, является тип ZipList, и находится он в модуле Control.Applicative.

Поскольку один тип не может иметь два экземпляра для одного и того же класса типов, был введён тип ZipList a, в котором имеется один конструктор (ZipList) с единственным полем (список). Вот так определяется его экземпляр:

instance Applicative ZipList where

   pure x = ZipList (repeat x)

   ZipList fs <*> ZipList xs = ZipList (zipWith (\f x –> f x) fs xs)

Оператор <*> применяет первую функцию к первому значению, вторую функцию – ко второму значению, и т. д. Это делается с помощью выражения zipWith (\f x –> f x) fs xs. Ввиду особенностей работы функции zipWith окончательный список будет той же длины, что и более короткий список из двух.

Функция pure здесь также интересна. Она берёт значение и помещает его в список, в котором это значение просто повторяется бесконечно. Выражение pure "ха-ха" вернёт ZipList (["ха-ха","ха-ха","ха-ха"… Это могло бы сбить с толку, поскольку вы узнали, что функция pure должна помещать значение в минимальный контекст, который по-прежнему возвращает данное значение. И вы могли бы подумать, что бесконечный список чего-либо едва ли является минимальным. Но это имеет смысл при использовании застёгиваемых списков, так как значение должно производиться в каждой позиции. Это также удовлетворяет закону о том, что выражение pure f <*> xs должно быть эквивалентно выражению fmap f xs. Если бы вызов выражения pure 3 просто вернул ZipList [3], вызов pure (*2) <*> ZipList [1,5,10] дал бы в результате ZipList [2], потому что длина результирующего списка из двух застёгнутых списков равна длине более короткого списка из двух. Если мы застегнём конечный список с бесконечным, длина результирующего списка всегда будет равна длине конечного списка.

Так как же застёгиваемые списки работают в аппликативном стиле? Давайте посмотрим.

Ладно, тип ZipList a не имеет экземпляра класса Show, поэтому мы должны использовать функцию getZipList для извлечения обычного списка из застёгиваемого:

ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100,100]

[101,102,103]

ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100..]

[101,102,103]

ghci> getZipList $ max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]

[5,3,3,4]

ghci> getZipList $ (,,) <$> ZipList "пар" <*> ZipList "ток" <*> ZipList "вид"

[('п','т','в'),('а','о','и'),('р',кt','д')]

ПРИМЕЧАНИЕ. Функция (,,) – это то же самое, что и анонимная функция \x y z –> (x,y,z) . В свою очередь, функция (,) – то же самое, что и \x y –> (x,y) .

Помимо функции zipWith в стандартной библиотеке есть такие функции, как zipWith3, zipWith4, вплоть до 7. Функция zipWith берёт функцию, которая принимает два параметра, и застёгивает с её помощью два списка. Функция zipWith3 берёт функцию, которая принимает три параметра, и застёгивает с её помощью три списка, и т. д. При использовании застёгиваемых списков в аппликативном стиле нам не нужно иметь отдельную функцию застёгивания для каждого числа списков, которые мы хотим застегнуть друг с другом. Мы просто используем аппликативный стиль для застёгивания произвольного количества списков при помощи функции, и это очень удобно.

 

Аппликативные законы

Как и в отношении обычных функторов, применительно к аппликативным функторам действует несколько законов. Самый главный состоит в том, чтобы выполнялось тождество pure f <*> x = fmap f x. В качестве упражнения можете доказать выполнение этого закона для некоторых аппликативных функторов из этой главы. Ниже перечислены другие аппликативные законы:

• pure id <*> v = v

• pure (.) <*> u <*> v <*> w = u <*> (v <*> w)

• pure f <*> pure x = pure (f x)

• u <*> pure y = pure ($ y) <*> u

Мы не будем рассматривать их подробно, потому что это заняло бы много страниц и было бы несколько скучно. Если вам интересно, вы можете познакомиться с этими законами поближе и посмотреть, выполняются ли они для некоторых экземпляров.

 

Полезные функции для работы с аппликативными функторами

Модуль Control.Applicative определяет функцию, которая называется liftA2 и имеет следующий тип:

liftA2 :: (Applicative f) => (a –> b –> c) –> f a –> f b –> f c

Она определена вот так:

liftA2 :: (Applicative f) => (a –> b –> c) –> f a –> f b –> f c

liftA2 f a b = f <$> a <*> b

Она просто применяет функцию между двумя аппликативными значениями, скрывая при этом аппликативный стиль, который мы обсуждали. Однако она ясно демонстрирует, почему аппликативные функторы более мощны по сравнению с обычными.

При использовании обычных функторов мы можем просто отображать одно значение функтора с помощью функций. При использовании аппликативных функторов мы можем применять функцию между несколькими значениями функторов. Интересно также рассматривать тип этой функции в виде (a –> b –> c) –> (f a –> f b –> f c). Когда мы его воспринимаем подобным образом, мы можем сказать, что функция liftA2 берёт обычную бинарную функцию и преобразует её в функцию, которая работает с двумя аппликативными значениями.

Есть интересная концепция: мы можем взять два аппликативных значения и свести их в одно, которое содержит в себе результаты этих двух аппликативных значений в списке. Например, у нас есть значения Just 3 и Just 4. Предположим, что второй функтор содержит одноэлементный список, так как этого очень легко достичь:

ghci> fmap (\x –> [x]) (Just 4)

Just [4]

Хорошо, скажем, у нас есть значения Just 3 и Just [4]. Как нам получить Just [3,4]? Это просто!

ghci> liftA2 (:) (Just 3) (Just [4])

Just [3,4]

ghci> (:) <$> Just 3 <*> Just [4]

Just [3,4]

Вспомните, что оператор : – это функция, которая принимает элемент и список и возвращает новый список с этим элементом в начале. Теперь, когда у нас есть значение Just [3,4], могли бы ли мы объединить это со значением Just 2, чтобы произвести результат Just [2,3,4]? Да, могли бы. Похоже, мы можем сводить любое количество аппликативных значений в одно, которое содержит список результатов этих аппликативных значений.

Давайте попробуем реализовать функцию, которая принимает список аппликативных значений и возвращает аппликативное значение, которое содержит список в качестве своего результирующего значения. Назовём её sequenceA:

sequenceA :: (Applicative f) => [f a] –> f [a]

sequenceA [] = pure []

sequenceA (x:xs) = (:) <$> x <*> sequenceA xs

А-а-а, рекурсия! Прежде всего смотрим на тип. Он трансформирует список аппликативных значений в аппликативное значение со списком. После этого мы можем заложить некоторую основу для базового случая. Если мы хотим превратить пустой список в аппликативное значение со списком результатов, то просто помещаем пустой список в контекст по умолчанию. Теперь в дело вступает рекурсия. Если у нас есть список с «головой» и «хвостом» (вспомните, x – это аппликативное значение, а xs – это список, состоящий из них), мы вызываем функцию sequenceA с «хвостом», что возвращает аппликативное значение со списком внутри него. Затем мы просто предваряем значением, содержащимся внутри аппликативного значения x, список, находящийся внутри этого аппликативного значения, – вот именно!

Предположим, мы выполняем:

sequenceA [Just 1, Just 2]

По определению такая запись эквивалентна следующей:

(:) <$> Just 1 <*> sequenceA [Just 2]

Разбивая это далее, мы получаем:

(:) <$> Just 1 <*> ((:) <$> Just 2 <*> sequenceA [])

Мы знаем, что вызов выражения sequenceA [] оканчивается в виде Just [], поэтому данное выражение теперь выглядит следующим образом:

(:) <$> Just 1 <*> ((:) <$> Just 2 <*> Just [])

что аналогично этому:

(:) <$> Just 1 <*> Just [2]

…что равно Just [1,2]!

Другой способ реализации функции sequenceA – использование свёртки. Вспомните, что почти любая функция, где мы проходим по списку элемент за элементом и попутно накапливаем результат, может быть реализована с помощью свёртки:

sequenceA :: (Applicative f) => [f a] –> f [a]

sequenceA = foldr (liftA2 (:)) (pure [])

Мы проходим список с конца, начиная со значения аккумулятора равного pure []. Мы применяем функцию liftA2 (:) между аккумулятором и последним элементом списка, что даёт в результате аппликативное значение, содержащее одноэлементный список. Затем мы вызываем функцию liftA2 (:) с текущим в данный момент последним элементом и текущим аккумулятором и т. д., до тех пор пока у нас не останется только аккумулятор, который содержит список результатов всех аппликативных значений.

Давайте попробуем применить нашу функцию к каким-нибудь аппликативным значениям:

ghci> sequenceA [Just 3, Just 2, Just 1]

Just [3,2,1]

ghci> sequenceA [Just 3, Nothing, Just 1]

Nothing

ghci> sequenceA [(+3),(+2),(+1)] 3

[6,5,4]

ghci> sequenceA [[1,2,3],[4,5,6]]

[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]

ghci> sequenceA [[1,2,3],[4,5,6],[3,4,4],[]]

[]

При использовании со значениями типа Maybe функция sequenceA создаёт значение типа Maybe, содержащее все результаты в виде списка. Если одно из значений равно Nothing, результатом тоже является Nothing. Это просто расчудесно, когда у вас есть список значений типа Maybe и вы заинтересованы в значениях, только когда ни одно из них не равно Nothing!

В применении к функциям sequenceA принимает список функций и возвращает функцию, которая возвращает список. В нашем примере мы создали функцию, которая приняла число в качестве параметра и применила его к каждой функции в списке, а затем вернула список результатов. Функция sequenceA [(+3),(+2),(+1)] 3 вызовет функцию (+3) с параметром 3, (+2) – с параметром 3 и (+1) – с параметром 3 и вернёт все эти результаты в виде списка.

Выполнение выражения (+) <$> (+3) <*> (*2) создаст функцию, которая принимает параметр, передаёт его и функции (+3) и (*2), а затем вызывает оператор + с этими двумя результатами. Соответственно, есть смысл в том, что выражение sequenceA [(+3),(*2)] создаёт функцию, которая принимает параметр и передаёт его всем функциям в списке. Вместо вызова оператора + с результатами функций используется сочетание : и pure [] для накопления этих результатов в список, который является результатом этой функции.

Использование функции sequenceA полезно, когда у нас есть список функций и мы хотим передать им всем один и тот же ввод, а затем просмотреть список результатов. Например, у нас есть число и нам интересно, удовлетворяет ли оно всем предикатам в списке. Вот один из способов это сделать:

ghci> map (\f –> f 7) [(>4),(<10),odd]

[True,True,True]

ghci> and $ map (\f –> f 7) [(>4),(<10),odd]

True

Вспомните, что функция and принимает список значений типа Bool и возвращает значение True, если все они равны True. Ещё один способ достичь такого же результата – применение функции sequenceA:

ghci> sequenceA [(>4),(<10),odd] 7

[True,True,True]

ghci> and $ sequenceA [(>4),(<10),odd] 7

True

Выражение sequenceA [(>4),(<10),odd] создаёт функцию, которая примет число, передаст его всем предикатам в списке [(>4),(<10),odd] и вернёт список булевых значений. Она превращает список с типом (Num a) => [a –> Bool] в функцию с типом (Num a) => a –> [Bool]. Правда, клёво, а?

Поскольку списки однородны, все функции в списке должны быть одного и того же типа, конечно же. Вы не можете получить список вроде [ord, (+3)], потому что функция ord принимает символ и возвращает число, тогда как функция (+3) принимает число и возвращает число.

При использовании со значением [] функция sequenceA принимает список списков и возвращает список списков. На самом деле она создаёт списки, которые содержат все комбинации находящихся в них элементов. Проиллюстрируем это предыдущим примером, который выполнен с применением функции sequenceA, а затем с помощью генератора списков:

ghci> sequenceA [[1,2,3],[4,5,6]]

[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]

ghci> [[x,y] | x <– [1,2,3], y <– [4,5,6]]

[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]

ghci> sequenceA [[1,2],[3,4]]

[[1,3],[1,4],[2,3],[2,4]]

ghci> [[x,y] | x <– [1,2], y <– [3,4]]

[[1,3],[1,4],[2,3],[2,4]]

ghci> sequenceA [[1,2],[3,4],[5,6]]

[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]

ghci> [[x,y,z] | x <– [1,2], y <– [3,4], z <– [5,6]]

[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]

Выражение (+) <$> [1,2] <*> [4,5,6] возвращает в результате недетерминированное вычисление x + y, где образец x принимает каждое значение из [1,2], а y принимает каждое значение из [4,5,6]. Мы представляем это в виде списка, который содержит все возможные результаты. Аналогичным образом, когда мы выполняем выражение sequenceA [[1,2],[3,4],[5,6]], результатом является недетерминированное вычисление [x,y,z], где образец x принимает каждое значение из [1,2], а y – каждое значение из [3,4] и т. д. Для представления результата этого недетерминированного вычисления мы используем список, где каждый элемент в списке является одним возможным списком. Вот почему результатом является список списков.

При использовании с действиями ввода-вывода функция sequenceA представляет собой то же самое, что и функция sequence! Она принимает список действий ввода-вывода и возвращает действие ввода-вывода, которое выполнит каждое из этих действий и в качестве своего результата будет содержать список результатов этих действий ввода-вывода. Так происходит, потому что чтобы превратить значение [IO a] в значение IO [a], чтобы создать действие ввода-вывода, возвращающее список результатов при выполнении, все эти действия ввода-вывода должны быть помещены в последовательность, а затем быть выполненными одно за другим, когда потребуется результат выполнения. Вы не можете получить результат действия ввода-вывода, не выполнив его!

Давайте поместим три действия ввода-вывода getLine в последовательность:

ghci> sequenceA [getLine, getLine, getLine]

эй

хо

ух

["эй","хо","ух"]

В заключение отмечу, что аппликативные функторы не просто интересны, но и полезны. Они позволяют нам объединять разные вычисления – как, например, вычисления с использованием ввода-вывода, недетерминированные вычисления, вычисления, которые могли окончиться неуспешно, и т. д., – используя аппликативный стиль. Просто с помощью операторов <$> и <*> мы можем применять обычные функции, чтобы единообразно работать с любым количеством аппликативных функторов и использовать преимущества семантики каждого из них.