Введение

Начну с того, что хорошо программировать на 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 многое взял из функционального программирования.

Не ограничивайтесь тем, что спрашивают на собесах, пожалуйста :)