Прежде чем мы пойдем дальше, я должен признаться: я кое-что умолчал о методе of
, который мы поместили в каждый из наших типов. На самом деле он нужен не для того, чтобы избежать использования ключевого слова new
, а для того, чтобы поместить значения в так называемый минимальный контекст определённого функтора (чистый контекст, default minimal context). Да-да, of
не имеет своей целью заменить конструктор — вместо этого он является частью важного интерфейса Pointed.
pointed функтор — это функтор, для которого реализована функция
of
— то есть операция помещения в минимальный контекст (метод это будет или функция — не существенно — прим. пер.).
Здесь важна возможность вбросить любое значение в наш тип и начать map
ить.
IO.of('tetris').map(concat(' master'));
// IO('tetris master')
Maybe.of(1336).map(add(1));
// Maybe(1337)
Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])
Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')
Если вы помните, конструкторы значений IO
и Task
ожидают в качестве аргумента функцию, но Maybe
и Either
этого не требуют. Назначение интерфейса Pointed — предоставить обобщённый и унифицированный способ поместить значение в какой-либо функтор, не требуя знания особенностей его конструкторов. Пусть термин «минимальный контекст» звучит не слишком точно, зато он хорошо отражает идею: мы хотели бы иметь возможность поднимать на уровень нужного типа любое произвольное значение, производить вычисления, применяя map
, и получать предсказуемый и ожидаемый результат c любым функтором.
Сейчас я должен сделать важное замечание о реализации of
. Например, реализовывать Left.of
не имеет никакого смысла. У каждого pointed функтора должен быть единственный универсальный способ поместить значение внутрь него, и в случае с Either
, of
будет реализован как new Right(x)
. Мы определяем of
через Right
, чтобы к произведённому значению map
действительно применял функции, ничего не пропуская. Из приведённых примеров нам должно быть интуитивно понятно, как будет работать of
, а Left
такое поведение обеспечить не сможет.
Вы могли слышать о таких функциях, как pure
,point
, unit
и return
. Так вот, все они являются синонимами of
. Важность of
вы оцените, когда мы начнём использовать монады, потому что нам придётся часто возвращать значение в контекст вручную.
Для того, чтобы обходиться без ключевого слова new
, в JavaScript существует ряд стандартных трюков/инструментов, но ставить of
в один ряд с ними мы не будем, потому что его предназначение отличается. Что же касается библиотечных реализаций функторов, то я рекомендую folktale
, ramda
и спецификацию fantasy-land
, поскольку они предоставляют корректную реализацию of
и конструкторы, которые не рассчитывают на new
.
Стоит также обратить внимание на серьёзнейшую библиотеку sanctuary
, разработанную David Chambers (активным контрибьютором ramda
и fantasy-land
), и библиотеку fp-ts
. С ними можно извлечь максимальную пользу из идей, изложенных в данной книге — прим. пер.
Видите ли, в дополнение к буррито (если до вас доходили слухи), монады похожи на лук. Позвольте продемонстрировать типовую ситуацию:
const fs = require('fs');
// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));
// print :: String -> IO String
const print = x => new IO(() => {
console.log(x);
return x;
});
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))
Так у нас получилась IO
, пойманная внутрь другой IO
, поскольку функция print
привнесла ещё один слой IO
(что разумно, ведь это «эффектное» действие), когда применялась с map
. И теперь, чтобы добраться до полученной строки, нам нужно делать map(map(f))
, а чтобы выполнить эти эффекты, нам придётся сделать unsafePerformIO().unsafePerformIO()
.
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);
catFirstChar('.git/config');
// IO(IO('['))
Хоть это и удобно — иметь два эффекта, упакованных и готовых к использованию, но сейчас это напоминает работу в двух костюмах, и API получается ужасно неудобным. Давайте рассмотрим ещё одну ситуацию:
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp('street'))),
map(safeHead),
safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))
Мы снова наблюдаем эту ситуацию с вложенными функторами, в которой обрабатываются 3 различных причины неудачи, но было бы самонадеянно ожидать, что клиентский код будет применять map
трижды, пользуясь результатом работы нашей функции. Такие ситуации будут складываться раз за разом, и именно они порождают для нас потребность в монадах.
Я заявил, что монады похожи на лук, потому что слёзы текут ручьём, когда мы снимаем очередной слой с вложенных друг в друга функторов, пытаясь выполнять свою работу с помощью map
. Пора вытереть глаза, сделать глубокий вдох и воспользоваться join
.
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))
mmo.join();
// Maybe('nunchucks')
const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))
ioio.join();
// IO('pizza')
const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));
ttt.join();
// Task(Task('sewers'))
Если у нас есть два слоя одного типа, мы можем объединить их в один при помощи join
. Эта способность соединяться вместе (такое «функторное бракосочетание») делает монаду монадой. Давайте сформулируем это чуть более аккуратно:
Монады — это pointed функторы, которые могут быть выровнены (flatten).
Любой функтор, для которого определены операции join
и of
, и который подчиняется нескольким законам, является монадой. Определение join
совсем несложное. Давайте напишем его для Maybe
:
Maybe.prototype.join = function join() {
return this.isNothing() ? Maybe.of(null) : this.$value;
};
Если наш Maybe(Maybe(x))
представляет собой Just(Nothing)
или Just(Just(x))
, то мы возвращаем в качестве результата то, что содержится в .$value
, чем бы оно ни было (оно будет либо Nothing
, либо Just(x)
соответственно). В противном случае, если поверхностный слой — это Nothing
, то и объединять там нечего, и мы возвращаем его как есть.
Теперь, когда у нас есть join
, давайте бросим щепотку волшебного монадного пороха в пример firstAddressStreet
и посмотрим, что из этого выйдет:
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();
// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead), safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})
Мы добавили join
везде, где нам встретились вложенные Maybe
, чтобы вложенность не выходила из-под контроля. Давайте проделаем то же самое с IO
, чтобы чувствовать себя уверенно.
IO.prototype.join = () => this.unsafePerformIO();
Как и в прошлый раз, мы просто снимаем один слой. Уверяю, мы не обращаем нашу функцию в нечистую, а просто удаляем один избыточный слой.
// log :: a -> IO a
const log = x => IO.of(() => {
console.log(x);
return x;
});
// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
curry((sel, props) => new IO(() => jQuery(sel).css(props)));
// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));
// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>
getItem
возвращает IO String
, и мы применяем map
, чтобы распарсить результат. Функции log
и setStyle
тоже возвращают IO
, поэтому мы должны делать join
, чтобы держать вложенность под контролем.
Вы наверняка заметили повторяющийся приём: мы применяем join
сразу после map
. Давайте абстрагируем это в функцию chain
.
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// или
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));
Мы просто композируем map
и join
в одну функцию. Если ранее вам доводилось читать о монадах, то вы встречали chain
под именем flatMap
или >>=
(читается как bind
), которые являются псевдонимами для одного и того же понятия — «монадического связывания». Я считаю, что flatMap
— это самое точное название, но мы будем придерживаться chain
, поскольку такое название более распространено в JS. Давайте отрефакторим предыдущие примеры, используя chain
:
// map/join
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead),
safeProp('addresses'),
);
// chain
const firstAddressStreet = compose(
chain(safeProp('street')),
chain(safeHead),
safeProp('addresses'),
);
// map/join
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
// chain
const applyPreferences = compose(
chain(setStyle('#main')),
chain(log),
map(JSON.parse),
getItem,
);
Я заменил все сочетания map/join
нашей новой функцией chain
и тем самым навёл в коде порядок. Красивые строчки кода — это, конечно, хорошо, но chain
— это нечто большее, чем кажется на первый взгляд. Это скорее торнадо, нежели вакуум. Поскольку chain
легко вкладывает «эффектные» вычисления друг в друга, мы можем охватить такие понятия, как последовательное выполнение и присваивание значений в чистом функциональном стиле.
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
.chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);
// querySelector :: Selector -> IO DOM
querySelector('input.username')
.chain(({ value: uname }) => querySelector('input.email')
.chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`)));
// IO('Welcome Olivia prepare for spam at [email protected]');
Maybe.of(3)
.chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);
Maybe.of(null)
.chain(safeProp('address'))
.chain(safeProp('street'));
// Maybe(null);
Мы могли бы написать эти примеры с помощью compose
, но нам потребовалось бы несколько вспомогательных функций, и это всё равно подтолкнуло бы нас к переприсваиванию переменных, добираясь до них через замыкание. Вместо этого мы используем chain
, которая, кстати, может быть выведена из map
и join
для любого типа: t.prototype.chain = function(f) { return this.map(f).join(); }
. Мы могли бы также написать chain
вручную (например, если бы нам захотелось потешить себя иллюзией производительности). Но в таком случае нам придётся позаботиться о корректности своей реализации — она должна давать в точности такой же результат, как map
и join
. Интересный факт: мы можем «бесплатно» получить map
, если у нас уже есть реализация chain
, для этого нужно композировать применяемую функцию с of
, чтобы поместить значение обратно в контекст. Также, имея chain
, мы можем определить join
как chain(id)
. Это может напоминать игру в Холдем с магом-иллюзионистом, будто я просто достаю все эти комбинации из-за спины, но в функциональном программировании, как и в математике, все основные конструкции взаимосвязаны. Множество таких операций, опирающихся друг на друга, описаны в fantasyland — общепризнанной спецификации алгебраических типов данных для JavaScript.
Давайте всё-таки обсудим вышеприведённые примеры. В первом примере два Task
соединены в последовательность асинхронных действий — сначала получаем user
, а затем запрашиваем его друзей (по идентификатору этого пользователя). Мы используем chain
, чтобы не городить Task(Task([Friend]))
.
Далее мы используем querySelector
, чтобы собрать необходимые исходные данные и составить из них приветствие. Обратите внимание: у нас есть доступ и к uname
, и к email
в самой вложенной функции — это функциональное назначение переменных во всей красе (и никаких присваиваний). Поскольку IO
любезно предоставляет нам значение, то и новое значение мы должны вернуть в подобном обёрнутом виде — мы ведь не хотим нарушать доверие к IO
(а заодно и к нашей программе). IO.of
— идеальный инструмент для этого шага, вот почему Pointed является важной составляющей интерфейса Monad. Хотя на этом шаге мы могли бы выбрать map
и получить на выходе значение нужного типа:
querySelector('input.username').chain(({ value: uname }) =>
querySelector('input.email').map(({ value: email }) =>
`Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at [email protected]');
Ещё два примера используют Maybe
. Поскольку chain
реализован подобно map
, вычисление «засохнет» и не будет продолжено, если на каком-то шаге встретится Nothing
.
Не беспокойтесь, если эти примеры поначалу сложно понять. Поиграйте с ними. Ткните их палкой. Разберите их на части и соберите снова. Помните, что вам нужен map
для применения некоторой функции, если она возвращает обычное значение, и chain
, если она возвращает значение, обёрнутое в монаду того же типа. В следующей главе мы задействуем аппликативные функторы и рассмотрим полезные трюки, с которыми составлять и читать подобные выражения станет легко и приятно.
Напоминаю, что chain
не работает с двумя разными функторами, вложенными друг в друга. Для этого существует композиция функторов и трансформеры монад (композиция рассматривается в этой книге, а трансформеры монад — нет. Изучить эту тему можно в языке Haskell, а потом, при желании, сделать что-то похожее в JS — прим. пер.).
Программирование с контейнерами иногда может сбивать с толку. Иногда бывает сложно понять, на какой глубине находится вложенное значение, или сделать выбор между map
и chain
(а скоро мы изучим ещё больше функций для работы с контейнерами). Мы можем значительно улучшить свою производительность за счёт подходящих инструментов отладки. Для этого мы реализуем inspect
и узнаем, как организовать для себя подобие «стека», в который мы сможем бросать всё, что может пригодиться при отладке позднее. Хотя нередко разработчики обходятся без этого (потому что нет желания возиться).
Сейчас я взмахну огненным монадическим мечом и продемонстрирую силу такого стиля программирования.
Давайте прочитаем файл и сразу после этого загрузим его:
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: String -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);
Здесь мы разветвляем наш код несколько раз. Глядя на сигнатуры типов, я вижу, что мы защищаемся от 3 ошибок
readFile
используетEither
для проверки ввода (видимо, удостоверяясь, что имя файла было предоставлено).readFile
может завершиться с ошибкой при получении доступа к файлу, тогда это будет выражено типомError
- загрузка файла может быть прервана по любой причине, и это выражает тип
Error
вhttpPost
И при этом мы, как обычно, последовательно выполняем два вложенных асинхронных действия с помощьюchain
.
Всего этого мы добиваемся линейно, слева направо. Всё это чисто и декларативно, поддаётся эквациональным рассуждениям и обладает надёжными свойствами. Нам не приходится добавлять ненужные и запутанные имена переменных. Наша функция upload
написана для универсальных интерфейсов, а не под чей-то конкретный одноразовый API. Одной строкой кода.
Для контраста давайте рассмотрим типичную императивную реализацию того же самого:
// upload :: String -> (String -> a) -> Void
const upload = (filename, callback) => {
if (!filename) {
throw new Error('You need a filename!');
} else {
readFile(filename, (errF, contents) => {
if (errF) throw errF;
httpPost('/uploads', contents, (errH, json) => {
if (errH) throw errH;
callback(json);
});
});
}
};
Ну разве это не дьявольская арифметика? Какой-то пинбол в переменчивом лабиринте безумия. А теперь представьте, что это типичное реальное приложение, которое, помимо этой функции, мутирует переменные всюду. Мы бы действительно оказались в битумной яме.
Первый закон, который мы рассмотрим, это закон ассоциативности, хоть он, возможно, и выглядит непривычно.
// закон ассоциативности
compose(join, map(join)) === compose(join, join);
Эти законы нацелены на вложенную природу монад, поэтому ассоциативность проявляется в том, что порядок соединения слоёв — изнутри или снаружи — не имеет значения, и приводит к одному и тому же результату.
Обычно закон ассоциативности для монад записывается в другом виде, через chain
и несколько функций. Но пусть это вас не слишком смущает, так как join
— это chain(id)
— прим. пер.
Схема будет нагляднее:
Начиная с верхнего левого угла и двигаясь вниз, мы можем «соединить» сначала внешние два M
из M(M(M a))
, а затем перейти к желаемому M a
, ещё раз применив join
. Другой путь — соединить внутренние два M
«под капотом» с помощью map(join)
. В итоге мы получаем один и тот же M a
, независимо от того, соединяем ли мы сначала внутренние или внешние M
, и в этом вся суть ассоциативности. Стоит отметить, что map(join) != join
. Промежуточные шаги могут различаться по значению, но конечный результат последнего join
будет одним и тем же.
Второй закон похож:
// закон идентичности для всех (M a)
compose(join, of) === compose(join, map(of)) === id;
Этот закон гласит, что для любой монады M
последовательное применение of
и join
равны id
. Мы также можем вооружиться map(of)
и атаковать её изнутри. Некоторые называют этот закон «треугольной идентичностью», потому что он производит такую форму при визуализации:
Если мы отправимся из левого верхнего угла направо, то увидим, что of
помещает наш M a
в другой контейнер M
. Затем, если мы направимся вниз и соединим слои, то получим то же самое, как если бы сразу применили id
. Двигаясь справа налево, мы видим, что если мы прокрадёмся изнутри с помощью map
и применим of
к a
, мы всё равно получим M(M a)
, и join
вернёт всё на круги своя.
Важно понимать, что of
, о которой мы рассуждаем — это функция M.of
, которая для каждой монады M
реализована по-разному.
Постойте, где-то я уже видел эти законы идентичности и ассоциативности... Я думаю... Да, конечно! Это законы для категории. Значит, нам нужна функция композиции, чтобы завершить определение. Вот:
const mcompose = (f, g) => compose(chain(f), g);
// идентичность слева
mcompose(M.of, f) === f;
// идентичность справа
mcompose(f, M.of) === f;
// ассоциативность
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));
В конце концов, они являются законами категории. Монады образуют категорию, называемую «категорией Клейсли», в которой объекты — это монады, а морфизмы — монадические функции. Я не ставлю перед собой цель посмеяться над вами с помощью теории категорий без объяснения того, как кусочки головоломки сочетаются между собой. Моё намерение состоит в том, чтобы в достаточной степени поцарапать поверхность, показать релевантность и пробудить интерес, при этом не отрываться практических свойств, которыми мы с вами пользуемся ежедневно.
Идентичностью этот закон называется потому, что подчиняющиеся ему функции of
не должны делать ничего, кроме помещения значения в минимальный контекст монады, то есть, вести себя как тождественный морфизм в категории Клейсли, ведь chain(of)
— это id
— прим. пер.
Монады позволяют нам углубляться во вложенные вычисления. Мы можем назначать переменные, запускать последовательные эффекты, выполнять асинхронные задачи, и все это — не устраивая callback hell. Они приходят на помощь, когда значение оказывается заточённым в нескольких однотипных слоях. Благодаря верному помощнику «Pointed» монады могут предоставлять нам значение без упаковки и знают, что мы сможем сами завернуть его обратно, когда закончим свои дела.
Да, монады очень мощные, но нам потребуются ещё некоторые дополнительные функции для полноценной работы с контейнерами. Например, что если мы захотим запустить множество вызовов API за один раз, а затем собрать результаты? Мы можем выполнить эту задачу с помощью монад, но нам придётся ждать завершения каждого вызова, прежде чем перейти к следующему. А как насчёт объединения результатов нескольких валидаций? Если бы мы захотели продолжить проверку, чтобы собрать все возникшие ошибки, а монады остановили бы шоу после первого Left
?
В следующей главе мы увидим, какое место аппликативные функторы занимают в мире контейнеров, и почему во многих случаях мы предпочитаем их монадам.
Глава 10: Аппликативные функторы
Используя следующий объект пользователя:
const user = {
id: 1,
name: 'Albert',
address: {
street: {
number: 22,
name: 'Walnut St',
},
},
};
Используйте safeProp
и map/join
или chain
, чтобы безопасно получить название улицы из данных о пользователе.
// getStreetName :: User -> Maybe String
const getStreetName = undefined;
Используя следующие вспомогательные функции:
// getFile :: () -> IO String
const getFile = () => IO.of('/home/mostly-adequate/ch09.md');
// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));
Используйте getFile
, чтобы получить путь к файлу, удалить из него директорию и оставить только имя файла, а затем просто залогировать его. Подсказка: вы можете использовать split
и last
для получения имени из пути к файлу.
// logFilename :: IO ()
const logFilename = undefined;
В этом упражнении у нас будут вспомогательные функции с такими сигнатурами:
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()
Используйте validateEmail
, addToMailingList
и emailBlast
, чтобы создать функцию, которая добавляет новый адрес в список рассылки, если он действителен, и затем уведомляет весь список.
// joinMailingList :: Email -> Either String (IO ())
const joinMailingList = undefined;