Skip to content

Latest commit

 

History

History
368 lines (240 loc) · 26.3 KB

ch10-ru.md

File metadata and controls

368 lines (240 loc) · 26.3 KB

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

Аппликативная аппликация

Название аппликативный функтор (он же — applicative) замечательно выражает суть, учитывая функциональное происхождение. Функциональные программисты печально известны тем, что придумывают имена вроде mappend или liftA4, которые звучат совершенно естественно в математической лаборатории, но в любом другом контексте непонятны и страшны, как нерешительный Дарт Вейдер.

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

Теперь давайте поразмышляем: зачем такому нормальному рациональному человеку, как вы, может это понадобиться? И что это вообще означает — «применять один функтор к другому»?

Чтобы ответить на эти вопросы, мы рассмотрим ситуацию, с которой вы наверняка сталкивались в своём функциональном путешествии. Предположим, у нас есть два функтора (одного типа), и мы хотели бы применить функцию двух аргументов к их содержимому. Самый простой пример — получить сумму двух чисел, помещённых в Container.

// Мы не можем просто сложить их, потому что значения запакованы.
add(Container.of(2), Container.of(3));
// NaN

// Давайте попробуем проверенный map
const containerOfAdd2 = map(add, Container.of(2));
// Container(add(2))

Итак, мы добились того, что у нас есть Container(add(2)) — контейнер с частично применённой функцией внутри. Теперь нам хотелось бы применить add(2) внутри него к значению 3 внутри Container(3), чтобы получить нужный результат. Другими словами, нам нужно применить один функтор к другому.

В данном примере нам повезло, и у нас уже есть подходящие инструменты, чтобы завершить дело. Мы можем использовать chain, и затем с помощью map применить add

Container.of(2).chain(two => Container.of(3).map(add(two)));

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

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

Корабли в бутылках

https://www.deviantart.com/hollycarden

ap — это функция, которая может применять функцию из одного функтора к значению из другого.

Container.of(add(2)).ap(Container.of(3));
// Container(5)

// а теперь — все вместе

Container.of(2).map(add).ap(Container.of(3));
// Container(5)

Вот так, красиво и опрятно. Хорошая новость для Container(3) состоит в том, что он высвобожден из вложенной монадической функции. Стоит ещё раз упомянуть, что в данном примере add частично применяется посредством map, что сработает, только если функция add каррированная.

Мы можем определить ap для Container так:

Container.prototype.ap = function (otherContainer) {
  return otherContainer.map(this.$value);
};

Мы ожидаем, что this.$value будет функцией, а другой функтор мы получим в качестве аргумента, поэтому нам потребуется только применить map. Таким образом, мы готовы сформулировать определение:

Аппликативный функтор — это pointed функтор, для которого определена операция ap.

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

Я могу почувствовать ваш скептицизм (а может, даже растерянность и ужас), но прошу вас оставить предубеждения — ap докажет нам свою пригодность. Прежде чем мы перейдём к деталям, давайте рассмотрим полезное свойство:

F.of(x).map(f) === F.of(f).ap(F.of(x));

Мы можем сказать об этом так: поместить x в контейнер и применить map(f) — то же самое, что поднять и f и x на уровень контейнера и применить к ним ap. Это позволяет нам записать наш пример «слева направо»:

Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
// Maybe(5)

Task.of(add).ap(Task.of(2)).ap(Task.of(3));
// Task(5)

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

Вы заметили? Мы использовали Task в нашем примере. Это основная ситуация, когда аппликативные функторы демонстрируют свою мощь. Давайте рассмотрим более углублённый пример.

Порядок выполнения

Допустим, мы разрабатываем туристический сайт и хотели бы получить список туристических направлений и местных событий. Каждый из этих наборов данных не зависит от других, и будет получен отдельным вызовом API.

// Http.get :: String -> Task Error HTML

const renderPage = curry((destinations, events) => { /* render page */ });

Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'));
// Task("<div>some page with dest and events</div>")

Оба вызова Http будут произведены мгновенно, а renderPage будет вызван после получения результата обоих. Сравните это с монадической версией, в которой одна Task должна завершиться до запуска следующей. Поскольку нам не нужны туристические направления для того, чтобы запросить местные события, мы свободны от необходимости производить вычисления последовательно.

Опять же, поскольку мы используем частичное применение для достижения этого результата, мы должны убедиться, что функция renderPage — каррированная, иначе она не будет ждать завершения обеих Task. Кстати, если вам приходилось проделывать всё это вручную, вы по достоинству оцените удивительную простоту нашего подхода. Этот код прекрасен, он будто на шаг приближает нас к сингулярности.

Давайте рассмотрим другой пример.

// $ :: String -> IO DOM
const $ = selector => new IO(() => document.querySelector(selector));

// getVal :: String -> IO String
const getVal = compose(map(prop('value')), $);

// signIn :: String -> String -> Bool -> User
const signIn = curry((username, password, rememberMe) => { /* signing in */ });

IO.of(signIn).ap(getVal('#email')).ap(getVal('#password')).ap(IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })

signIn — это каррированная функция трёх аргументов, поэтому и ap мы должны применить трижды, предоставляя аргументы в нужном порядке (по одному с каждым применением ap). Вызов функции signIn произойдёт тогда, когда ей будут переданы все аргументы. Разумеется, мы можем применять этот подход с любым количеством аргументов. В этом примере два первых аргумента уже будут помещены в IO, а третий будет нуждаться в нашей помощи — мы должны поднять его в IO, поскольку ap ожидает, что все аргументы будут на одном уровне (это просто пример, а на практике нам, скорее всего, придётся обратиться к DOM за состоянием флага «remember me», и получить всё то же значение в IO- прим. пер.).

Сколько жмёшь, братишка?

Давайте изучим бесточечный способ написания этих аппликативных вызовов. Поскольку мы знаем, что map эквивалентно of/ap, мы можем написать универсальные функции, которые будут применять ap столько раз, сколько мы укажем:

const liftA2 = curry((g, f1, f2) => f1.map(g).ap(f2));

const liftA3 = curry((g, f1, f2, f3) => f1.map(g).ap(f2).ap(f3));

// liftA4, etc

liftA2 — странное имя. Оно звучит как название капризного грузового лифта на «уставшем» заводе, или как «не очень блатной» номер на «не очень дорогом» лимузине. Однако, когда всё прояснится, это имя будет говорить само за себя: «подними эту функцию в мир аппликативных функторов».

Когда я впервые увидел эти глупые цифры «2-3-4», они показались мне ужасными и ненужными. В конце концов, в JavaScript мы можем проверить арность функций и построить поднятую функцию нужной арности динамически. Тем не менее, часто бывает полезно частично применить liftA(N), поэтому она не может варьироваться по количеству аргументов.

Давайте посмотрим на это в действии:

// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String

const user = {
  name: 'John Doe',
  email: 'blurp_blurp',
};

//  createUser :: Email -> String -> IO User
const createUser = curry((email, name) => { /* creating... */ });

Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// Left('invalid email')

liftA2(createUser, checkEmail(user), checkName(user));
// Left('invalid email')

Поскольку createUser принимает 2 аргумента, мы используем liftA2. Оба способа записи эквивалентны, но версия с liftA2 не упоминает Either. Это делает её более универсальной и гибкой, поскольку мы не привязываем код к конкретному типу.

Давайте запишем по-новому и предыдущие примеры:

liftA2(add, Maybe.of(2), Maybe.of(3));
// Maybe(5)

liftA2(renderPage, Http.get('/destinations'), Http.get('/events'));
// Task('<div>some page with dest and events</div>')

liftA3(signIn, getVal('#email'), getVal('#password'), IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })

Инфиксные функции

В таких языках, как Haskell, Scala, PureScript и Swift, где распространены инфиксные функции, вы можете увидеть специальный синтаксис:

-- Haskell / PureScript
add <$> Right 2 <*> Right 3
// JavaScript
map(add, Right(2)).ap(Right(3));

Будет полезно знать, что <$> — это map (он же fmap), а <*> — это просто ap. Инфиксная нотация даёт применению функций более естественный стиль и требует меньше скобок.

Бесплатные открывашки

http://www.breannabeckmeyer.com/

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

Например, мы знаем, что аппликативный функтор прежде всего является функтором, поэтому, если мы определили, каким образом некий тип является applicative, то мы можем вывести из него functor.

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

Ранее я упоминал, что of/ap эквивалентно map. Мы можем использовать это знание, чтобы получить map бесплатно:

// map выведен из of/ap
X.prototype.map = function map(f) {
  return this.constructor.of(f).ap(this);
};

Монады находятся на вершине пищевой цепочки (если можно так выразиться), поэтому, если у нас есть chain, мы получаем functor и applicative бесплатно:

// map выведен из chain
X.prototype.map = function map(f) {
  return this.chain(a => this.constructor.of(f(a)));
};

// ap выведен из chain/map
X.prototype.ap = function ap(other) {
  return this.chain(f => other.map(f));
};

Если мы можем определить, каким образом некий тип является монадой, то сможем через это определить аппликативный и функторный интерфейсы. Это весьма примечательно, поскольку вместе с контейнером мы бесплатно получаем все эти «открывашки». Можно зайти ещё дальше — анализировать тип и автоматизировать этот процесс.

Следует отметить, что ap привлекателен своей способностью запускать вычисления одновременно, поэтому определение ap через chain будет неоптимальным. Тем не менее, с самого начала иметь работающую реализацию — хорошо, а наилучшая реализация ap может быть разработана при необходимости.

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

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

Теперь перейдём к юридической стороне вопроса...

Законы

Как и другие математические конструкции, которые мы исследовали, аппликативные функторы обладают некоторыми полезными свойствами, на которые мы можем положиться в повседневном коде. Прежде всего, следует знать, что аппликативные функторы «замкнуты относительно композиции», что означает, что ap никогда не изменит типы контейнеров (это ещё одна причина отдавать предпочтение аппликативным функторам). Но это не значит, что мы не можем иметь несколько разных эффектов — мы можем композировать наши типы, зная, что они останутся неизменными для всех применений.

Для демонстрации:

const tOfM = compose(Task.of, Maybe.of);

liftA2(liftA2(concat), tOfM('Rainy Days and Mondays'), tOfM(' always get me down'));
// Task(Maybe(Rainy Days and Mondays always get me down))

Как видите, не нужно беспокоиться о случаях, когда используется композиция типов.

Теперь давайте рассмотрим наш любимый категориальный закон — закон идентичности.

Идентичность

// идентичность (identity)
A.of(id).ap(v) === v;

Всё так, применение id внутри функтора не должно изменять значение в v. Например:

const v = Identity.of('Pillow Pets');
Identity.of(id).ap(v) === v;

Identity.of(id) заставляет меня смеяться над его тщетностью. Интересно отметить: поскольку of/ap — это то же самое, что и map, закон идентичности следует из идентичности для функтора: map(id) == id.

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

Гомоморфизм

// гомоморфизм (homomorphism)
A.of(f).ap(A.of(x)) === A.of(f(x));

Гомоморфизм — это просто отображение, сохраняющее структуру. Фактически, функтор — это просто гомоморфизм между категориями, поскольку он сохраняет структуру исходной категории при отображении.

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

Пример:

Either.of(toUpperCase).ap(Either.of('oreos')) === Either.of(toUpperCase('oreos'));

Interchange

Закон interchange гласит, что не имеет значения, решим мы поднять нашу функцию в левую или в правую часть относительно ap.

// interchange
v.ap(A.of(x)) === A.of(f => f(x)).ap(v);

Вот пример:

const v = Task.of(reverse);
const x = 'Sparklehorse';

v.ap(Task.of(x)) === Task.of(f => f(x)).ap(v);

Композиция

И, наконец, закон композиции, который является просто способом убедиться в том, что наша стандартная композиция функций сохраняется при применении внутри контейнеров.

// композиция (composition)
A.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));
const u = IO.of(toUpperCase);
const v = IO.of(concat('& beyond'));
const w = IO.of('blood bath ');

IO.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));

Итог

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

Мы почти закончили с контейнерными инструментами. Мы научились работать с map, chain и теперь ещё и с ap. В следующей главе мы узнаем, как лучше организовать работу с несколькими функторами, и рассмотрим их в другом ключе.

Глава 11: Опять преобразования, естественно

Упражнения

Упражнение A

Напишите функцию, которая суммирует два возможно отсутствующих числа, используя Maybe и ap.

// safeAdd :: Maybe Number -> Maybe Number -> Maybe Number
const safeAdd = undefined;

Упражнение B

Перепишите safeAdd из упражнения A, используя liftA2 вместо ap.

// safeAdd :: Maybe Number -> Maybe Number -> Maybe Number
const safeAdd = undefined;

Упражнение C

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

const localStorage = {
  player1: { id:1, name: 'Albert' },
  player2: { id:2, name: 'Theresa' },
};

// getFromCache :: String -> IO User
const getFromCache = x => new IO(() => localStorage[x]);

// game :: User -> User -> String
const game = curry((p1, p2) => `${p1.name} vs ${p2.name}`);

Напишите IO, которая получает player1 и player2 из кэша и запускает игру.

// startGame :: IO String
const startGame = undefined;