Работа в контекст: Работа с монади в моя JavaScript

Предупреждение: Това е въведение във функционалното програмиране в JavaScript. Ако това ви звучи детинско, търсите в грешното място.

Източник на Github: Monad Tutorial

Въведение

И така, какво е монада? Това обикновено е малко натоварен въпрос. Трябва да е лесен въпрос. В света на JavaScript Дъглас Крокфорд се опита да дефинира монадите в този разговор: „Монади и гонади“. Докато добър разговор за функционалното програмиране в JavaScript, голямото отнемане на монадите? Монадите са обекти. Монадата е обект? Благодаря Дъг.

Сандвич с шунка също е обект:

const hamSandwich = {
  bread: 'rye',
  cheese: 'swiss',
  meat: 'ham',
  mustard: 'yes, please'
};

Може би нямам сандвич с шунка:

const hamSandwich = null;
hamSandwich.bread // -> BOOM!! Runtime error.

Но утре ще ям сандвич с шунка:

const hamSandwich = Future(sandwich);

За монадите са написани цели книги, много статии и научни статии. Много от тях обсъждат монадите абстрактно и навлизат в вътрешностите на теорията на категориите. Теория на категориите? Имам молба да пиша тук. Практически погледнато, тъй като на практика ние правим нещата, когато чуете някой да нарича нещо като монада, можете да мислите за това като за обект, който се придържа към определен интерфейс. По-конкретно, това е интерфейс, който дефинира композируеми изчисления. Очаква се монадата да има два метода. Едната ще наречем единица, известна още като „от“ или „връщане“. Той приема стойност и я поставя в контекста на определената монада, действайки като конструктор за нова монада. Другата функция, която ще наречем верига, известна още като „bind“, „flatMap“ или, ако използвате Haskell, „››=“. Както може би подозирате с това име, това е това, което определя състава. Веригата приема унарна функция, която получава като свой аргумент стойността, съдържаща се в монадата, към която е извикана. Функцията връща нов екземпляр на същата монада; в резултат на веригата едно монадично изчисление след друго. Това е много подобно на начина, по който работи „тогава“ в API на Promise.

Настрана: Да, „интерфейс“ е неприятна обектно-ориентирана дума. Но дори тези от нас, които са ентусиасти по функционално програмиране, вероятно имат ежедневна работа да пишат обектно-ориентиран код. Отпуснете се, бъдете приобщаващи.

Всичко е свързано с контекста

Преди да продължим напред, ще направим крачка назад. Когато обсъждаме монади, ние навлизаме в света на чистото функционално програмиране. Много проблеми във функционалното програмиране се решават чрез наличието на функции, които знаят как да работят със стойности в контекст. Най-познатият пример за това са методите за карта, намаляване и филтриране, открити в масиви.

// Apply function to every value
[1,2,3].map((val) => val + 1); // -> [2,3,4]

// Filter values with function
[1,2,3,4,5].filter((val) => val > 2); // -> [3,4,5]

// Combine values with function
[1,2,3].reduce((acc, next) => acc + next, 0); // -> 6

Всеки от тези методи знае как да работи със стойности в контекста на масив, като поддържа функционална чистота, като винаги връща нов масив или, в случай на намаляване, нова стойност. Има много предимства при работа с функции, които разбират контекста на стойността, върху която работят. Най-очевидното предимство в горния код е колко по-сбит и четим е кодът, когато нямаме цикли for и временни обекти, които замърсяват кода на нашето приложение. Поради повишената простота има и по-малка вероятност да правим грешки и да въвеждаме грешки. Това също ни кара да пишем по-чист, по-декларативен код. Връщаме нови масиви, така че винаги декларираме, че някаква променлива е равна на някаква нова стойност, а не изпращаме масив за мутиране и трябва да следим мутираното състояние. Нашият код дори не се интересува дали има нещо в масива. Ако няма нищо в масива, нашата функция просто няма да работи. Няма проверка за дължина на масива, по-голяма от нула.

function modifyArray(arr) {
  const len = arr.length;
  for (let i = 0; i < len; i++) {
    arr[i] = arr[i] + 2;
  }
}

const arr = [1,2,3];
const x = arr.map((val) = val + 2); // -> [3,4,5]
console.log(arr); // -> [1,2,3]
modifyArray(arr);
console.log(arr); // -> [3,4,5]

Можем да използваме същия този принцип, работейки със стойности в контекст, за да опростим други операции. Има много сложности, свързани с работата с някои ценности, които наистина нямат нищо общо със стойността. Те са свързани с това как се получава стойността или с това как се поддържа нейното състояние. Стойностите могат да идват от мрежови заявки, от потребителски вход, от localStorage. Някои стойности може понякога да са нулеви или по друг начин празни. Монадите могат да ни помогнат в тези ситуации и много повече, за да опростим кода на нашето приложение и да го направим по-надежден. Скриване на подробности за внедряването и ни принуждаване да пишем клиентски код, който е по-бездържавен.

Нека да разгледаме как да изградим това. Ще започнем просто и ще изградим до пълна реализация на IO монада.

Най-просто е:

function Context(val) {
  this.value = val;
}

Уау, това е впечатляващо, нали? Ако сте написали значително количество код, може да сте намерили функцията за самоличност полезна. Функцията за идентичност е функция, която просто връща стойността, която е дадена като аргумент.

function identity(val) {
  return val;
}

Да, това казах. Полезно е, когато понякога искате да използвате функция за промяна на стойност, а понякога не. Поддържате кода си по-прост, като винаги очаквате да използвате функция, просто използвайте самоличността, когато не искате да променяте стойността. Вероятно ще видите това в базов клас на някаква рамка. Реализацията по подразбиране на метод е функцията за идентичност. Ще зависи от разширяващ се клас да отмени това. Междувременно рамката е настроена винаги да изпълнява стойност чрез метода въз основа на някакво събитие.

По подобен начин тук това е началото на монада на идентичност. Или мога да го нарека и контекст на идентичност. Целта на опаковането на стойността в обект е да не се работи директно със стойността. Ще приемем, че стойността е по някакъв начин ненадеждна. Кодът на нашето приложение наистина не трябва да се интересува от това как се получава стойността или дали е надеждна. Просто искаме да оперираме със стойността. Въпреки това, ако не използваме абстракции, за да се справим със стойността, ние сме принудени да накараме нашия код на приложение да се занимава с поддържането на стойността. Ще работим със стойността само чрез методите, които присвояваме на дадения монадичен контекст.

Context.prototype.map = function(fn) {
  return new Context(fn(this.value));
};

Context.prototype.get = function() {
  return this.value;
};

Context.prototype.toString = function() {
  return 'Context(' + this.value + ')';
};

Методът на картата очевидно е интересен. Той прилага дадената функция към стойността на този екземпляр и връща нов екземпляр с върнатата стойност на тази функция. Точно това прави методът array map. Разликата е, че масивите са последователности от стойности. Това е основната отличителна черта на ценностите, съдържащи се в този контекст. Ако трябваше ръчно да внедрите метода Array map, той би бил малко по-сложен от този за нашия Context обект.

Може би нещо подобно:

Array.prototype.map = function(fn) {
  const len = this.length;
  const newArray = new Array(len);
  for (let i = 0; i < len; i++) {
    newArray[i] = fn(this[i]);
  }
  return newArray;
};

Що се отнася до нашия прост обект Context. Някакъв примерен изход от тези операции, забележете, че картата не променя оригиналния обект:

const c = new Context(3);
const d = c.map((val) => val + 3);

c.get(); // -> 3
d.get(); // -> 6

c.toString(); // -> 'Context(3)'
d.toString(); // -> 'Context(6)'

Добре, значи взехме число и го увихме в обект. Това обаче става интересно, когато започнете да мислите за възможностите на този метод на карта. Работи с проста стойност и връща прост резултат. Какво ще стане, ако стойността, съдържаща се в контекста, идва от ajax заявка, или резултат от изпълнение на уеб работник, или сокет? Ами ако стойността е генерирана от потребителя? Какво ще стане, ако не е надеждно, че стойността изобщо ще бъде там? Методът на картата все още ще приеме проста функция. Контекстът, в който работи, ще определи кога, дали и как трябва да се приложи функцията. Вашият код на приложение тогава ще бъде тази проста малка функция, която предавате на map. Тествайте това. Подробностите за внедряване на правене на заявки към сървъра или извършване на нулеви проверки ще бъдат обработени от конкретния контекст. Това е същото опростяване, което методите на масивите ни дават, скривайки подробности за внедряването, за които нашето приложение не трябва да се интересува, оставяйки ни с по-чист и по-тестваем код.

Аз съм функтор, както и ти.

Ако говорим за функционално програмиране на нашия обектно-ориентиран език, бихме казали, че ако една монада ще бъде монада, тя също трябва да отговаря на интерфейса на функтора. Вероятно сега просто измислям тези думи. Идеята за наличие на функции, които знаят как да работят в определен контекст, не е уникална за монадите. Монадите представляват определен интерфейс, някои контексти не задоволяват този интерфейс, но те все още имат методи, които работят в този контекст и осигуряват стойност. Един такъв контекст се дефинира от интерфейса на функтора. Функторът е обект с метод за карта (или обект, където някаква функция за карта знае как да се справи с него). Достатъчно просто. Методът map приема унарна функция, която приема стойност и връща стойност. Входната стойност е стойността от инстанцията, с която работим, а върнатата стойност се поставя в нова инстанция на същия функтор.

Сега ще вземем нашия метод на функтор, „map“, ще го добавим към нашите монадични методи, „chain“ и „unit“, и ще попълним интерфейса за нашата монада за идентичност. Ще започнем, като преименуваме нещото на „Идентичност“. Сега имаме функтор за идентичност:

/**
 * An identity monad.
 *
 * @name Identity
 * @class
 * @param {*} val A value to place in our context
 */
function Identity(val) {
  this.value = val;
}

// get :: Identity a -> a
Identity.prototype.get = function() {
  return this.value;
};

// map :: Identity a -> (a -> b) -> Identity b
Identity.prototype.map = function(fn) {
  return new Identity(fn(this.value));
};

Добре, така че единственото интересно нещо в тази нова част са коментарите. Какво става там? Това е нотация на типа в стил Haskell. Коментарът над метода get казва, че методът get работи върху идентичност, а именно екземпляра, на който е извикан, съдържащ някакъв тип „a“. „a“ в този контекст означава, че не ни интересува типът. Ще го наречем променлива тип. Ако искахме типът, който се съдържа в този обект, да бъде само цяло число, бихме могли да напишем „Identity Int“ или „Identity Number“. Стрелката (‘-›’) показва връщане. Така че ние оперираме с идентичност от някакъв тип „a“ и връщаме „a“, стойността, съдържаща се в обекта.

По подобен начин в метода map ние работим с Идентичност от някакъв тип „a“, отново екземплярът, към който се извиква методът. Това ще бъде конвенция за всички методи на екземпляр (всички методи, намиращи се в прототипа), за първия аргумент, показан в коментарите, за да представлява екземпляра, върху който се работи, „това“ вътре в метода. Ще забележите, че методът на картата има три стрелки. Стойността след последната стрелка е връщаният тип, всички предишни стрелки отделят аргументи. Ще забележите, че средната стрелка разделя „a“ и „b“ и е обвита в скоби. Скобите показват, че средната стрелка е функция, предадена като аргумент за картографиране, без да разделя два аргумента за предаване на картографиране. Така че map оперира с идентичност от някакъв тип „a“ и приема втори аргумент на функция от някакъв тип „a“ към някакъв тип „b“. „a“ и „b“ могат да бъдат от един и същи тип, но не е задължително. Така че map взема „a“ от екземпляра, върху който се работи, и връща идентичност от някакъв тип „b“, резултат от функцията, предадена на map. Стойността тук е да видите възможно най-много информация за нашите операции възможно най-кратко. Това прави по-ясно в какъв контекст работим и какво се очаква да направят нашите методи.

А сега… Обратно към нашето редовно планирано програмиране

Ще продължим с нашата монада за идентичност, като най-накрая добавим монадичните методи към нея. Първото нещо, което ще направим, е да добавим метода единица. Не забравяйте, че работата на метода единица е да постави стойност в монадичен контекст. Да, това звучи като конструктор. Да, нашата реализация изглежда като конструктор:

// unit :: a -> Identity a
Identity.unit = function(val) {
  return new IO(val);
};

// unit :: a -> Identity a
Identity.prototype.unit = Identity.unit;

Добре, уау, нищо вълнуващо тук. Методът unit буквално просто извиква конструктора вместо нас. Можем да го направим достатъчно лесно сами. Ние обаче се придържаме към общ интерфейс. Можете да започнете да си представяте в по-сложна монада, да речем монада, която дефинира асинхронни операции, можете да използвате единица, за да поставите стойност в контекста, без да се налага да изпълнявате някакво по-сложно изчисление. Стойността може да изглежда асинхронна, без всъщност да е асинхронна. Това ще бъде ценно за създаване на ценности, които знаят как да работят заедно. Ако имам синхронно изчисление, което искам да композирам с асинхронно изчисление, мога да поставя изчислението за синхронизиране в същия монадичен контекст като асинхронното изчисление. Тази монада обикновено се определя с имената Обещание или Бъдеще. Разбира се, можете да създадете свой собствен или да използвате друга реализация.

Разбира се, нещата стават по-интересни, когато започнем да определяме състава. За това използваме верижния метод. Веригата приема унарна функция, за да оперира със стойността на екземпляра, към който е извикана. Тази функция връща нов екземпляр на същата монада:

// chain :: Identity a -> (a -> Identity b) -> Identity b
Identity.prototype.chain = function(fn) {
  return fn(this.value);
};

Хубаво, композицията тук е лесна за определяне. Можете да лансирате колкото искате идентичности заедно. Веригата винаги ще връща една самоличност, която е резултат от състава на всички ваши самоличности.

const mapping = (val) => new Identity(val + 1);
cosnt id = (new Identity(1))
  .chain(mapping)
  .chain(mapping)
  .chain(mapping);
console.log(id.toString()); // -> 'Identity(4)'

Нещо, което може да ви е минало през ума, докато обсъждахме вложени стойности, стойности в монадите, е какво се случва, ако имаме монада вътре в монада? Нищо не ни спира да направим това:

const id = new Identity(new Identity(3));

Or:

const id = Identity.unit(Identity.unit(3));

Може би това не изглежда като проблем. Всъщност това е просто част от справянето с ценности като тази. Със сигурност би било много по-чисто, ако избягваме използването на вложени обекти като този. Ще добавим нов метод към нашата монада за идентичност, за да се справим с това. Ще наречем този метод „присъединяване“. Известен е също като „изравняване“.

// join :: Identity (Identity b) -> Identity b
Identity.prototype.join = function() {
  return new Identity(this.get().get());
};

За монадата за идентичност това е достатъчно лесно да се направи. Ние просто извличаме стойността от вложената монада. Този метод е по-интересен, когато се работи с по-сложни изчисления, но, както при много неща във функционалното програмиране, стига да разберете правилно типовете, всичко трябва да е наред. Както и да приложите този метод, той трябва надеждно да върне нов екземпляр със стойността на вътрешната монада. Ако го направиш, ти си златен.

Интересна възможност възниква от нашия нов метод за присъединяване. Ако помислим отново за нашия верижен метод, той звучи много като метода на картата от нашата дискусия за функтора. Единствената разлика е, че верижният метод приема функция, която връща нова монада, а методът map приема функция, която връща нова проста стойност, която се поставя в нов монаден контекст от самия метод map. Така че, ако вземем верига и делегираме нейното извикване на функция към метода на картата, ще завършим с вложена монада. Функцията, предадена на веригата, връща нова монада и тази монада след това се поставя в нова монада чрез метода map. Въпреки това, ако дефинираме веригата като композиция от map и join, всичко работи. Присъединяването премахва влагането, въведено от map:

// chain :: Identity a -> (a -> Identity b) -> Identity b
Identity.prototype.chain = function(fn) {
  return fn(this.value);
};

Това всъщност не ни помага с нашата монада за идентичност. Нашата първоначална реализация на веригата е по-проста и по-ясна. Тази реализация обаче е специфична за нашата монада за идентичност. Тази реализация е обща за всяка монада, която пишете. Както може би си спомняте, друго име за „верига“ е „flatMap“, а друго име за „join“ е „flatten“. Има смисъл. Дефиницията на верижния метод е съставянето на map и join. Когато започнем да се занимаваме със сложни изчисления, прилагането на тези методи не е толкова ясно. Когато можем да дефинираме методите от гледна точка на други методи, ще направим нещата много по-прости за себе си. Голяма част от това да си успешен програмист е да можеш да разделяш сложните проблеми на по-прости.

Друга форма на композиция се определя от метода concat, известен също като „››“ в Haskell. Методът concat свързва две монадични изчисления заедно, но отхвърля стойността на първото изчисление. Реализация за нашата монада за идентичност може да се дефинира като:

// concat :: Identity a -> Identity b -> Identity b
Identity.prototype.concat = function(id) {
  return this.map(() => id.get());
};

Вероятно е най-лесно да мислите за това като за композиция от асинхронни операции. Ако имате поредица от асинхронни операции, които искате да изпълните, но стойността на една не разчита на стойността на предишната и ние се интересуваме само от последния резултат или ни интересува само всички те да са завършени. Отново, когато прилагате това за по-сложни операции, просто помислете за типовете. Искаме да изпълним първото монадично изчисление и след това второ и да върнем нова монада, съдържаща втората стойност, като отхвърляме първата. Това трябва да е ясно от декларацията за тип в коментара.

Вече имаме пълна реализация на нашата монада за идентичност:

/**
 * An identity monad.
 *
 * @name Identity
 * @class
 * @param {*} val A value to place in our context
 */
function Identity(val) {
  this.value = val;
}

// toString :: Identity a -> String
Identity.prototype.toString = function() {
  return 'Identity(' + this.value + ')';
};

// get :: Identity a -> a
Identity.prototype.get = function() {
  return this.value;
};

// unit :: a -> Identity a
Identity.unit = function(val) {
  return new IO(val);
};

// unit :: a -> Identity a
Identity.prototype.unit = Identity.unit;

// map :: Identity a -> (a -> b) -> Identity b
Identity.prototype.map = function(fn) {
  return new Identity(fn(this.value));
};

// join :: Identity (Identity b) -> Identity b
Identity.prototype.join = function() {
  return new Identity(this.get().get());
};

// chain :: Identity a -> (a -> Identity b) -> Identity b
Identity.prototype.chain = function(fn) {
  return fn(this.value);
};

// concat :: Identity a -> Identity b -> Identity b
Identity.prototype.concat = function(id) {
  return this.map(() => id.get());
};

Утре ще хапна сандвич: IO монадата

Надяваме се, че това започва да предизвиква някои мисли в главата ви за това какво е възможно с модели като тези. Монадата за идентичност не прави нищо интересно. Той илюстрира принципа зад това, което правят методите. Няма много шум в механиката. Лесно можем да видим какво се случва и как работят нещата. Да преминем отвъд принципа. Да направим нещо полезно.

В чистите функционални езици вашият код трябва да бъде чист в математически смисъл. Това означава, че функцията винаги трябва да връща една и съща стойност за един и същ вход и не трябва да променя никакво състояние извън функцията. Функцията е просто преобразуване на вход към изход. Това е лесно да се види, ако имате функция, която събира две числа, но как е възможно това, ако дадена функция трябва да комуникира с потребителя чрез получаване на вход или показване на изход? Ами ако трябва да говорим с база данни? Или сървър на трета страна? За бога, имам jQuery, разпръснат в моя код, актуализирайки DOM навсякъде. Със сигурност това е невъзможно и хората, които програмират на тези езици, очевидно са луди.

Решението произтича от това: това, което искаме нашето приложение да прави, е чисто. Какво всъщност прави, може да се гадае. Поне се надяваме намеренията ни да са чисти, не морално, а функционално. Надяваме се, че когато седнем да пишем приложение, знаем какво искаме да прави. Ние сме го планирали и ние не просто правим удари в тъмното. Ако приемем, че това, което искаме да направи, е чисто, тогава в известен смисъл нашите функции могат да върнат това, което искаме да се случи в света, идеализирана версия на това, което действително се случва.

Ще си позволим илюзията за чистота. Тази илюзия се извършва от монади. Ще обвием стойността, която очакваме да получим от външния свят, или действието, което искаме да извършим във външния свят, в монада. Това понякога се нарича IO монада (Наскоро, благодарение на езика Elm за компилиране към JS, стана популярно да се нарича това задача). Тогава, когато имаме функция, която трябва да изпълни някакъв IO, тя винаги ще връща екземпляр на тази монада. Може би няма да получим низ, или може би нула, или може би грешка въз основа на това, което се случва. Винаги ще получаваме екземпляр на IO монадата. В известен смисъл ни дава същия резултат за същия вход. Този IO е обещание за това, което ще се случи във външната работа. Чували сте тази дума „обещание“ преди, нали?

Голяма част от това, което поддържа тази илюзия, е мързелът. Функцията, която извикваме, всъщност не прави нищо. Той описва какво иска да се случи и връща IO, което е обещание за изпълнение на това действие. Можем да изпълним този IO по-късно. Функцията, която го връща, остава чиста. Той връща същия IO за същия вход, заявка за същото действие. Нещата може да отидат по дяволите, когато наистина го управляваме.

Тъй като използваме монади, за да опишем IO, нашият IO ще може да се съставя. Можем да имаме задачи, които разчитат на други задачи и да се връщат в предвидим ред. Можем да ги изградим. Ние създаваме малки парчета от IO, които са лесни за разбиране, когато ги композираме заедно, завършваме с една IO монада, която представлява изпълнението на всички тези задачи по начина, който описваме с нашите композиции (известни също като комбинатори) . Когато създаваме нашето приложение IO по този начин, по-голямата част от нашето приложение остава чисто и можем да го тестваме, без да изпълняваме неприятния IO.

Обратно към кода...

Така че не трябва да продължаваме без план. Наистина би било контрапродуктивно, ако нашите намерения за IO монадата не бяха чисти (добре дефинирани). Какво трябва да направи? Трябва да извърши някои изчисления. Това звучи много като функция. Трябва да бъде мързелив. Той няма да изпълни функцията, която сме му дали, докато не му кажем. Не можем да оставим приложението ни да се взриви, докато не сме дяволски добри и готови за това. Ще се нуждаем от начин да разберем кога изчислението ни е завършено или кога дава грешка. Това звучи като обратно повикване. Освен това, трябва да приложим същите методи, които използвахме за монадата Identity. Ние сме обвързани с типовете, определени от интерфейсите, към които се придържаме. Ще разберем как да внедрим всеки от тях един по един.

Когато конструираме едно от тези неща, знаем, че ще имаме нужда от изчислението, за да изпълним:

/**
 * An IO monad for wrapping impure operations.
 * - computations will be performed lazily.
 *
 * @name IO
 * @class
 * @param {Function} comp Computation for this IO to run
 */
function IO(comp) {
  this.fn = comp;
};

Знаем, че ще ни трябва начин да го стартираме:

/**
 * @name run
 * @method
 * @memberof IO#
 */
IO.prototype.run = function() {
  this.fn();
};

Какво не е наред с тази снимка? Казахме, че ще ни трябва обратно обаждане, начин да кажем кога това нещо е приключило. Как можем да свържем тези неща заедно, ако не знаем кога едното свършва? Тъй като тези неща са тясно свързани с обещанията, с които вече сме запознати, нека направим нещо подобно:

/**
 * @name run
 * @method
 * @memberof IO#
 * @param {Function} reject A function to call on failure
 * @param {Function} resolve A function to call on success
 */
IO.prototype.run = function(reject, resolve) {
  this.fn(reject, resolve);
};

Сега имаме допълнително изискване към клиентите на нашата монада. Изчислението трябва да бъде функция, която приема два аргумента: функция, която да извика, когато не успее, и функция, която да извика, когато успее.

И ако искаме да отчетем синдрома на глупавия разработчик:

/**
 * @name run
 * @method
 * @memberof IO#
 * @param {Function} reject A function to call on failure
 * @param {Function} resolve A function to call on success
 */
IO.prototype.run = function(reject, resolve) {
  try {
    this.fn(reject, resolve);
  } catch(err) {
    reject(err);
  }
};

Най-лесният от нашите монадични методи за изпълнение е единица. Единицата приема стойност и я поставя в контекста на монадата. IO получава своята стойност чрез извършване на някои изчисления. Ако някаква стойност ще бъде поставена в контекста на IO, тя трябва да се върне като резултат от изчислението. Следователно, за да поставим стойност в контекста на IO монада, трябва да я увием във функция. Така че ще създадем функция, която просто разрешава със стойността, дадена на единица.

/**
 * unit :: a -> IO x a
 *
 * @name unit
 * @method
 * @memberof IO
 * @param {*} val A value to place in the IO context
 * @returns {IO} A new IO with the given value
 */
IO.map = function(val) {
  return new IO((_, resolve) => {
    resolve(val);
  });
};

/**
 * unit :: a -> IO x a
 *
 * @name unit
 * @method
 * @memberof IO#
 * @param {*} val A value to place in the IO context
 * @returns {IO} A new IO with the given value
 */
IO.prototype.unit = IO.unit;

Забележка: „x“ в декларацията на типа показва грешка. IO връща някакъв тип „a“ в случай на успех или някакъв тип „x“ в случай на грешка.

Когато направихме Identity unit не направи нищо повече от извикване на конструктора. Поставянето на нещо в контекста на IO е малко по-сложно. Надяваме се, че това по-добре илюстрира какво е да поставиш стойност в контекста на монада.

Създаваме статични и инстанционни методи. На практика вероятно винаги ще използваме статичния метод, но за пълнота ще го поставим и в прототипа. Използвам долната черта, за да покажа, че не ме интересува методът за отхвърляне. Ние създаваме тази функция. Не трябва да има начин да се провали. Това използване на долна черта е конвенция в няколко функционални езика и ще видите, че тази конвенция се разпростира върху много функционални JavaScript библиотеки.

Нека направим нещо по-интересно. Нека картографираме стойността, върната от IO. Така че map приема унарна функция и връща нов IO с върнатата стойност от извикването на функцията със стойността на IO картата, която е извикана. И така, имаме нужда от IO, който знае как да изпълни предишния IO, когато поискаме от новия IO да се изпълни. Те трябва да се държат като един. Звучи сякаш ще отвлечем изчислението на предишния IO или метода за изпълнение на предишния IO. Мисля, че е по-лесно да се отвлече предишното изчисление и да се обвие с нова функция, която знае как да картографира стойността, върната от предишното изчисление.

Нещо като това:

/**
 * map :: IO x a -> (a -> b) -> IO x b
 *
 * @name map
 * @method
 * @memberof IO#
 * @param {Function} fn A function to map successful values of this IO
 * @returns {IO} A new IO with the mapped value
 */
IO.prototype.map = function(fn) {
  const comp = this.fn;
  return new IO((reject, resolve) => {
    comp(reject, (val) => {
      resolve(fn(val));
    });
  });
};

Ние запазваме препратка към изчислението на картата на екземпляра на IO. След това създаваме нов IO и изграждаме нашето ново изчисление, за да извикаме предишното изчисление. Прекарваме метода за отхвърляне непроменен. С map ние не се интересуваме от това, което се случва в клона за отхвърляне. Ние не променяме нищо там. В клона за разрешаване обаче използваме нова функция, която получава стойността на изчислението, предава я през нашата функция за картографиране и след това дава резултата на обратното извикване за разрешаване. Сега имаме нов IO, който извиква предишното изчисление и картографира стойността му, преди да го даде на обратното извикване за разрешаване. Това прави точно това, което трябва да прави.

След това ще изградим нашия метод за присъединяване. След това можем да използваме състава на map and join, за да създадем нашия верижен метод, както е описано по-рано. Когато правим тези неща, 90% от коректността (напълно измислена оценка) прави нашите типове правилни.

Присъединяването трябва да премахне едно ниво на монадично влагане. Отново ще отвлечем изчислението на инстанцията, към която сме извикани. Сега знаем, че стойността, която връща изчислението, е друг IO. Това е стойността вътре в IO е друга IO. Така че, когато изпълняваме изчислението на нашия външен IO, ние също трябва да изпълним изчислението на нашия вътрешен IO. Отново създаваме нова функция, която да предадем на клона за разрешаване, такава, която извиква изпълнение на стойността, която получава. Тогава, когато този вътрешен IO се върне, ние връщаме тази стойност към първоначалното обратно извикване за разрешаване.

/**
 * join :: IO x (IO x a) -> IO x a
 *
 * @name join
 * @method
 * @memberof IO#
 * @returns {IO} A new IO with one level of nesting removed
 */
IO.prototype.join = function() {
  const comp = this.fn;
  return new IO((reject, resolve) => {
    comp(reject, (val) => {
      val.run(reject, resolve);
    });
  });
};

Тук ясно виждаме влагането, извиквайки изпълнение на стойността, която получаваме в нашето извикване към оригиналното изчисление. В началото може да е малко умопомрачително, но с малко практика след това влагането става по-лесно. Също така не забравяйте, че това е код на библиотека. Вашето приложение трябва да бъде скрито от подробностите за изпълнението. Просто опишете какъв ред искате да изпълняват IO.

Сега, когато имаме карта и присъединяване, веригата е лесна. Работи точно както показахме в Идентичност, композиция от карта и след това присъединяване. Ако нашите функции за map и join са правилни, тогава нашата верижна функция винаги ще бъде правилна.

/**
 * chain :: IO x a -> (a -> IO x b) -> IO x b
 *
 * @name chain
 * @method
 * @memberof IO#
 * @param {Function} fn A function that returns an IO
 * @returns {IO} A new IO that is the composition of two IOs
 */
IO.prototype.chain = function(fn) {
  return this.map(fn).join();
};

Продължавайки, нека добавим метод concat към нашата IO монада. Изглежда като напълно разумен случай на употреба, че може да имаме няколко IO, които искаме да изпълним последователно.

Какво трябва да направи concat? Трябва да вземе IO, за да се изпълни след стартиране на IO, към който се извиква. Това не изглежда много трудно. Просто трябва да изчакаме първото изчисление да завърши, след което да извикаме второто, връщайки стойността на второто към нашето обратно извикване за разрешаване.

/**
 * concat :: IO x a -> IO x b -> IO x b
 *
 * @name concat
 * @method
 * @memberof IO#
 * @param {IO} io An IO to run after this IO
 * @returns {IO} A new IO that is the composition of two IOs
 */
IO.prototype.concat = function(io) {
  const comp = this.fn;
  return new IO((reject, resolve) => {
    comp(reject, (_) => {
      io.run(reject, resolve);
    });
  });
};

Там връщаме един IO, който е съставът на две IO изчисления. Отново е лесно да се види влагането на извикване за изпълнение вътре в извикването на първото изчисление.

И така какво друго?

Със сигурност не сме ограничени от тези методи. Ще си спомните, че в Arrays имахме филтърен метод. Методът на филтриране има много смисъл в структура от данни, която представлява последователност. По отношение на IO ние никога не правим нищо с нашия клон за грешки. Не искаме винаги това нещо да се обърка. Понякога искаме да хванем грешка. Някои реализации имат метод, наречен „mapError“. Този метод прави точно това, което може да мислите, че прави. Той картографира стойността, получена от клона за отхвърляне, обикновено грешка. Аз лично обичам да следвам конвенцията на Scala и да наричам този метод „възстановяване“. Този метод ще работи точно както картата; освен че отвличаме клона за отхвърляне вместо клона за разрешаване.

/**
 * recover :: IO x a -> (x -> a) -> IO x a
 *
 * @name recover
 * @method
 * @memberof IO#
 * @param {Function} fn A function to map errors to values
 * @returns {IO} A new IO that will never fail
 */
IO.prototype.recover = function(fn) {
  const comp = this.fn;
  return new IO((_, resolve) => {
    comp((err) => {
      resolve(fn(err));
    }, resolve);
  });
};

Тук имаме IO, който няма да се провали. Ако нашето първоначално изчисление се провали, ние извикваме функцията за картографиране на грешката и вземаме върнатата стойност от това и я предаваме на обратното извикване за разрешаване, като никога не извикваме отказ.

Нещо много подобно, което може да искаме да направим, е да предоставим стойност по подразбиране, която да замени грешката в случай на грешка. Да кажем, че поискаме списък със снимки от някаква онлайн услуга, можем да заменим грешката с празен списък. Метод като този би бил достатъчно лесен за прилагане по отношение на нашия метод за възстановяване.

/**
 * default :: IO x a -> a -> IO x a
 *
 * @name default
 * @method
 * @memberof IO#
 * @param {*} val A value to replace an error
 * @returns {IO} A new IO that will never fail
 */
IO.prototype.default = function(val) {
  return this.recover(() => val);
};

Всичко това е добре, но нека да дадем бърз пример:

// Mock network request
const fetchResults = () => {
  return new IO((reject, resolve) => {
    setTimeout(() => {
      resolve({
        status : 'success',
        data : ['Bob', 'Sam', 'Marcia', 'Leon', 'Kelly']
      });
    }, 1000);
  });
};

// Displays list of strings in the DOM
const displayResults = (results) => {
  return new IO((reject, resolve) => {
    const list = document.getElementById('fixture');
    const contents = results.reduce((acc, next) => {
      acc += '<li>' + next + '</li>';
      return acc;
    });
  });
};

const toData = (response) => response.data;

const fetchAndDisplay =
  fetchResults().map(toData).chain(displayResults);

// Nothing happens yet

// ...

// Now, the magic
fetchAndDisplay.run(
  (err) => console.log('error: ', err),
  (val) => console.log('success: ', val)
);

Не е най-красивият код, но показва нещо, което в концепцията може разумно да се направи. Ние се подиграваме с мрежова заявка за чистото удобство да можем просто да стартираме това, без да се притесняваме за външни зависимости. Изпълняваме мрежова заявка, връщайки отговор, съдържащ списък с имена. Картираме отговора, като извличаме само частта от данните, от която се нуждаем. След това свързваме това с друг IO, за да покажем списъка с резултати на страницата. Нещо, което е хубаво, е нашата декларация за fetchAndDisplay. Чете се малко като изречение. Има логичен ред в начина, по който дефинираме нашия IO. Ако мрежовата заявка или манипулациите на DOM са неуспешни, ние ще регистрираме грешката в конзолата.

Ще забележите, че не извикваме resolve във функцията displayResults. Това действие няма какво да ни върне. Той просто има страничен ефект. В обектно-ориентираните езици типът връщане на функция като тази често се нарича void. Във функционалните езици този празен тип връщане често се нарича Unit, изразен като празен кортеж „()“. Така че можем да кажем, че типът връщане на нашата функция displayResults е IO void или IO (). Тъй като функциите на JavaScript винаги имплицитно връщат недефинирано, ако няма израз за връщане, истинската стойност на тази функция е IO недефинирано.

Важното е накрая. Както бе споменато в коментарите на кода, нищо не се случва, докато не извикаме run на IO. До този момент нашият код е чист. Всички грешки, които могат да възникнат, са синтактични или логически грешки от името на разработчика. Не можем да обвиняваме мрежов срив или някаква друга IO злополука.

До следващата среща

Надяваме се, че разбирате как работят монадите или подобните на монади обекти. Те не са толкова страшни. Те трябва да се чувстват познати. Тяхното истинско предимство е възможността за предсказуемо съставяне на операции и логично абстрактно изпълнение. Някои от жаргоните, свързани с функционалното програмиране, могат да бъдат малко отблъскващи за хората, които са нови в него. Монади? Функционери? Приложни? Моноиди? Забележете, жаргонът може да бъде отблъскващ за хората, запознати с функционалното програмиране, но не са математици. Това може да отнеме известно време да свикнете.

В света на JavaScript всъщност не сме свикнали с цялата структура, свързана с функционалното програмиране. Въпреки че сме свободни да заемаме парченца. Някои разработчици отделиха известно време, за да се настроят да използват функционалните методи на Arrays или да използват Promise API за AJAX заявки. Къде са простите ми обратни извиквания, по дяволите?

Това, което тези неща правят за вас, като разработчик на приложения, е да позволят на кода на приложението ви да описва повече какво трябва да прави приложението ви. Ще има режийни разходи за стойностите, които използвате във вашето приложение. Опаковането на вашите ценности в монадичен контекст ще ви позволи по-последователно да се справяте с тях. Използването на абстракции като IO, за да запазите голяма част от вашето приложение без състояние, честно ще изисква малко режийни разходи от ваше име, за да планираме как трябва да работи вашият IO, но това ще помогне да запазите по-голямата част от вашия код предвидим, изолирайки най-опасните части от вашия код.

Опитайте сами: Друга често срещана монада, за която споменах, но не я разгледах, е монадата Може би или Незадължителна. Използва се за обвиване на всички потенциално нулеви стойности във вашия код, така че винаги да можете да се справяте със стойността, сякаш тя съществува, а Maybe се занимава с нулевите проверки вместо вас, още една малка абстракция.

Допълнителна информация:

  1. „Повечето адекватно ръководство за функционално програмиране“
  2. Страница на Уикипедия за монадите (теория на категориите)
  3. Нежно въведение в монадите в Haskell

Първоначално публикувано на https://www.linkedin.com на 2 май 2016 г.