Перш ніж ми підемо далі, я маю зробити зізнання: я не був повністю чесним щодо методу of
, який ми помістили в кожен з наших типів. Виявляється, він не для того, щоб уникнути ключового слова new
, а щоб помістити значення в те, що називається мінімальним стандартним контекстом. Так, of
насправді не замінює конструктор - це частина важливого інтерфейсу, який ми називаємо Направленим (Pointed).
Направлений функтор — це функтор з методом
of
Тут важливою є можливість кинути будь-яке значення в наш тип щоб одразу ж розпочати мапінг.
IO.of('tetris').map(concat(' master'));
// IO('tetris master')
Maybe.of(1336).map(add(1));
// Maybe(1337)
Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])
Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')
Якщо ви пам'ятаєте, конструктори IO
і Task
очікують функцію як свій аргумент, але Maybe
і Either
— ні. Мотивація для цього інтерфейсу полягає в тому, щоб мати загальний, послідовний спосіб поміщення значення у наш функтор без складнощів і специфічних вимог конструкторів. Термін "дефолтний мінімальний контекст" не є точним, але добре передає ідею: ми хочемо підняти будь-яке значення у наш тип і використовувати map
як зазвичай з очікуваною поведінкою будь-якого функтора.
Одне важливе виправлення, яке я повинен зробити на цьому етапі, це те, що Left.of
не має сенсу. Кожен функтор повинен мати один спосіб поміщення значення всередину, і для Either
це new Right(x)
. Ми визначаємо of
за допомогою Right
, тому що якщо наш тип може використовувати map
, він повинен використовувати map
. Дивлячись на наведені вище приклади, ми можемо мати уявлення, як зазвичай працює of
, і Left
ламає цей шаблон.
Можливо, ви чули про функції, такі як pure
, point
, unit
і return
. Це різні назви для нашого методу of
, міжнародної функції-загадки. of
стане важливим, коли ми почнемо використовувати монади, тому що, як ми побачимо, це наша відповідальність — вручну повертати значення у тип.
Щоб уникнути ключового слова new
, існує кілька стандартних трюків або бібліотек JavaScript, тому давайте використовувати їх і відтепер використовувати of
як відповідальна доросла людина. Я рекомендую використовувати екземпляри функтора з бібліотек folktale
, ramda
або fantasy-land
, оскільки вони забезпечують правильний метод of
, а також хороші конструктори, які не залежать від new
.
Розумієте, окрім космічних буріто (якщо ви чули чутки), монади схожі на цибулю. Дозвольте продемонструвати це на поширеній ситуації:
const fs = require('fs');
// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));
// print :: String -> IO String
const print = x => new IO(() => {
console.log(x);
return x;
});
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))
Що ми маємо тут, це IO
, який потрапив всередину іншого IO
, тому що print
предвставив другий IO
під час нашого map
. Щоб продовжити роботу з нашим рядком, ми повинні використовувати map(map(f))
, а щоб спостерігати ефект, ми повинні викликати unsafePerformIO().unsafePerformIO()
.
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);
catFirstChar('.git/config');
// IO(IO('['))
Хоча приємно бачити, що ми маємо два ефекти, запаковані та готові до використання в нашій програмі, це відчувається так, ніби ми працюємо у двох захисних костюмах, і в результаті отримуємо незручно громіздкий API. Давайте розглянемо іншу ситуацію:
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp('street'))),
map(safeHead),
safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))
Знову ми бачимо цю ситуацію з вкладеними функторами, де приємно бачити, що у нашій функції є три можливі помилки, але очікувати від того, хто викликав, що він буде використовувати map
три рази, щоб отримати значення - це трохи зухвало, особливо коли ми тільки познайомилися. Цей шаблон буде з’являтися знову і знову, і саме в таких ситуаціях нам потрібно висвітлити могутній символ монади в нічному небі.
Я сказав, що монади схожі на цибулю, тому що сльози навертаються, коли ми знімаємо кожен шар вкладеного функтора за допомогою map
, щоб дістатися до внутрішнього значення. Ми можемо витерти очі, глибоко вдихнути і використати метод під назвою join
.
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))
mmo.join();
// Maybe('nunchucks')
const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))
ioio.join();
// IO('pizza')
const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));
ttt.join();
// Task(Task('sewers'))
Якщо у нас є два шари одного типу, ми можемо об'єднати їх за допомогою join
. Ця здатність об'єднуватися, цей функторний шлюб, є тим, що робить монаду монадою. Давайте перейдемо до повного визначення, використовуючи більш точне формулювання:
Монади — це направлені функтори, які можуть бути сплощені
Будь-який функтор, який визначає метод join
, має метод of
і дотримується кількох законів, є монадою. Визначити join
не надто складно, тому давайте зробимо це для Maybe
:
Maybe.prototype.join = function join() {
return this.isNothing() ? Maybe.of(null) : this.$value;
};
Ось, просто як поглинання свого близнюка в утробі. Якщо у нас є Maybe(Maybe(x))
, то .$value
просто видалить зайвий шар, і ми можемо безпечно використовувати map
звідти. Інакше у нас буде лише один Maybe
, оскільки нічого не було б відображено спочатку.
Тепер, коли у нас є метод join
, давайте посипемо трохи магічного монадного пилу на приклад firstAddressStreet
і побачимо його в дії:
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();
// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead), safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})
Ми додали join
всюди, де зустрічали вкладені Maybe
, щоб вони не виходили з-під контролю. Давайте зробимо те ж саме з IO
.
IO.prototype.join = function() {
const $ = this;
return new IO(() => $.unsafePerformIO().unsafePerformIO());
};
Ми просто об'єднуємо виконання двох шарів IO послідовно: зовнішній, потім внутрішній. Зверніть увагу, ми не відмовилися від чистоти, а лише перепакували зайві два шари захисної плівки в одну легшу для відкриття упаковки.
// log :: a -> IO a
const log = x => new IO(() => {
console.log(x);
return x;
});
// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
curry((sel, props) => new IO(() => jQuery(sel).css(props)));
// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));
// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>
getItem
повертає IO String
, тому ми використовуємо map
для його розбору. Як log
, так і setStyle
повертають IO
, тому ми повинні використовувати join
, щоб тримати наше вкладення під контролем.
You might have noticed a pattern. We often end up calling join
right after a map
. Let's abstract this into a function called chain
.
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// or
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));
Ми просто об'єднаємо цю комбінацію map/join в одну функцію. Якщо ви раніше читали про монади, ви могли бачити, що chain
називається >>=
(вимовляється як bind) або flatMap
, які є синонімами для тієї ж концепції. Я особисто вважаю, що flatMap
є найбільш точним ім'ям, але ми будемо використовувати chain
, оскільки це широко прийнята назва в JS. Давайте рефакторимо два приклади вище з використанням chain
:
// map/join
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead),
safeProp('addresses'),
);
// chain
const firstAddressStreet = compose(
chain(safeProp('street')),
chain(safeHead),
safeProp('addresses'),
);
// map/join
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
// chain
const applyPreferences = compose(
chain(setStyle('#main')),
chain(log),
map(JSON.parse),
getItem,
);
Я замінив усі map/join
нашою новою функцією chain
, щоб трохи навести порядок. Охайність - це добре, але в chain
є щось більше, ніж здається на перший погляд - це більше схоже на торнадо, ніж на пилосос. Оскільки chain
без зусиль вкладає ефекти, ми можемо захопити як послідовність, так і призначення змінних у чисто функціональний спосіб.
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
.chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);
// querySelector :: Selector -> IO DOM
querySelector('input.username')
.chain(({ value: uname }) =>
querySelector('input.email')
.chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`))
);
// IO('Welcome Olivia prepare for spam at [email protected]');
Maybe.of(3)
.chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);
Maybe.of(null)
.chain(safeProp('address'))
.chain(safeProp('street'));
// Maybe(null);
Ми могли б написати ці приклади за допомогою compose
, але нам знадобиться кілька допоміжних функцій, і цей стиль, в будь-якому випадку, більше підходить для явного призначення змінних через замикання. Замість цього, ми використовуємо інфіксну версію chain
, яка, до речі, може бути виведена з map
і join
для будь-якого типу автоматично: t.prototype.chain = function(f) { return this.map(f).join(); }
. Ми також можемо визначити chain
вручну, якщо хочемо отримати хибне відчуття продуктивності, хоча ми повинні бути обережними, щоб зберегти правильну функціональність - тобто, він має дорівнювати map
, після чого слідує join
. Цікавим фактом є те, що ми можемо безкоштовно отримати map
, якщо створили chain
, просто повертаючи значення назад за допомогою of
. З chain
ми також можемо визначити join
як chain(id)
. Це може виглядати, як грати в Texas Hold'em з фокусником з блискучими каменями, наче я просто витягаю речі з нізвідки, але, як і в більшості математичних принципів, усі ці конструкції пов'язані між собою. Багато з цих виводів згадуються в репозиторії fantasyland, який є офіційною специфікацією для алгебраїчних типів даних у JavaScript.
А тепер давайте перейдемо до прикладів вище. У першому прикладі ми бачимо два Task
, зв'язані в послідовність асинхронних дій - спочатку отримуємо user
, потім знаходимо друзів з ідентифікатором цього користувача. Ми використовуємо chain
, щоб уникнути ситуації Task(Task([Friend]))
.
Далі, ми використовуємо querySelector
, щоб знайти кілька різних полів вводу та створити привітальне повідомлення. Зверніть увагу, що ми маємо доступ до обох uname
і email
у найвнутрішній функції - це функціональне призначення змінних у найкращому вигляді. Оскільки IO
люб'язно надає нам своє значення, ми відповідаємо за те, щоб повернути його на місце - ми не хочемо порушити його довіру (і наш програмний код). IO.of
є ідеальним інструментом для цієї задачі, і саме тому Pointed є важливою передумовою для інтерфейсу монади. Однак, ми можемо вибрати map
, оскільки це також поверне правильний тип:
querySelector('input.username').chain(({ value: uname }) =>
querySelector('input.email').map(({ value: email }) =>
`Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at [email protected]');
Нарешті, у нас є два приклади з використанням Maybe
. Оскільки chain
виконує map під капотом, якщо будь-яке значення є null
, ми зупиняємо обчислення на місці.
Не хвилюйтеся, якщо ці приклади спочатку важко зрозуміти. Пограйте з ними. Поколупайте їх патичком. Розбийте їх на частини та зберіть знову. Пам’ятайте використовувати map
, коли повертаєте "нормальне" значення, і chain
, коли повертаєте інший функтор. У наступному розділі ми розглянемо Applicatives
(аплікативи) і побачимо приємні трюки, щоб зробити такі вирази приємнішими та більш читабельними.
Як нагадування, це не працює з двома різними вкладеними типами. У цій ситуації нам можуть допомогти композиція функтора та, пізніше, монадні трансформери.
Програмування в стилі контейнерів іноді може бути заплутаним. Іноді ми боремося з тим, щоб зрозуміти, скільки на контейнерів в глибину знаходиться значення, або чи потрібно нам використовувати map
чи chain
(незабаром ми побачимо більше методів для контейнерів). Ми можемо значно покращити відлагодження за допомогою трюків, таких як реалізація inspect
, і ми дізнаємося, як створити "стек", який може обробити будь-які ефекти, які ми на нього накладемо, але бувають моменти, коли ми задаємося питанням, чи варте це зусиль.
Я хотів би на мить махнути вогняним мечем монад, щоб продемонструвати силу програмування в такий спосіб.
Давайте прочитаємо файл, а потім завантажимо його безпосередньо після цього:
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);
Тут ми кілька разів розгалужуємо наш код. Дивлячись на підписи типів, я бачу, що ми захищаємося від тьох помилок - readFile
використовує Either
для перевірки введення (можливо, переконуючись, що ім'я файлу присутнє), readFile
може помилитися при доступі до файлу, як виражено в першому параметрі типу Task
, і завантаження може зазнати невдачі з будь-якої причини, що виражається помилкою в httpPost
. Ми спокійно виконуємо дві вкладені, послідовні асинхронні дії за допомогою chain
.
Все це досягається в одному лінійному потоці зліва направо. Все чисто і декларативно. Це зберігає рівняння та надійні властивості. Ми не змушені додавати непотрібні та заплутані імена змінних. Наша функція upload
написана проти загальних інтерфейсів, а не конкретних одноразових API. Заради всього на Світі, це ж всього один клятий рядок.
Для контрасту давайте подивимось на стандартний імперативний спосіб виконання цього завдання:
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
if (!filename) {
throw new Error('You need a filename!');
} else {
readFile(filename, (errF, contents) => {
if (errF) throw errF;
httpPost('/uploads', contents, (errH, json) => {
if (errH) throw errH;
callback(json);
});
});
}
};
Ну хіба це не арифметика диявола? Нас проштовхують по нестабільному лабіринту безумства. Уявіть, що це була б типова програма, яка ще й змінювала змінні по ходу виконання! Ми дійсно потрапили б у болото.
Перше правило, яке ми розглянемо, це асоціативність, але, можливо, не в тому вигляді, до якого ви звикли.
// асоціативність
compose(join, map(join)) === compose(join, join);
Ці правила відображають вкладену природу монад, тому асоціативність зосереджується спочатку на об'єднанні внутрішніх або зовнішніх типів для досягнення того самого результату. Малюнок може бути більш наочним:
Починаючи з верхнього лівого кута та рухаючись вниз, ми можемо спочатку об'єднати зовнішні два M
у M(M(M a))
, а потім перейти до бажаного M a
з іншим join
. Або ж ми можемо розкрити внутрішні два M
за допомогою map(join)
. Ми отримаємо той самий M a
, незалежно від того, чи об'єднаємо ми спочатку внутрішні або зовнішні M
, і саме про це йдеться в асоціативності. Варто зазначити, що map(join) != join
. Проміжні кроки можуть відрізнятися за значенням, але кінцевий результат останнього join
буде однаковим.
Друге правило є схожим:
// ідентичність для всіх (M a)
compose(join, of) === compose(join, map(of)) === id;
Воно стверджує, що для будь-якої монади M
, of
і join
дорівнює id
. Ми також можемо використовувати map(of)
і атакувати його зсередини. Ми називаємо це "трикутною ідентичністю", тому що вона утворює таку форму при візуалізації:
Якщо ми почнемо з верхнього лівого кута, рухаючись праворуч, ми побачимо, що of
дійсно поміщає наш M a
в інший контейнер M
. Потім, якщо ми рухатимемося вниз і використаємо join
, ми отримаємо те саме, що і при виклику id
спочатку. Рухаючись справа наліво, ми бачимо, що якщо підходити зсередини за допомогою map
і викликати of
для простого a
, ми все одно отримаємо M (M a)
, і join
поверне нас на початкову позицію.
Варто зазначити, що я просто написав of
, проте це має бути конкретний M.of
для тієї монади, яку ми використовуємо.
Тепер, я десь бачив ці закони, ідентичність і асоціативність, раніше... Почекайте, я думаю... Так, звичайно! Це закони категорії. Але це означало б, що нам потрібна функція композиції для завершення визначення. Ось вона:
const mcompose = (f, g) => compose(chain(f), g);
// ліва ідентичність
mcompose(M, f) === f;
// права ідентичність
mcompose(f, M) === f;
// асоціативність
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));
Вони є законами категорії, врешті-решт. Монади формують категорію, яку називають "категорією Клейслі", де всі об'єкти є монадами, а морфізми - це зв'язані функції. Я не хочу дражнити вас частинами теорії категорій без достатнього пояснення, як всі частини пазла складаються разом. Намір полягає в тому, щоб трохи заглибитися, показати актуальність і викликати інтерес, зосереджуючись на практичних властивостях, які ми можемо використовувати щодня.
Монади дозволяють нам занурюватися у вкладені обчислення. Ми можемо призначати змінні, виконувати послідовні ефекти, виконувати асинхронні завдання, і все це без побудови піраміди жаху. Вони приходять на допомогу, коли значення виявляється замкненим у кількох шарах одного типу. За допомогою надійного помічника "pointed" монади можуть надати нам розпаковане значення та знати, що ми зможемо повернути його назад, коли закінчимо.
Так, монади дуже потужні, але ми все одно відчуваємо потребу в деяких додаткових функціях для контейнерів. Наприклад, що, якщо ми хочемо запустити список викликів API одночасно, а потім зібрати результати? Ми можемо виконати це завдання за допомогою монад, але нам доведеться чекати завершення кожного виклику перед тим, як викликати наступний. А що щодо поєднання кількох валідацій? Ми хотіли б продовжити валідацію, щоб зібрати список помилок, але монади зупинили б процес після першої появи Left
.
У наступному розділі ми побачимо, як аплікативні функтори вписуються у світ контейнерів і чому в багатьох випадках ми надаємо їм перевагу перед монадами.
Розділ 10: Аплікативні функктори
Розглянемо об'єкт User наступним чином:
const user = {
id: 1,
name: 'Albert',
address: {
street: {
number: 22,
name: 'Walnut St',
},
},
};
{% exercise %}
Використовуйте safeProp
і map/join
або chain
, щоб безпечно отримати назву вулиці при передачі користувача.
{% initial src="./exercises/ch09/exercise_a.js#L16;" %}
// getStreetName :: User -> Maybe String
const getStreetName = undefined;
{% solution src="./exercises/ch09/solution_a.js" %} {% validation src="./exercises/ch09/validation_a.js" %} {% context src="./exercises/support.js" %} {% endexercise %}
Тепер розглянемо наступні елементи:
// getFile :: IO String
const getFile = IO.of('/home/mostly-adequate/ch09.md');
// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));
{% exercise %}
Використовуйте getFile, щоб отримати шлях до файлу, видаліть директорію та залиште лише базове ім'я файлу,
потім чисто залогуйте його. Підказка: можливо, ви захочете використовувати split
і last
, щоб отримати
базове ім'я з шляху до файлу.
{% initial src="./exercises/ch09/exercise_b.js#L13;" %}
// logFilename :: IO ()
const logFilename = undefined;
{% solution src="./exercises/ch09/solution_b.js" %} {% validation src="./exercises/ch09/validation_b.js" %} {% context src="./exercises/support.js" %} {% endexercise %}
Для цієї вправи ми розглянемо помічники з наступними сигнатурами:
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()
{% exercise %}
Використовуйте validateEmail
, addToMailingList
і emailBlast
, щоб створити функцію,
яка додає новий email до списку розсилки, якщо він валідний, і потім повідомляє весь список.
{% initial src="./exercises/ch09/exercise_c.js#L11;" %}
// joinMailingList :: Email -> Either String (IO ())
const joinMailingList = undefined;
{% solution src="./exercises/ch09/solution_c.js" %} {% validation src="./exercises/ch09/validation_c.js" %} {% context src="./exercises/support.js" %} {% endexercise %}