В тази статия ще изследвам монадите и по-специално IO монадата в Javascript (или Typescript). Искам да разбера как се сравнява с други подходи като функционална ядро ​​императивна обвивка, инжектиране на зависимости и техники, които използваме в езици, които не са чисто функционални. Ще опиша темата така, както я разбирам и не претендирам, че съм експерт по никоя от тях. Всъщност цялото това нещо е просто стремеж да се опитаме да разберем как да създаваме по-добри програми. Ако съм сбъркал нещо, моля, уведомете ме!

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

https://medium.com/@magnusjt/functional-core-imperative-shell-in-javascript-29bef2353ac2

Забележка: Ще използвам машинопис в моите примери. Надяваме се, че това ще бъде по-лесно за разбиране от системата тип Haskell.

Нека започнем с разглеждане на монадите като цяло

Какво е монада?

Накратко, монада е всичко, което може да бъде картографирано и flatMapped (въпреки че map винаги може да бъде извлечено от гледна точка на flatMap, така че не е толкова важно)

  • map е функция, която приема функция (val: T) =› T и връща нова монада (от същия тип като това, което сте извикали map on).

С други думи, map ви позволява да промените нещото, което живее вътре в монадата.

  • flatMap е функция, която приема функция (val: T) =› Monad‹T› и връща нова монада (от същия тип като това, което сте нарекли flatMap).

Така че flatMap е просто карта, последвана от flat, където flat разопакова монадата, която сте върнали.

Примери

Масивът е (почти) монада:

[1, 2, 3].map(x => x * 2) // [2, 4, 6]
[1, 2, 3].flatMap(x => [x, 10]) // [1, 10, 2, 10, 3, 10]

Това е „почти“ монада, защото map и flatMap поддържат повече от един аргумент, но това не е важно в момента.

Друг пример, който е (почти) монада, е обещание:

// map
Promise.resolve(5)
    .then(x => x * 2) // Promise of 10

// flatMap
Promise.resolve(5)
    .then(x => Promise.resolve(x * 2)) // Promise of 10

Това е „почти“ монада, защото

  • flatMap се нарича „тогава“
  • „тогава“ върши и работата на карта
  • „тогава“ може да приеме повече от един аргумент

Но по същество това е монада.

Забавен факт:

Когато спецификацията на обещанието беше разработена, някои хора твърдяха, че обещанията трябва да бъдат правилни монади (https://github.com/promises-aplus/promises-spec/issues/94), но те бяха затворени и им казаха, че са живеещи във фантастична земя.

Fantasy land вече е реален проект:

https://github.com/fantasyland/fantasy-land

Какво всъщност е монада?

Добре, има още малко. Монадата трябва да бъде създадена по някакъв начин, така че имаме нужда от някаква функция, която приема стойност и ни дава монада. Ще наречем тази функция „на“. В случай на масив, „от“ са само скобите [x] с една стойност вътре. В случай на promise, "of" е просто Promise.resolve.

Ако четете за монадите другаде, имайте предвид, че имената на нещата могат да бъдат различни.

  • на се нарича също точка, чист, връщане и т.н
  • flatMap се нарича още верига, свързване и т.н

Монадата също трябва да следва монадните закони (вижте https://miklos-martin.github.io/learn/fp/2016/03/10/monad-laws-for-regular-developers.html).

Ето кратко описание (чувствайте се свободни да замажете това).

1. Лява идентичност: Ако създадете монада с „of“ и изпълните flatMap(f), трябва да получите същия резултат, както просто да изпълните f.

const f = x => [x, 10]
[1].flatMap(f) 
// should equal 
f(1)

2. Правилна идентичност: Ако имате монада и стартирате flatMap(of), получавате същата монада

[1, 2, 3].flatMap(x => [x]) 
// should equal 
[1, 2, 3]

3. Асоциативност: Не трябва да има значение колко са вложени flatMap.

[1, 2, 3].flatMap(x => [x, 10]).flatMap(x => [x, 20])
// should equal
[1, 2, 3].flatMap(x => [x, 10].flatMap(x => [x, 20]))

Добре, сериозно, какво е монада?

Наистина няма нищо друго. Монадата е просто спецификация на поведението на някои функции. Какво всъщност правят тези функции зависи изцяло от вас.

Добре… И така, как е полезно?

Сега това е интересен въпрос. Монадата е полезна, защото:

  • Това е стандартен начин за правене на нещата. Монада от всякакъв вид може да бъде управлявана от всичко, което познава законите на монадата.
  • Една монада крие нещо от вас. Това може да бъде всичко, като контролен поток, грешки, странични ефекти, състояние, среда и т.н.

Но чух, че монадите са изключително важни за функционалното програмиране. Каква е сделката?

Всъщност не се нуждаете от монади за функционално програмиране. Но със сигурност правят някои неща по-лесни на определени езици.

По-специално, нещото, за което повечето хора изглежда говорят, когато говорят за монади, е IO монадата. Целта на IO монадата беше да извършва вход/изход на нестриктен, чисто функционален език като Haskell.

Но дали монадите са единственият начин за постигане на тази цел? Не! Haskell не винаги е имал IO монада (използвал е нещо, наречено Диалози).

Препоръчвам да прочетете тази статия за IO монадата за някакъв контекст:

https://www.microsoft.com/en-us/research/wp-content/uploads/1993/01/imperative.pdf

Друг подход за въвеждане/извеждане на чист език са продълженията (помислете: обратни извиквания). Ето какво мислят авторите на статията за IO monad относно продълженията:

„Този ​​допълнителен аргумент е необходим за всяка I/O-изпълняваща функция, ако трябва да бъде съставима, широко разпространена и уморителна функция.“

С други думи, те искаха да се отърват от ада на обратното извикване (Здравейте Javascript общност през 2015 г.!). Те направиха това, използвайки монади (точно както общността на Javascript направи с обещанията).

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

Монадата IO

Досега някак знаем какво е монада, но какво прави монадата конкретно IO монада? Знаем, че по някакъв начин е свързано с вход/изход, или с други думи: ефекти.

Ефектът е просто нещо, което трябва да се случи (и то не трябва да не се случи) и може да е нечисто.

Защо е толкова важно това трябва да се случи? Това е така, защото в нестриктен, чисто функционален език, като Haskell, оценката на нещата е мързелива.
Освен това, тъй като всичко е чисто, Haskell е свободен да мемоизира резултата от всяка функция (поне на теория, на практика това води до изтичане на памет).
Това не работи добре с ефектите, тъй като те може да се случи в грешен ред, може да се случи само веднъж, въпреки че са били извикани няколко пъти, или може изобщо да не се случи.
IO монадата решава тези проблеми. Въпреки това: Имайте предвид, че нямаме тези проблеми в повечето други езици за програмиране и затова не е нужно да преминаваме през толкова много обръчи, колкото Haskell. Освен това това може да означава, че IO монадата не е толкова полезна в повечето езици.

В Haskell IO ефектът се дефинира нещо подобно (преобразуван в машинопис):

(свят: RealWorld) =› [свят: RealWorld, стойност: T]

Така че просто функция, която приема „реалния свят“, връща нов свят и някаква стойност, която е резултат от ефекта. „Реалният свят“ всъщност се използва само от системата за типове, за да се гарантира, че ефектите се изпълняват и се изпълняват в правилния ред. Световната стойност изчезва по време на компилация.

Не се нуждаем от гаранциите, които световната стойност дава в Haskell, така че можем просто да го пропуснем и да дефинираме ефект като:

() => T

Така че това е просто функция, която връща стойност („стойността“ също може да бъде невалидна).

Сега, за да направим монада от това, имаме нужда от:

  • of
  • плоска карта
  • Някои удобства, например клас IO

Ето първия ми опит за IO монада в машинопис:

import fs from 'fs'

type Effect<T> = () => T

class IO<A>{
    private effect: Effect<A>
    constructor(effect: Effect<A>){
        this.effect = effect
    }
    static of<T>(val: T){
        return new IO(() => val)
    }
    map<B>(f: (val: A) => B): IO<B>{
        return new IO(() => f(this.effect()))
    }
    flatMap<B>(f: (val: A) => IO<B>): IO<B>{
        return new IO(() => f(this.effect()).effect())
    }
    eval(){
        return this.effect()
    }
}

Както можете да видите, имаме методите на, map и flatMap, както е описано по-рано. „Стойността“ вътре в монадата е ефект. Имаме и функция „eval“, която всъщност изпълнява всички ефекти.

Можем да използваме IO монадата за четене и запис в някои файлове:

const readFile = (fileName, opts?) => 
    new IO(() => fs.readFileSync(fileName, opts))

const writeFile = (fileName, data, opts?) => 
    new IO(() => fs.writeFileSync(fileName, data, opts))

const program = readFile('monad.txt', 'utf8')
    .map(content => content + ' more content')
    .flatMap(content => writeFile('monad2.txt', content, 'utf8'))
    .flatMap(() => readFile('monad2.txt', 'utf8'))

const result = program.eval()
console.log(result)

Тук имаме два ефекта в readFile и writeFile. Ние също имаме нашата „програма“, която сама по себе си е IO монада. Ефектите се изпълняват едва когато извикаме eval() на нашата програма, така че преди извикването на eval имаме напълно чиста програма. Все още можем да напишем нашата програма, сякаш извикваме нечисти функции, дори ако всъщност не го правим.

Все пак има няколко проблема:

  • Как да тестваме това? Въпреки че файлът за четене/запис не се изпълнява преди извикването на eval, може би бихме искали да им се подиграем за тест
  • Използваме синхронизирани версии на fs. Трябва да използваме async
  • Синтаксисът не е много хубав. Искаме нещо като async await

За щастие можем да поправим всичко това. Нека започнем с добавяне на захарен синтаксис, подобен на async await. Това е, което се нарича do-notation в Haskell.

Направете нотация

const _do = (fn: (...args: any[]) => Generator) => (...args) => {
    const gen = fn(...args)

    const next = (val?) => {
        const res = gen.next(val)
        if(!res.done) return res.value.flatMap(next)
        if(res.value && res.value.flatMap) return res.value
        return IO.of(res.value)
    }

    return next()
}

const program2 = _do(function * (){
    let content = yield readFile('monad.txt', 'utf8')
    content = content + ' more content'
    yield writeFile('monad2.txt', content, 'utf8')
    return readFile('monad2.txt', 'utf8')
})

const result2 = program2().eval()
console.log(result2)

Трябва да използваме генератори, но това е почти същият синтаксис като async await.

Ето нещо за размисъл: ако тази „чиста“ програма изглежда точно като нечистия аналог, как може да бъде по-добра?

(Забележка: Тази нотация „do“, която току-що измислих, не работи за всички монади за съжаление. Генераторите не са достатъчно мощни за това. Вижте: https://github.com/pelotom/burrido).

Тестване

Сега нека направим нещо за тестването. Нека инжектираме ефектите, вместо да ги изпълняваме директно. Ефектите вече се дефинират като (ефекти: Ефекти) =› T вместо просто () =› T.

Ето нашата нова IO монада с инжектирани ефекти:

import fs from 'fs'

type Effects = {
    readFile: (fileName, opts?) => string
    writeFile: (fileName, data, opts?) => void
}

type Effect<T> = (effects: Effects) => T

class IO<A>{
    private effect: Effect<A>
    constructor(effect: Effect<A>){
        this.effect = effect
    }
    static of<T>(val: T){
        return new IO(() => val)
    }
    map<B>(f: (val: A) => B): IO<B>{
        return new IO((effects) => f(this.effect(effects)))
    }
    flatMap<B>(f: (val: A) => IO<B>): IO<B>{
        return new IO((effects) => f(this.effect(effects)).effect(effects))
    }
    eval(effects: Effects){
        return this.effect(effects)
    }
}

Вече можем да напишем нашата програма по следния начин (Имайте предвид, че program2 е непроменена):

const readFile = (fileName, opts?) =>
    new IO((effects: Effects) => effects.readFile(fileName, opts))

const writeFile = (fileName, data, opts?) =>
    new IO((effects: Effects) => effects.writeFile(fileName, data, opts))

const program2 = _do(function * (){
    let content = yield readFile('monad.txt', 'utf8')
    content = content + ' more content'
    yield writeFile('monad2.txt', content, 'utf8')
    return readFile('monad2.txt', 'utf8')
})

const effects: Effects = {
    readFile: (fileName, opts?) =>
        fs.readFileSync(fileName, opts).toString(),

    writeFile: (fileName, data, opts?) =>
        fs.writeFileSync(fileName, data, opts)
}

const result2 = program2().eval(effects)
console.log(result2)

Вече имаме способността да изпращаме нашите опасни ефекти, когато извикваме eval. Това ни позволява да правим чисти версии на нашите ефекти за тестване - но въпреки че имаме способносттада тестваме, това не е непременно лесноза тестване.

Като изпращаме нашите ефекти като този, комбинирани с do-notation, ние позволяваме нещо, което също наподобява алгебрични ефекти (вижте: https://overreacted.io/algebraic-effects-for-the-rest-of-us/ )

Поддържащи асинхронни ефекти

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

Свързвайки всичко заедно

Какво постигнахме с IO монадата?

  • Написахме програма, в която всяка част от програмата беше чиста (с изключение на ефектите на листовия възел и стойността на горния възел). Можем да извикаме всяка функция в такава програма, без да се изпълняват странични ефекти, с изключение на едно извикване на eval, за да започне всичко.
  • Използвахме техника (монадата), която можеше да се използва и за много други неща. Всяка функция, която бихме могли да напишем за работа с монади, би била полезна и за много други монади.
  • Можем да извършим инжектиране на зависимости на нашите ефекти. Имайте предвид също, че бихме могли да направим това по много други начини. Можехме да оставим „ефектите“ просто да описват ефектите, вместо да ги изпълняваме. Би било възможно действително да стартирате ефектите на съвсем различно място. Един вид преводач.

Какво НЕ постигнахме?

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

Сравнявайки го с инжектиране на зависимост

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

Сравнявайки го с функционално ядро, императивна обвивка

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

В известен смисъл IO монадата ни позволява да напишем цялата програма като функционално ядро ​​(тъй като всичко вече е чисто), така че това трябва да правим, нали? Е, работата е там, че въпреки че нещата са технически чисти, ние не получаваме никакви предимства от това, че са чисти извън кутията. Програмата не е по-предсказуема или тествана, освен ако не направим нещо повече от просто използване на IO монада. Основният проблем е, че така нареченият чист код диктува кога и как да се изпълнява нечистият код. След това трябва да се увери, че обработва грешките както обикновено и трябва да използваме инжектиране на зависимости, както бихме иначе.

За да обобщим, не бих разглеждал IO монадата като част от функционално ядро.

Заключение

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

Доколкото мога да преценя, основното предимство на IO монадата в език като Javascript е способността да съставяме програми по различен начин, по-специално начина, по който можем да правим инжектиране на зависимости, без да предаваме функции (или класове) като параметри към междинни функции. Обърнете внимание също как това прилича на алгебричните ефекти, описани тук: https://overreacted.io/algebraic-effects-for-the-rest-of-us/

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

Имате ли опит с монади в Javscript/Typescript? Моля, уведомете ме в коментарите!