Немного о монадах в JavaScript
Введение
Начну с того, что хорошо программировать на JS можно, вообще не зная слова “монада” и не представляя об их существовании. Ни в одном учебнике по JavaScript я этого слова не встречал, не слышал его из уст даже опытных JS-разработчиков и в принципе узнал о них только тогда, когда начал изучать Haskell, полностью функциональный язык программирования. Поэтому, если вы изучаете JS, не пугайтесь: знать такое вовсе не обязательно даже для человека с достаточно большим опытом в разработке. Мне просто захотелось попытаться написать о монадах так просто, как только это возможно.
Я специально возьму скучное определение монад из Википедии, чтобы сначала нам стало скучно :) но не стоит огорчаться — дальше будет гораздо интереснее. Например, как вам такой факт: вы уже используете монады, программируя на JS?
Итак, то самое определение: монада — это особый тип данных, для которого можно задать императивную последовательность выполнения некоторых операций над хранимыми значениями. Вроде слова знакомые, а ничего не понятно. Зачем нужен такой тип данных? Что мы можем сделать с ним, чего не можем без него? Примерно об этом будет эта статья. И да, существует теория: как только ты понимаешь, что такое монады, ты теряешь возможность об этом рассказывать. Эту теорию я надеюсь опровергнуть, ждать осталось совсем немного :)
Небольшая сноска: для того, чтобы лучше понимать, о чем я здесь говорю, лучше для начала прочитать мою статью об асинхронности в JavaScript.
Сразу к сути
Мы знаем, что в JS существуют промисы, и мы возьмем один пример из моей статьи об асинхронности:
function getBeef(name: string): Promise<boolean> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (beefExists) {
const beef = true;
resolve(beef);
} else {
reject('No beef available')
}
}, 300)
});
}
getBeef('John')
.then(sliceBeef)
.then(cookBeef)
.then(serveBeef)
.then((servedBeef) => servedBeef ? console.log('Beef is served!'): console.log('Beef is not served'))
.catch((err) => console.log(err));
Что здесь происходит? Некоторая функция getBeef()
выполняется и по цепочке передает полученное значение следующей функции, которая, в свою очередь, делает буквально то же самое — и так далее. Еще мы можем связывать в такие же цепочки методы массивов вроде Array.prototype.map
или Array.prototype.flatMap
(о том, что здесь значит таинственное слово prototype
, можно прочитать здесь).
Так вот: если вы хоть раз так делали, вы уже использовали монады.
Чуть подробнее
Давайте, используя TypeScript, опишем Promise
как интерфейс:
interface Promise<A> {
then<B>(callback: (a: A) => B | Promise<B>): Promise<B>
}
(если непонятно, что тут вообще происходит и что это за треугольные скобочки, прошу вас прочитать статью о дженериках в TypeScript).
Это немного упрощенный интерфейс Promise
, но суть понятна: у промиса, работающего с любым типом, который мы обозначили как A
есть метод then()
, который работает с другим любым типом, обозначенным как B
. Этот метод принимает функцию-коллбек, которая принимает аргумент типа A
и возвращает либо значение типа B
, либо промис типа B
. Метод then()
, в свою очередь, возвращает промис типа B.
Становится понятно, что эти методы then()
можно вызывать по цепочке: раз then()
вернет промис, то у него тоже есть метод then()
, который мы можем вызвать — и делать это неограниченное количество раз.
В ES2017 были добавлены асинхронные функции и ключевые слова async
и await
. Теперь мы можем делать так:
async (): Promise<boolean> => {
try {
const beef = await getBeef('John');
const slicedBeef = await sliceBeef(beef);
const cookedBeef = await cookBeef(slicedBeef);
const servedBeef = await serveBeef(cookedBeef);
servedBeef ? console.log('Beef is served!') : console.log('Beef is not served!');
} catch (err) {
console.log(err);
}
}
Асинхронные функции могут помочь нам тогда, когда даже промисы превращаются в нечитаемый callback hell. Вот пример:
declare function getA(): Promise<number>;
declare function getB(a: number): Promise<number>;
declare function getC(a: number, b: number): Promise<number>;
declare function getD(a: number, b: number, c: number): Promise<number>;
function sumAwithBwithCwithD(): Promise<number> {
return getA()
.then(a => getB(a)
.then(b => getB(a, b)
.then(c => getD(a, b, c)
.then(d => {
return a + b + c + d;
})
)
)
);
}
В примере выше мы вынуждены передавать переменную, полученную на текущем шаге, во все, за ним следующие. Выглядит не очень, на мой взгляд, — правда?
Вот как мы можем переписать такое с использованием async
и await
:
declare function getA(): Promise<number>;
declare function getB(a: number): Promise<number>;
declare function getC(a: number, b: number): Promise<number>;
declare function getD(a: number, b: number, c: number): Promise<number>;
async function sumAwithBwithCwithD(): Promise<number> {
const a = await getA();
const b = await getB(a);
const c = await getC(a, b);
const d = await getD(a, b, c);
return a + b + c + d;
}
Намного читаемо, да? Но это нас не удивляет — мы все это уже знаем. Нам просто нужны эти примеры, чтобы перейти к монадам.
И при чем здесь какие-то монады?
К сожалению, мы немного ограничены синтаксисом TypeScript — TS не умеет в полиморфные типы данных. Вы можете думать о полиморфных типах данных как о типе данных, в который можно вложить другой тип данных. Стало немножко сложновато, да? Обратимся к определению монад из Википедии снова (да-да, фу): там упоминались некие хранимые значения. Если говорить языком типов, то для реализации типа “монада” мы должны уметь хранить тип данных внутри типа данных “монада”.
Так, вероятно, стало чуть понятнее — но, поскольку мы вынужденно немного упростим всю эту функциональщину, станет еще понятнее. Давайте определим тип Monad<A>
как монаду для типа A
, если мы можем реализовать вот такие функции:
function of<A>(value: A): Monad<A>
function flatMap<A, B>(monad: Monad<A>, callback: (a: A) => Monad<B>): Monad<B>
Что тут происходит? С помощью функции of()
, работающей с любым типом, который мы обозначим как A
, и принимающей аргумент типа A
, мы можем вложить в монаду это самое значение типа A
. И мы можем это значение достать, передав функцию-коллбек в функцию flatMap, работающую с типами A
и B
(помним, это не типы, это дженерики).
Не очень-то понятно, о чем я вообще говорю, при чем здесь JS, да? Давайте рассмотрим то же самое на промисах:
type Monad<A> = Promise<A>;
function of<A>(value: A): Monad<A> {
return Promise.resolve(value);
}
function flatMap<A, B>(monad: Monad<A>, callback: Function) {
return monad.then(callback); // мы же помним, что Monad<A> = Promise<A>?
}
А можно и еще менее запутанно:
function of<A>(value: A): Promise<A> {
return Promise.resolve(value);
}
function flatMap<A, B>(promise: Promise<A>, callback: Function) {
return promise.then(callback);
}
То есть, для Promise
функция of()
реализована как функция Promise.resolve()
, а функция flatMap
реализована как метод then()
. Таким образом, Promise
в JS можно считать монадой. И вправду, можем ли мы для хранимых в Promise
данных задать императивную последовательность действий, несмотря на асинхронную природу промисов? Да — у нас для этого есть метод then()
, который мы можем вызывать по цепочке. Можем ли мы хранить что-то в промисе? Да, у нас есть Promise.resolve(value)
— притом, замечу, промис может хранить и промис, и что-то другое внутри себя — и это крайне напоминает нам о полиморфных типах данных :)
То есть, Promise
— это вполне себе что-то похожее на монаду. И используется он для того же, для чего монады используются в функциональных языках типа Haskell: для работы с побочными эффектами (I/O).
Законы монад в Haskell и применимость этих законов к подобию монад в JS
В Haskell монады не только должны реализовать функции of()
и flatMap()
, но и подчиняться трем законам:
Первый закон идентичности (left identity law)
Первый акон идентичности гласит, что функции of()
и flatMap()
для монады должны быть обратны друг другу. То есть:
declare const a: A;
declare function of(a: A): Monad<A>;
flatMap(of(a), a => of(a)) === of(a) //true
То есть, если мы кладем в монаду значение с помощью of()
и забираем это значение с помощью коллбека, оно равно результату выполнения функции of()
над тем же значением. Для промисов это выглядит как-то так:
const likeFlatMap = Promise.resolve(5).then(x => console.log(x)) // 5
const likeOf = await Promise.resolve(5) // 5
Как мы видим, первый закон выполняется :)
Второй закон идентичности
Согласно второму закону идентичности, если мы достанем значение из монады с помощью flatMap()
, а потом положим это значение обратно в монаду с помощью of()
, то мы получим идентичную монаду. Смотрите, как этот закон выполняется на промисах:
const promiseOf = promiseFlatMap.then(value => Promise.resolve(value));
const promiseFlatMap = Promise.resolve(await promiseOf);
// promiseOf и PromiseFlatMap идентичны
Закон композиции
Закон композиции проявляется как-то так:
declare const monad: Monad<A>;
declare function f1(a: A): Monad<B>;
declare function f2(b: B): Monad<C>;
flatMap(flatMap(monad, f1), f2) === flatMap(monad, (a) => flatMap(f1(a), f2)); // true
То есть, композиция сбора значений из разных монад эквивалентна вызовам flatMap() в коллбеке предыдущей монады.
Для промисов закон композиции тоже выполняется:
getA()
.then(a => getB(a)
.then(b => getC(b)
.then(c => getD(c))));
// можно отрефакторить так:
getA().then(getB).then(getC).then(getD);
То есть, нам неважно, объединим ли мы в цепочку вызовы then()
внутри коллбека другого метода then()
, или же будем вызывать then()
как метод предыдущего промиса — результат будет эквивалентен.
И зачем все это?
Думаю, теперь достаточно понятно, что промисы в JS сильно напоминают монады. Но есть одно небольшое отличие: внутри промиса не может лежать промис, он всегда отрезолвится до значения. Не то чтобы это мешало нам работать с промисами как с монадами, правда :)
В сущности, любую структуру данных в JS можно считать монадой, если значение получается с помощью императивной последовательности действий, а не достается напрямую. Например:
-
Promise
- монада, поскольку, если мы попытаемся просто достать значение из промиса напрямую, у нас не выйдет — нам нужно использоватьthen()
, другими словами, выполнить некую императивную последовательность действий и получить значение, используя коллбек. -
Array
может быть монадой, когда мы трансформируем значения не напрямую, а с помощью коллбека — используя, к примеру,flatMap()
(он есть в JS какArray.prototype.flatMap()
),map()
и прочие методы из прототипаArray
. - монада
Optional
не существует по умолчанию ни в ванильном JS, ни в TS, но подобием может быть что-то вродеNullable<T> = T | null, T !== null
. Если мы используем коллбеки вместо ручной проверки на null, то эта конструкция ведет себя как монада -
Iterable
очень даже может использоваться как монада, если мы, итерируя эту последовательность, применяем к каждому объекту `
Вот, к примеру, реализация методов монады для массива:
type Monad<A> = Array<A>;
function of<A>(a: A): Monad<A> {
return [a];
}
function flatMap<A, B>(monad: Monad<A>, cb: (a: A) => Monad<B>): Monad<B> {
return monad.flatMap(cb);
}
Вообще, промисам повезло, им подвезли async
/await
, и избегать callback hell стало очень легко. А что, если callback hell произойдет при работе с массивом? К примеру, как-нибудь вот так:
function rightTriangles(maxLengthC: number) {
return range(1, maxLengthC + 1)
.flatMap((c) => range(1, c)
.flatMap((a) => range(1, a)
.flatMap((b) => [[a, b, c]])
.filter(([a, b, c]) => a**2 + b**2 === c**2)
)
);
}
console.log(rightTriangles(10));
// [[4,3,5], [8,6,10]]
Есть планы добавить в JS подобие синтаксиса do
для монад в Haskell, который выглядит как-то так:
rightTriangles :: [(Integer, Integer, Integer)]
rightTriangles = do
c <- [1..10]
a <- [1..c]
b <- [1..a]
guard (a^2 + b^2 == c^2)
return (a,b,c)
Вот что предлагается добавить в JS (но это не точно):
multi function rightTrianglesG(maxLengthC: number) {
const c = pick range(1, maxLengthC + 1);
const a = pick range(1, c);
const b = pick range(1, a);
pick where(a**2 + b**2 === c**2);
return [a, b, c] as const;
}
multi function where(cond: boolean) {
if (cond) return pick [];
}
К счастью, в JS уже есть удобный метод работы с любыми монадами — генераторы. Что это такое и как они могут помочь в работе с монадами, вы можете почитать в моей статье о генераторах.
Финалочка
Не то чтобы эти знания нужны программисту на императивных языках программирования. Но, во-первых, расширять кругозор полезно и практически обязательно, а во-вторых, из статьи понятно, что и такие знания могут помочь в разработке на JS, ведь JS многое взял из функционального программирования.
Не ограничивайтесь тем, что спрашивают на собесах, пожалуйста :)
Интересный пост?
Вот еще похожие:
- Событийно-ориентированная архитектура и Node.js Events
- Реактивное программирование: теория и практика
- Как и зачем писать тесты?
- Функциональное программирование. Что это и зачем?
- Профилирование Node.js-приложений