Примечание. Это часть серии s (теперь книга!) Программное обеспечение для создания текста, посвященное изучению функционального программирования и методов композиционного программного обеспечения на JavaScript ES6 + с нуля. . Будьте на связи. Впереди еще много всего!
‹ Предыдущая | ‹< Начать сначала с Части 1
Прежде чем вы начнете изучать монады, вы уже должны знать:
- Функциональный состав:
compose(f, g)(x) = (f ∘ g)(x) = f(g(x))
- Основы функторов: понимание операции с массивом
.map()
.
«Как только вы понимаете монады, вы сразу же становитесь неспособными объяснить их кому-либо еще» Проклятие леди Монадгрин ~ Гилад Браха (широко используется Дугласом Крокфордом)
«Доктор. Хёниккер имел обыкновение говорить, что любой ученый, который не мог объяснить восьмилетнему ребенку, что он делал, был шарлатаном ». ~ Роман Курта Воннегута "Кошачья колыбель"
Если вы поищете в Интернете слово «монада», вас засыпают непонятной математикой из теории категорий и кучей людей, «услужливо» объясняющих монады в терминах буррито и космических костюмов.
Монады просты. Это сложный жаргон. Перейдем к сути.
монада - это способ составления функций, которым в дополнение к возвращаемому значению требуется контекст, например вычисление, ветвление или ввод-вывод. Монады набирают, выравнивают и отображают так, чтобы типы выстраивались в линию для функций подъема a => M(b)
, что делает их компонуемыми. Это отображение от некоторого типа a
к некоторому типу b
вместе с некоторым вычислительным контекстом, скрытым в деталях реализации подъема, выравнивания и сопоставления:
- Карта функций:
a => b
- Сопоставление функторов с контекстом:
Functor(a) => Functor(b)
- Монады сглаживаются и сопоставляются с контекстом:
Monad(Monad(a)) => Monad(b)
Но что означают «сглаживание», «карта» и «контекст»?
- Карта означает «применить функцию к
a
и вернутьb
». Учитывая некоторый ввод, верните некоторый вывод. - Контекст - это вычислительная деталь композиции монады (включая подъем, выравнивание и отображение). API Functor / Monad и его работа предоставляют контекст, который позволяет вам составлять монаду с остальной частью приложения. Смысл функторов и монад состоит в том, чтобы абстрагироваться от этого контекста, чтобы нам не приходилось беспокоиться об этом, пока мы сочиняем вещи. Отображение внутри контекста означает, что вы применяете функцию из
a => b
к значению внутри контекста и возвращаете новое значениеb
, заключенное в тот же тип контекста. Наблюдаемые слева? Наблюдаемые справа:Observable(a) => Observable(b)
. Массивы слева? Массивы справа:Array(a) => Array(b)
. - Подъем типа означает поднять тип в контекст, снабдив значение API, который вы можете использовать для вычисления из этого значения, запуска контекстных вычислений и т. д.
a => F(a)
(Монады - это своего рода функтор). - Свести означает развернуть значение из контекста.
F(a) => a
.
Пример:
const x = 20; // Some data of type `a` const f = n => n * 2; // A function from `a` to `b` const arr = Array.of(x); // The type lift. // JS has type lift sugar for arrays: [x] // .map() applies the function f to the value x // in the context of the array. const result = arr.map(f); // [40]
В этом случае Array
- это контекст, а x
- это значение, которое мы отображаем.
Этот пример не включал массивы массивов, но вы можете сгладить массивы в JS с помощью .flat()
или .concat()
:
[[1], [2, 3], [4]].flat(); // [1, 2, 3, 4] or [].concat.apply([], [[1], [2, 3], [4]]); // [1, 2, 3, 4]
Вы, вероятно, уже используете монады.
Независимо от вашего уровня навыков или понимания теории категорий, использование монад упрощает работу с кодом. Неспособность воспользоваться преимуществами монад может усложнить работу с вашим кодом (например, ад обратных вызовов, вложенные условные ветки, большая многословность).
Помните, что суть разработки программного обеспечения - это композиция, а монады упрощают композицию. Взглянем еще раз на суть того, что такое монады:
- Карта функций:
a => b
, которая позволяет составлять функции типаa => b
. - Сопоставление функторов с контекстом:
Functor(a) => Functor(b)
, что позволяет составлять функцииF(a) => F(b)
- Монады сглаживаются и сопоставляются с контекстом:
Monad(Monad(a)) => Monad(b)
, что позволяет составлять функции подъемаa => F(b)
Все это просто разные способы выражения композиции функций. Вся причина существования функций в том, что вы можете их составлять. Функции помогают разбить сложные проблемы на простые, которые легче решить изолированно, так что вы можете составлять их различными способами для формирования приложения.
Ключом к пониманию функций и их правильного использования является более глубокое понимание композиции функций.
Композиция функций создает конвейеры функций, через которые проходят ваши данные. Вы вводите некоторый ввод в первый этап конвейера, а некоторые данные появляются из последнего этапа конвейера, преобразованные. Но для того, чтобы это работало, каждый этап конвейера должен ожидать тип данных, возвращаемый предыдущим этапом.
Составлять простые функции легко, потому что все типы легко выстраиваются в линию. Просто сопоставьте тип вывода b
с типом ввода b
, и вы в деле:
g: a => b f: b => c h = f(g(a)): a => c
Составление с помощью функторов также легко, если вы сопоставляете F(a) => F(b)
, потому что типы совпадают:
g: F(a) => F(b) f: F(b) => F(c) h = f(g(Fa)): F(a) => F(c)
Но если вы хотите составлять функции из a => F(b)
, b => F(c)
и т. Д., Вам нужны монады. Давайте заменим F()
на M()
, чтобы прояснить это:
g: a => M(b) f: b => M(c) h = composeM(f, g): a => M(c)
Ой. В этом примере типы функций компонентов не совпадают! Для ввода f
нам нужен тип b
, но мы получили тип M(b)
(монада b
). Из-за этого несовпадения composeM()
необходимо развернуть M(b)
, который возвращает g
, чтобы мы могли передать его f
, потому что f
ожидает тип b
, а не тип M(b)
. В этом процессе (часто называемом .bind()
или .chain()
) выполняются сглаживание и сопоставление.
Он разворачивает b
из M(b)
перед передачей его следующей функции, что приводит к следующему:
g: a => M(b) flattens to => b f: b maps to => M(c) h composeM(f, g): a flatten(M(b)) => b => map(b => M(c)) => M(c)
Монады выстраивают типы для подъемных функций a => M(b)
, чтобы вы могли их скомпоновать.
На приведенной выше диаграмме flatten
из M(b) => b
и карта из b => M(c)
находятся внутри chain
из a => M(c)
. Вызов chain
обрабатывается внутри composeM()
. На высоком уровне вам не о чем беспокоиться. Вы можете просто составлять функции, возвращающие монаду, используя тот же API, который вы использовали бы для составления обычных функций.
Монады необходимы, потому что многие функции не являются простыми отображениями из a => b
. Некоторым функциям необходимо иметь дело с побочными эффектами (обещания, потоки), обрабатывать ветвление (Возможно), обрабатывать исключения (Либо) и т. Д.
Вот более конкретный пример. Что, если вам нужно получить пользователя из асинхронного API, а затем передать эти данные пользователя другому асинхронному API для выполнения некоторых вычислений ?:
getUserById(id: String) => Promise(User) hasPermision(User) => Promise(Boolean)
Напишем несколько функций, чтобы продемонстрировать проблему. Во-первых, утилиты compose()
и trace()
:
const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x); const trace = label => value => { console.log(`${ label }: ${ value }`); return value; };
Затем нужно составить несколько функций:
{ const label = 'API call composition'; // a => Promise(b) const getUserById = id => id === 3 ? Promise.resolve({ name: 'Kurt', role: 'Author' }) : undefined ; // b => Promise(c) const hasPermission = ({ role }) => ( Promise.resolve(role === 'Author') ); // Try to compose them. Warning: this will fail. const authUser = compose(hasPermission, getUserById); // Oops! Always false! authUser(3).then(trace(label)); }
Когда мы пытаемся составить hasPermission()
с getUserById()
для формирования authUser()
, мы сталкиваемся с большой проблемой, потому что hasPermission()
ожидает объект User
и вместо этого получает Promise(User)
. Чтобы исправить это, нам нужно заменить compose()
на composePromises()
- специальную версию compose, которая знает, что ей нужно использовать .then()
для выполнения композиции функций:
{ const composeM = chainMethod => (...ms) => ( ms.reduce((f, g) => x => g(x)[chainMethod](f)) ); const composePromises = composeM('then'); const label = 'API call composition'; // a => Promise(b) const getUserById = id => id === 3 ? Promise.resolve({ name: 'Kurt', role: 'Author' }) : undefined ; // b => Promise(c) const hasPermission = ({ role }) => ( Promise.resolve(role === 'Author') ); // Compose the functions (this works!) const authUser = composePromises(hasPermission, getUserById); authUser(3).then(trace(label)); // true }
Мы поговорим о том, что делает composeM()
позже.
Помните суть монад:
- Карта функций:
a => b
- Сопоставление функторов с контекстом:
Functor(a) => Functor(b)
- Монады сглаживаются и сопоставляются с контекстом:
Monad(Monad(a)) => Monad(b)
В этом случае наши монады действительно являются обещаниями, поэтому, когда мы составляем эти функции, возвращающие обещания, у нас есть Promise(User)
вместо User
, ожидаемого hasPermission()
. Обратите внимание: если вы уберете внешнюю Monad()
оболочку с Monad(Monad(a))
, у вас останется Monad(a) => Monad(b)
, который является обычным функтором .map()
. Если бы у нас было что-то, что могло бы сгладить Monad(x) => x
, мы были бы в бизнесе.
Из чего сделаны монады
Монада основана на простой симметрии - способ обернуть значение в контекст и способ развернуть значение из контекста:
- Подъем / Единица: Подъем типа из некоторого типа в контекст монады:
a => M(a)
- Сведение / объединение: разворачивание типа из контекста:
M(a) => a
А поскольку монады также являются функторами, они также могут отображать:
- Карта: карта с сохраненным контекстом:
M(a) -> M(b)
Совместите flatten с map, и вы получите цепочку - композицию функций для монад-подъемных функций, также известную как композиция Kleisli, названная в честь Heinrich Kleisli:
- FlatMap / Chain: Flatten + map:
M(M(a)) => M(b)
Для монад .map()
методы часто не включаются в общедоступный API. .map()
.map()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ Если вы можете поднять (он же / unit) и связать (он же bind / flatMap), вы можете сделать .map()
:
const MyMonad = value => ({ // <... insert arbitrary chain and of here ...> map (f) { return this.chain(a => this.constructor.of(f(a))); } });
Итак, если вы определяете .of()
и _89 _ / _ 90_ для своей монады, вы можете вывести определение .map()
.
Лифт - это завод / конструктор и / или constructor.of()
метод. В теории категорий это называется «единицей». Все, что он делает, - это переносит тип в контекст монады. Это превращает a
в Monad
из a
.
В Haskell он (очень сбивающий с толку) называется return
, что сильно сбивает с толку, когда вы пытаетесь говорить об этом вслух, потому что почти все путают это с возвратом функции. Я почти всегда называю это «лифтом» или «лифтом типа» в прозе и .of()
в коде.
Этот процесс выравнивания (без карты в .chain()
) обычно называется flatten()
или join()
. Часто (но не всегда) _101 _ / _ 102_ полностью опускается, потому что он встроен в .chain()/.flatMap()
. Сглаживание часто ассоциируется с композицией, поэтому его часто комбинируют с отображением. Помните, что развертывание + карта необходимы для составления a => M(a)
функций.
В зависимости от того, с какой монадой вы имеете дело, процесс развертывания может быть чрезвычайно простым. В случае монады идентичности это похоже на .map()
, за исключением того, что вы не поднимаете результирующее значение обратно в контекст монады. Это приводит к отказу от одного слоя обертывания:
{ // Identity monad const Id = value => ({ // Functor mapping // Preserve the wrapping for .map() by // passing the mapped value into the type // lift: map: f => Id.of(f(value)), // Monad chaining // Discard one level of wrapping // by omitting the .of() type lift: chain: f => f(value), // Just a convenient way to inspect // the values: toString: () => `Id(${ value })` }); // The type lift for this monad is just // a reference to the factory. Id.of = Id;
Но в части развертывания обычно скрываются такие странные вещи, как побочные эффекты, ветвление ошибок или ожидание асинхронного ввода-вывода. Во всей разработке программного обеспечения все самое интересное происходит именно в композиции.
Например, с обещаниями .chain()
называется .then()
. Вызов promise.then(f)
не вызовет f()
сразу. Вместо этого он будет ждать выполнения обещания и затем вызывает f()
(отсюда и название).
Пример:
{ const x = 20; // The value const p = Promise.resolve(x); // The context const f = n => Promise.resolve(n * 2); // The function const result = p.then(f); // The application result.then( r => console.log(r) // 40 ); }
В обещаниях .then()
используется вместо .chain()
, но это почти то же самое.
Возможно, вы слышали, что обещание - это не строго монада. Это потому, что он развернет внешнее обещание только в том случае, если значение изначально является обещанием. В противном случае .then()
ведет себя как .map()
.
Но поскольку он ведет себя по-разному для значений обещаний и других значений, .then()
не строго подчиняется всем математическим законам, которым должны удовлетворять все функторы и / или монады для всех заданных значений. На практике, если вы знаете об этом ветвлении поведения, вы обычно можете относиться к ним как к любому из них. Просто имейте в виду, что некоторые общие инструменты композиции могут работать с обещаниями не так, как ожидалось.
Строительная монадическая (она же Клейсли) композиция
Давайте подробнее рассмотрим composeM
функцию, которую мы использовали для создания функций повышения обещаний:
const composeM = method => (...ms) => ( ms.reduce((f, g) => x => g(x)[method](f)) );
В этом странном редукторе скрыто алгебраическое определение композиции функций: f(g(x))
. Сделаем так, чтобы было легче обнаружить:
{ // The algebraic definition of function composition: // (f ∘ g)(x) = f(g(x)) const compose = (f, g) => x => f(g(x)); const x = 20; // The value const arr = [x]; // The container // Some functions to compose const g = n => n + 1; const f = n => n * 2; // Proof that .map() accomplishes function composition. // Chaining calls to map is function composition. trace('map composes')([ arr.map(g).map(f), arr.map(compose(f, g)) ]); // => [42], [42] }
Это означает, что мы могли бы написать обобщенную утилиту компоновки, которая должна работать для всех функторов, которые предоставляют .map()
method (например, массивы):
const composeMap = (...ms) => ( ms.reduce((f, g) => x => g(x).map(f)) );
Это всего лишь небольшая переформулировка стандарта f(g(x))
. Для любого количества функций типа a -> Functor(b)
выполните итерацию по каждой функции и примените каждую к ее входному значению x
. Метод .reduce()
принимает функцию с двумя входными значениями: аккумулятор (в данном случае f
) и текущий элемент в массиве (g
).
Мы возвращаем новую функцию x => g(x).map(f)
, которая становится f
в следующем приложении. Выше мы уже доказали, что x => g(x).map(f)
эквивалентно поднятию compose(f, g)(x)
в контекст функтора. Другими словами, это эквивалентно применению f(g(x))
к значениям в контейнере: в этом случае это применит композицию к значениям внутри массива.
Предупреждение о производительности: я не рекомендую это для массивов. Составление функций таким способом потребует нескольких итераций по всему массиву (который может содержать сотни тысяч элементов). Для карт на основе массива сначала составьте простые
a -> b
функции, затем сопоставьте массив один раз или оптимизируйте итерации с помощью.reduce()
или преобразователя.
Для синхронных, активных приложений функций с данными массива это перебор. Однако многие вещи являются асинхронными или ленивыми, и многие функции должны обрабатывать беспорядочные вещи, такие как ветвление для исключений или пустых значений.
Вот тут и пригодятся монады. Монады могут полагаться на значения, которые зависят от предыдущих асинхронных или ветвящихся действий в цепочке композиции. В таких случаях вы не можете получить простое значение для простых композиций функций. Ваши действия, возвращающие монаду, принимают форму a => Monad(b)
вместо a => b
.
Всякий раз, когда у вас есть функция, которая принимает некоторые данные, обращается к API и возвращает соответствующее значение, и другая функция, которая принимает эти данные, обращается к другому API и возвращает результат вычисления с этими данными, вы захотите составить функции типа a => Monad(b)
. Поскольку вызовы API являются асинхронными, вам необходимо заключить возвращаемые значения в нечто вроде обещания или наблюдаемого. Другими словами, подписи для этих функций: a -> Monad(b)
и b -> Monad(c)
соответственно.
Составление функций типа g: a -> b
, f: b -> c
легко, потому что типы совпадают: h: a -> c
это просто a => f(g(a))
.
Составление функций типа g: a -> Monad(b)
, f: b -> Monad(c)
немного сложнее: h: a -> Monad(c)
- это не просто a => f(g(a))
, потому что f
ожидает b
, а не Monad(b)
.
Давайте немного конкретизируем и составим пару асинхронных функций, каждая из которых возвращает обещание:
{ const label = 'Promise composition'; const g = n => Promise.resolve(n + 1); const f = n => Promise.resolve(n * 2); const h = composePromises(f, g); h(20) .then(trace(label)) ; // Promise composition: 42 }
Как написать composePromises()
, чтобы результат был правильно зарегистрирован? Подсказка: вы это уже видели.
Помните нашу composeMap()
функцию? Все, что вам нужно сделать, это изменить вызов .map()
на .then()
. Promise.then()
в основном асинхронный .map()
.
{ const composePromises = (...ms) => ( ms.reduce((f, g) => x => g(x).then(f)) ); const label = 'Promise composition'; const g = n => Promise.resolve(n + 1); const f = n => Promise.resolve(n * 2); const h = composePromises(f, g); h(20) .then(trace(label)) ; // Promise composition: 42 }
Странно то, что когда вы нажимаете вторую функцию, f
(помните, f
после g
), входным значением является обещание. Это не тип b
, это тип Promise(b)
, но f
принимает тип b
без оболочки. Так что же происходит?
Внутри .then()
есть процесс распаковки, который начинается с Promise(b) -> b
. Эта операция называется join
или flatten
.
Вы могли заметить, что composeMap()
и composePromises()
- почти идентичные функции. Это идеальный вариант использования функции высшего порядка, которая может обрабатывать и то, и другое. Давайте просто смешаем метод цепочки с каррированной функцией, а затем воспользуемся записью в квадратных скобках:
const composeM = method => (...ms) => ( ms.reduce((f, g) => x => g(x)[method](f)) );
Теперь мы можем написать такие специализированные реализации:
const composePromises = composeM('then'); const composeMap = composeM('map'); const composeFlatMap = composeM('flatMap');
Законы монад
Прежде чем вы сможете начать создавать свои собственные монады, вам необходимо знать три закона, которым должны удовлетворять все монады:
- Левый тождество:
unit(x).chain(f) ==== f(x)
- Правильный идентификатор:
m.chain(unit) ==== m
- Ассоциативность:
m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g))
Законы идентичности
Монада - это функтор. Функтор - это морфизм между категориями, A -> B
. Морфизм обозначен стрелкой. В дополнение к стрелке, которую мы явно видим между объектами, каждый объект в категории также имеет обратную стрелку к себе. Другими словами, для каждого объекта X
в категории существует стрелка X -> X
. Эта стрелка известна как стрелка идентичности, и обычно она рисуется как маленькая круглая стрелка, указывающая от объекта и возвращающаяся к тому же самому объекту.
Ассоциативность
Ассоциативность просто означает, что не имеет значения, где мы помещаем круглые скобки при сочинении. Например, если вы добавляете, a + (b + c)
совпадает с (a + b) + c
. То же самое и с составом функций: (f ∘ g) ∘ h = f ∘ (g ∘ h)
.
То же верно и для композиции Клейсли. Вам просто нужно прочитать это задом наперед. Когда вы видите оператор композиции (chain
), подумайте after
:
h(x).chain(x => g(x).chain(f)) ==== (h(x).chain(g)).chain(f)
Доказательство законов монад
Докажем, что тождественная монада удовлетворяет законам монад:
{ // Identity monad const Id = value => ({ // Functor mapping // Preserve the wrapping for .map() by // passing the mapped value into the type // lift: map: f => Id.of(f(value)), // Monad chaining // Discard one level of wrapping // by omitting the .of() type lift: chain: f => f(value), // Just a convenient way to inspect // the values: toString: () => `Id(${ value })` }); // The type lift for this monad is just // a reference to the factory. Id.of = Id; const g = n => Id(n + 1); const f = n => Id(n * 2); // Left identity // unit(x).chain(f) ==== f(x) trace('Id monad left identity')([ Id(x).chain(f), f(x) ]); // Id monad left identity: Id(40), Id(40) // Right identity // m.chain(unit) ==== m trace('Id monad right identity')([ Id(x).chain(Id.of), Id(x) ]); // Id monad right identity: Id(20), Id(20) // Associativity // m.chain(f).chain(g) ==== // m.chain(x => f(x).chain(g) trace('Id monad associativity')([ Id(x).chain(g).chain(f), Id(x).chain(x => g(x).chain(f)) ]); // Id monad associativity: Id(42), Id(42) }
Заключение
Монады - это способ составить функции подъема типов: g: a => M(b)
, f: b => M(c)
. Для этого монады должны сгладить M(b)
до b
перед применением f()
. Другими словами, функторы - это то, что вы можете сопоставить. Монады - это то, что вы можете нанести на карту:
- Карта функций:
a => b
- Сопоставление функторов с контекстом:
Functor(a) => Functor(b)
- Монады сглаживаются и сопоставляются с контекстом:
Monad(Monad(a)) => Monad(b)
Монада основана на простой симметрии - способ обернуть значение в контекст и способ развернуть значение из контекста:
- Лифт / Единица: Тип лифта из некоторого типа в контекст монады:
a => M(a)
- Сглаживание / объединение: извлечение типа из контекста:
M(a) => a
А поскольку монады также являются функторами, они также могут отображать:
- Карта: карта с сохраненным контекстом:
M(a) -> M(b)
Объедините flatten с map, и вы получите цепочку - композицию функций для подъемных функций, также известную как композиция Kleisli:
- FlatMap / Chain Flatten + карта:
M(M(a)) => M(b)
Монады должны удовлетворять трем законам (аксиомам), вместе известным как законы монад:
- Левый тождество:
unit(x).chain(f) ==== f(x)
- Правильная личность:
m.chain(unit) ==== m
- Ассоциативность:
m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g)
Примеры монад, которые вы можете встретить в повседневном коде JavaScript, включают обещания и наблюдаемые объекты. Композиция Kleisli позволяет вам составлять логику потока данных, не беспокоясь о деталях API типа данных и не беспокоясь о возможных побочных эффектах, условном переходе или других деталях вычислений распаковки, скрытых в chain()
operation.
Это делает монады очень мощным инструментом для упрощения вашего кода. Вам не нужно понимать или беспокоиться о том, что происходит внутри монад, чтобы воспользоваться преимуществами упрощения, которые могут предоставить монады, но теперь, когда вы знаете больше о том, что находится под капотом, заглянуть под капот - не такая уж страшная перспектива .
Не нужно бояться проклятия леди Монадгрин.
Повышайте уровень своих навыков с живым наставничеством 1: 1
DevAnywhere - это самый быстрый способ повысить уровень навыков JavaScript:
- Живые уроки
- Гибкий график
- 1: 1 наставничество
- Создавайте настоящие производственные приложения
Эрик Эллиотт является автором Программирования приложений JavaScript (O’Reilly) и соучредителем DevAnywhere.io. Он участвовал в разработке программного обеспечения для Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC и ведущие музыканты, включая Usher, Frank Ocean, Metallica и многие другие.
Он работает где угодно с самой красивой женщиной в мире.