Практическая вводная в монады в JavaScript

Оригинал: Practical Intro to Monads in JavaScript

Если вы слышали о Монадах, но никогда не находили времени изучить их, то здесь вы найдёте простое объяснение. Без теоретической чепухи. Это простое, практическое пособие для представления JavaScript разработчикам того, как можно использовать монады. Это для инженеров, не учёных.

В теории нету разницы между теорией и практикой. На практике есть.

Yogu Berra

Все примеры основаны на monet.js – ящик с инструментами помогающий писать в функциональном стиле за счёт богатого набора монад и других полезных функций. Я буду использовать стрелочные функции появившиеся в ES6. Их легче читать, чем обычные функции, в задачах где нужно использовать функции обратного вызова. Так же в некоторых примерах будет добавлено TypeScript определение типов для максимально комфортного чтения.

Монада

Монада это коробка с некоторым значением. Люди говорят, что это как-то связано с теорией категорий, но мы можем проигнорировать этот факт. Наша коробка это не только обёртка. Она имеет инструменты, для связывания вычислений к значению. Она может служить для множества разных целей – асинхронности, чувствительных исключений, сбора ошибок, отложенных вычислений и других.. но их рассмотрим в будущих статьях.

Монады описываются 3 аксиомами. Одна из аксиом говорит нам, что есть некоторая функция, которая связывает вычисления к значению, содержащиеся в монаде. Её называют bind (или flatMap), соответствующая 3 обязательным условиям:

  • bind принимает первым параметром функцию (давайте называть её функцией обратного вызова)
  • callback принимает текущее значение и возвращает монаду, содержащую результат вычисления
  • bind возвращает монаду от функции обратного вызова
var monadWithValue = Monad( value );

function callback(value) {
    var newValue = calculate…new…value…from…value;
    return Monad( newValue );
}

var monadWithNewValue = monadWithValue.bind(callback);

И с определением типов TypeScript:

declare class Monad {
    bind<T, V>((value: T) => Monad<V>): Monad<V>;
}

Давайте забудем сейчас о теории и погрузимся в практические примеры:

Identity

Аксиомы говорят, что есть некоторая функция, которая создает монаду. Подумав, мы получаем Identity(value) функцию, которая делает эту работу:

var monadWith3 = Identity(3); // number 3 wrapped in an Identity box

var monadWith5 = monadWith3.bind( value => Identity(value + 2) ); // Identity(5)

Но почему мне нужно усложнять себе жизнь так сильно, если всё что я хочу это сложить два числа?

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

var monadWith3 = Identity(3);
var monadWith5 = monadWith3.map( value => value + 2 ); // Identity(5);

Давайте сделаем некоторое реальное вычисление:

function getPercentRatio(wrongCount, correctCount): string {
    return Identity(wrongCount)

        // we get total count here
        .map(wrongCount => wrongCount + correctCount)

        // ratio between 0 and 1
        .map(totalCount => correctCount / totalCount)

        // ratio between 0 and 100
        .map(ratio => Math.round(100 * ratio))

        // ratio as a string like '28%'
        .map(percentRatio => String(ratio) + '%')

        // get value from inside of the Identity
        .get();
}

.map() в Identity работает точно также как в Array:

function getPercentRatio(wrongCount, correctCount): string {
    return [wrongCount]

        // we get total count here
        .map(wrongCount => wrongCount + correctCount)

        // ratio between 0 and 1
        .map(totalCount => correctCount / totalCount)

        // ratio between 0 and 100
        .map(ratio => Math.round(100 * ratio))

        // ratio as a string like '28%'
        .map(percentRatio => String(ratio) + '%')

        // get value from inside of the Identity
        .pop();
}

Для примера, вычисления сверху слегка упрощены, но они показывают идею того, как можно компоновать операции содержательно и читабельно. Рассмотрим другие варианты:

function getPercentRatio(wrongCount, correctCount): string {
    return String(Math.round(correctCount / (wrongCount + correctCount)) * 100) + '%';
}

или в более функциональном стиле:

function getPercentRatio(wrongCount, correctCount): string {
    return toPercentString(Math.round(ratio2percent(ratio(correctCount)(wrongCount))));
}

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

Promise A/A+

Известные JavaScript Promises немного похожи на монады. Они являются коробками со значениями (или отказами). Сравните этот кусок кода с примером инициализации Identity:

var promiseOf3 = Q.resolve(3); // a Promise with number 3 inside
var promiseOf5 = promiseOf3.then( value => Q.resolve(value + 2) );
// => a Promise with number 5 inside

Это простой пример. На самом деле Promise A/A+ не типобезопасный, как монады. Метод .then() настолько гибкий, что делает работу нескольких разных функций монады (bind/flatMap, map, orElse, cata и нескольких других).

Но, точно существует похожая имплементация монад в JavaScript.

Maybe

Изучали несколько монад. Возможно наиболее известная – Maybe (Option). Она может быть Some(Value) или Nothing. Пример, по аналогии с предыдущими:

var optionOf3 = Some(3);
var optionOf5 = optionOf3.flatMap((value) => Some(value + 2));
// Name 'flatMap' is commonly used for 'bind' method.
// I'll stick to it the rest of this article.

Это начинает быть полезным, когда появляются методы/функции возвращающие некоторое значение или null/undefined. Для примера getCurrentUser():

function getCurrentUser(): User { /* some implementation */ };

Он может вернуть одну User сущность или… null. Смотрите:

function getId() {
    // will throw error if user is null or undefined
    return getCurrentUser().id;
}

Мы можем исправить это при помощи раздутого if/else:

function getId(): string {
    var user = getCurrentUser();
    if (user) {
        return user.id;
    }
    return null;
}

…или мы можем упаковать наши значения в одну Maybe коробку:

function getCurrentUser(): Maybe<User>; // another implementation

function getId(): Maybe<string> {
    return getCurrentUser().map(user => user.id);
}

Мы безопасные. Никаких Uncaught TypeError: Cannot read property ‘id’ of null исключений. И мы не должны добавлять сложный if/else в код.

Maybe имеет несколько базовых методов:

  • flatMap который является ядром любой монады
  • map позволяющий нам получать Maybe из Maybe
  • filter позволяющий нам изменять Some в None, если условие не выполнилось
  • orSome – выдаёт значение в монаде или просто другое значение
  • orElse – выдаёт монаду (если это Some) или любую другую новую монаду
  • cata – ух! много магии…

Давайте .filter() и .map() как Array

Если вы понимаете Maybe как один элемент (Some) или пустой (None) массив – map и filter будут работать точно так же:

let maybeUser = [{id: "3asd4asd", name: "James"}];
maybeUser.map(user => user.id); // => ["3asd4asd"]
maybeUser.filter(user => user.name === "John"); // => []

let notUser = [];
notUser.map(user => user.id); // => []
notUser.filter(user => user.name === "John"); // => []

и для Maybe:

let maybeUser = Some({id: "3asd4asd", name: "James"});
maybeUser.map(user => user.id); // => Some("3asd4asd")
maybeUser.filter(user => user.name === "John"); // => None

let notUser = None;
notUser.map(user => user.id); // => None
notUser.filter(user => user.name === "John"); // => None

Давайте создадим новый геттер (похожий на getId() несколькими строками кода выше):

function getName(): Maybe<string> {
    return getCurrentUser().map(user => user.name);
}

Разница от map, реализованным через Array, является то, что в процессе прохода, монада должна упасть с ошибкой, если callback не вернет значение. Так что, если имя пользователя является null или undefined, вышестоящий пример будет сломан. Мы можем легко исправить это при помощи фильтрации:

function getName(): Maybe<string> {
    // if user.name is null or undefined map 'callback' will never be called
    return getCurrentUser()
        .filter(user => !!user.name)
        .map(user => user.name);
}

“Но это становится таким сложным..” вы могли бы сказать. И вы правы. Основной метод монады это flatMap (называемый ещё unit) и он может исправить эту ситуацию.

Давайте .flatMap()

function getName(userOption: Maybe<User>): Maybe<string> {
    return userOption.flatMap(user => {
        if (user.name) {
            return Maybe.Some(user.name);
        }
        return Maybe.None;
    });
}

..но это всё еще немного не красиво. И это является тем, почему моя любимая JavaScript реализация (monet.js) предоставляет дополнительный способ создать Maybe монаду:

let name = 'James';
let otherName;

let maybeName = Maybe.fromNull(name); // this one is Some('James');

let maybeOtherName = Maybe.fromNull(otherName); // None

Вместе с JavaScript супер силой мы можем:

function getName(userOption: Maybe): Maybe {
    return userOption.flatMap(user => Maybe.fromNull(user.name));
}

Мы можем легко представить ситуацию в которой, пустое имя пользователя может заполниться значением по умолчанию, например “Гость”. Мы можем использовать .orSome() для этого.

Получим значение .orSome() другое значение

Давайте получим имя пользователя со значением по умолчанию “Гость”:

// Some getters defined earlier:
function getCurrentUser(): Maybe<User>;
function getName(userOption: Maybe<User>): Maybe<string>;

// And the meat and potatoes:
let name = getName(getCurrentUser()).orSome('Guest');

Это одна из множеств реализаций называемая .getOrElse() – получить значение или получить другое, указанное как параметр.

Без монад это могло бы выглядеть так:

function getCurrentUser(): User;
function getName(user: User): string;

let user = getCurrentUSer();
let name;

if (user) {
    name = getName(user);
}

if (!name) {
    name = 'Guest';
}

Страшно, не правда ли?

Теперь, в чём разница между .orSome() и .orElse()?

Maybe чаю .orElse() может кофе…

Мы рассмотрели значение по умолчанию как необязательное поле имени. Мы также можем захотеть заполнить его необязательным значением из какого-либо другого поля в нашем исходном коде, например из ‘nickname’. Это то, что мы можем сделать при помощи императивных инструментов:

function getName(user: User): string;
function getNick(user: User): string;

function getPrintableName(user: User) {
    let name;

    if (user) {
        name = getName(user);
        if (!name) {
            name = getNick(user);
        }
    }

    return name || 'Guest';
}

Возвращаясь на твёрдую землю:

function getName(userOption: Maybe<User>): Maybe<string>;
function getNick(userOption: Maybe<User>): Maybe<string>;

function getPrintableName(userOption: Maybe<User>): Maybe<string> {
    return getName(userOption).orElse(getNick(userOption)).orSome('Guest');
}

Просто, не правда ли?

Катастрофизм против Катаморфизма

В теории категорий, концепт катаморфизма (с греческого: κατά – вниз, μορφή – форма) обозначает уникальный гомоморфизм из начальной алгебры в некоторую другую алгебру.

Думаю, мы можем игнорировать эту научную лапшу. Легко запомнить, что самая сложная часть monet.js Maybe монады .cata() – просто катастрофизм. Это может привести к катастрофическому исходу или катаклизму, если использовать его невнимательно или без типобезопасности в уме. Это также лучший инструмент (наряду с .orSome() и .orElse()) для связывания нашего принципиально нового функционального кода со старыми и неприятными сторонними библиотеками.

Это как гибрид .orSome() и .map() с 2 callbacks:

  • для случаев когда у нас есть None – он не принимает аргументы и возвращает значение по умолчанию
  • для случаев когда у нас есть Some(value) – он принимает один аргумент (значение) и возвращает новое значение

Он возвращает результат функции обратного вызова, которая была вызвана. Его сигнатура в контексте Maybe выглядит так:

Maybe<T> {
    …
    cata<Z>(noneCallback: () => Z, someCallback: (val: T) => Z): Z;
    …
}

Изучая другой случай – мы имеем один Maybe (опция для имени пользователя) и нужно поздравить – пользователь с именем и анонимный пользователь разделены:

function getGreeting(nameOption: Maybe<string>): string {
    return nameOption.cata(() => 'Hi Guest!', name => 'Welcome back ' + name + '!');
}

Изощрённое научное название для простой операции.

В заключении

Эта простая маленькая монада может сохранить вам много времени и немного кода. Identity даёт вам возможность писать сложные вычисления наглядными операциями. Maybe приносит с собой защиту от Cannot read property of null исключений. И что самое важное – это всего лишь первый шаг в мир функционального программирования монадами, где вы сможете найти ещё больше похожих инструментов, таких как Either (обработка чувствительных исключительных ситуаций), Validation (сбор ошибок), Promise (асинхронные операции), иммутабельные List, Lazy, Continuation, Free, Read и другие… И некоторые из них будут очень скоро здесь!

Запомни, монада на самом деле, не больше чем вычисления через цепочки. Это просто функциональный способ выстраивания вещей. Вот и всё.

Лёгкая вводная в Монады… Maybe? Sean Voisen

Дальнейшее чтение:

Leave a comment