Назва аплікативний функтор напрочуд приємно описана, беручи до уваги її функціональні витоки. Функціональні програмісти відомі своїм вмінням придумувати назви на кшталт mappend
або liftA4
, які здаються абсолютно природними, коли ви перебуваєте в математичній лабораторії, але втрачають ясність нерішучого Дарта Вейдера в драйв-тру в будь-якому іншому контексті.
Функціональні програмісти відимі своїми вигадуваннями назв накшталт mappend
або liftA4
, які виглядають абсолтно природньо в якій-небудь математичній лабораторі, але мають чіткість нерішучого Дарт Вейдер у будь-якому іншому контексті.
Так чи інакше, назва має виражати те, що цей інтерфейс нам дає: здатність застосовувати функтори один до одного.
Тепер, чому звичайна, раціональна людина, як ви, могла б забажати такої речі? Що це взагалі означає застосувати один функтор до іншого?
Щоб відповісти на ці питання, ми почнемо з ситуації, з якою ви вже могли зустрітися у своїх функціональних мандрах. Скажімо, гіпотетично, що у нас є два функтори (одного типу) і ми хотіли б викликати функцію з обома їхніми значеннями як аргументами. Щось просте, як додавання значень двох Container
ів.
// Ми не можемо так зробити, оскільки числа замкнені.
add(Container.of(2), Container.of(3));
// NaN
// Давайте використаємо наш перевірений map
const containerOfAdd2 = map(add, Container.of(2));
// Container(add(2))
У нас є Container
з частково застосованою функцією всередині. Якщо бути більш конкретним - у нас є Container(add(2))
, і ми хочемо застосувати його add(2)
до 3
в Container(3)
, щоб завершити виклик. Іншими словами, ми хочемо застосувати один функтор до іншого.
Так сталося, що у нас вже є інструменти для виконання цього завдання. Ми можемо застосувати chain
, а потім замапити (викликавши map
) частково застосований add(2)
:
Container.of(2).chain(two => Container.of(3).map(add(two)));
Проблема тут полягає у тому, що ми застрягли в послідовному світі монад, де нічого не може бути оцінено(evaluated), поки попередня монада не закінчить свої справи. У нас є два сильних, незалежних значення, і я вважаю непотрібним затримувати створення Container(3)
лише для задоволення послідовних вимог монади.
Насправді, було б чудово, якби ми могли стисло застосувати вміст одного функтора до значення іншого без цих непотрібних функцій і змінних, якщо ми опинимося в цій банці з огірками.
ap
- це функція, яка може застосовувати функціональний вміст одного функтора до значення іншого. Скажіть це швидко п'ять разів.
Container.of(add(2)).ap(Container.of(3));
// Container(5)
// а тепер усе разом
Container.of(2).map(add).ap(Container.of(3));
// Container(5)
Ось ми й впорались, чисто та охайно. Хороші новини для Container(3)
, оскільки він звільнений з в'язниці вкладеної монадичної функції. Варто знову згадати, що add
, у цьому випадку, частково застосовується під час першого map
, тому це працює лише тоді, коли add
є каррованою.
Ми можемо визначити ap
так:
Container.prototype.ap = function (otherContainer) {
return otherContainer.map(this.$value);
};
Пам'ятайте, this.$value
буде функцією, і ми прийматимемо інший функтор, тому нам потрібно тільки замапити (map
) його. І ось ми маємо наше визначення інтерфейсу:
Аплікативний функтор - це вказаний (pointed) функтор з методом
ap
Зауважте залежність від вказаного (pointed). Вказаний інтерфейс є важливим, як ми побачимо в наступних прикладах.
Тепер я відчуваю ваш скептицизм (або можливо збентеження та жах), але залиште розум відкритим; цей персонаж ap
виявиться корисним. Перш ніж ми перейдемо до цього, давайте розглянемо гарну властивість.
F.of(x).map(f) === F.of(f).ap(F.of(x));
На належній англійській мові, мапування f
еквівалентне ap
уванню функтора f
. Або на більш коректній англійській, ми можемо помістити x
у наш контейнер і map(f)
АБО ми можемо підняти (lift) обидва f
та x
у наш контейнер і застосувати щодо них ap
. Це дозволяє нам писати нам у стилі зліва-направо:
Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
// Maybe(5)
Task.of(add).ap(Task.of(2)).ap(Task.of(3));
// Task(5)
Можна навіть впізнати віддалену форму звичайного виклику функції, якщо дивитися крізь напівзакриті очі. Ми розглянемо версію без точок пізніше в розділі, але зараз це бажаний спосіб написання такого коду. Використовуючи of
, кожне значення переноситься до магічної країни контейнерів, цього паралельного світу, де кожне застосування може бути асинхронним або нульовим або будь-чим іншим, і ap
застосовуватиме функції в цьому фантастичному місці. Це як будувати корабель у пляшці.
Ви це побачили? Ми використали Task
у нашому прикладі. Це прекрасний приклад, коли аплікативні функтори виявляють свою цінність. Давайте розглянемо більш детальний приклад.
Скажімо, ми будуємо сайт подорожей і хотіли б отримати список туристичних місць та місцевих подій. Кожен з цих є окремим, самостійним API-викликом.
// Http.get :: String -> Task Error HTML
const renderPage = curry((destinations, events) => { /* render page */ });
Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'));
// Task("<div>some page with dest and events</div>")
Обидва Http
виклики відбуваються одразу, і коли вони обидва завершаться - буде викликаний renderPage
. Це контрастує з монадичною версією, де один Task
має закінчитися, перш ніж запуститься наступний. Оскільки нам не потрібні місця призначення для отримання подій, ми вільні від послідовної оцінки (evaluation).
Знову ж таки, оскільки ми використовуємо часткове застосування для досягнення цього результату, ми повинні переконатися, що renderPage
каррований, бо інакше він не чекатиме на обидва Tasks
щоб завершитись. Випадково, якщо ви коли-небудь робили таке ручне втручання, ви оціните неймовірну простоту цього інтерфейсу. Це той вид красивого коду, який наближає нас на крок ближче до сингулярності.
Давайте подивимося на інший приклад.
// $ :: String -> IO DOM
const $ = selector => new IO(() => document.querySelector(selector));
// getVal :: String -> IO String
const getVal = compose(map(prop('value')), $);
// signIn :: String -> String -> Bool -> User
const signIn = curry((username, password, rememberMe) => { /* signing in */ });
IO.of(signIn).ap(getVal('#email')).ap(getVal('#password')).ap(IO.of(false));
// IO({ id: 3, email: '[email protected]' })
signIn
є закритою функцією з трьома аргументами, тому ми мусимо використовувати ap
відповідно до цього. З кожним ap
, signIn
отримує ще один аргумент, поки він не завершиться і не запуститься. Ми можемо продовжити цей шаблон з стількома аргументами, скільки необхідно. Інша річ, на яку слід звернути увагу, це те, що два аргументи природно опиняються в IO
, тоді як останньому трохи допомагає of
, щоб підняти його в IO
, оскільки ap
очікує, що функція та всі її аргументи будуть одного типу.
Давайте розглянемо спосіб запису цих аплікативних викликів без використання точок. Оскільки ми знаємо, що map
дорівнює of/ap
, ми можемо написати узагальнені функції, які будуть викликати ap
стільки разів, скільки ми вкажемо:
const liftA2 = curry((g, f1, f2) => f1.map(g).ap(f2));
const liftA3 = curry((g, f1, f2, f3) => f1.map(g).ap(f2).ap(f3));
// liftA4, etc
liftA2
це дивна назва. Вона видає звук як один з примхливих вантажних ліфтів у занедбаному заводі або як номерний знак для дешевої лімузинної компанії. Однак, коли ви просвітлені, вона стає самоочевидною: підніміть ці частини у світ аплікативного функтора.
Коли я вперше побачив цей безглуздий нонсенс 2-3-4, він здався мені непривабливим і непотрібним. Адже ми можемо перевіряти арність функцій у JavaScript і побудувати це динамічно. Проте, часто корисно частково застосувати liftA(N)
саму по собі, щоб в ній не могла бути змінена кількість аргументів.
Давайте побачимо це на практиці:
// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String
const user = {
name: 'John Doe',
email: 'blurp_blurp',
};
// createUser :: Email -> String -> IO User
const createUser = curry((email, name) => { /* creating... */ });
Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// Left('invalid email')
liftA2(createUser, checkEmail(user), checkName(user));
// Left('invalid email')
Оскільки createUser
приймає два аргументи, ми використовуємо відповідний liftA2
. Обидва висловлювання еквівалентні, але версія з liftA2
не має згадки про Either
. Це робить його більш узагальненим і гнучким, оскільки ми більше не прив'язані до конкретного типу.
Давайте подивимось на попередні приклади написані таким чином:
liftA2(add, Maybe.of(2), Maybe.of(3));
// Maybe(5)
liftA2(renderPage, Http.get('/destinations'), Http.get('/events'));
// Task('<div>some page with dest and events</div>')
liftA3(signIn, getVal('#email'), getVal('#password'), IO.of(false));
// IO({ id: 3, email: '[email protected]' })
У мовах, таких як Haskell, Scala, PureScript та Swift, де можливо створити власні інфіксні оператори, ви можете побачити синтаксис на кшталт цього:
-- Haskell / PureScript
add <$> Right 2 <*> Right 3
// JavaScript
map(add, Right(2)).ap(Right(3));
Корисно знати, що <$>
- це map
(також відомий як fmap
), а <*>
- це просто ap
. Це дозволяє більш природно застосовувати функції та може допомогти усунути деякі дужки.
Ми мало говорили про похідні функції. Зважаючи на те, що всі ці інтерфейси побудовані один на одному та дотримуються певного набору законів, ми можемо визначити деякі слабші інтерфейси в термінах сильніших.
Наприклад, ми знаємо, що аплікатив спершу є функтором, тому, якщо у нас є аплікативний інстанс, ми, безсумнівно, можемо визначити функтор для нашого типу.
Ця досконала комп'ютерна гармонія можлива, тому що ми працюємо в математичному контексті. Моцарт не міг би зробити краще, навіть якби він завантажив Ableton у дитинстві.
Я згадував раніше, що of/ap
еквівалентне map
. Ми можемо використовувати ці знання для визначення map
безкоштовно:
// map похідний від of/ap
X.prototype.map = function map(f) {
return this.constructor.of(f).ap(this);
};
Монади знаходяться на вершині харчового ланцюга, так би мовити, тож, якщо у нас є chain
, ми отримуємо функтор і аплікатив безкоштовно:
// map похідний від chain
X.prototype.map = function map(f) {
return this.chain(a => this.constructor.of(f(a)));
};
// ap похідний від chain/map
X.prototype.ap = function ap(other) {
return this.chain(f => other.map(f));
};
Якщо ми можемо визначити монаду, ми можемо визначити і аплікативний, і функторний інтерфейси. Це досить неймовірно, оскільки ми отримуємо всі ці відкривачки безкоштовно. Ми навіть можемо перевірити тип і автоматизувати цей процес.
Слід зазначити, що частина привабливості ap
полягає в здатності виконувати речі одночасно, тому визначення його через chain
втрачає на цій оптимізацію. Незважаючи на це, добре мати миттєво робочий інтерфейс, поки ви працюєте над найкращою можливою реалізацією.
Чому б просто не використовувати монади і все? Це гарна практика працювати з рівнем потужності, який вам потрібен, не більше і не менше. Це знижує когнітивне навантаження, виключаючи можливі функціональні можливості. З цієї причини краще віддавати перевагу аплікативам над монадами.
Монади мають унікальну здатність послідовно виконувати обчислення, присвоювати змінні та припиняти подальше виконання завдяки їхній структурі вкладення. Коли ви бачите використання аплікативів, вам не потрібно турбуватися про будь-які з цих справ.
Тепер перейдемо до законності...
Як і інші математичні конструкції, які ми вивчали, аплікативні функтори мають деякі корисні властивості, на які ми можемо покладатися в нашому щоденному написанні коду. Насамперед вам слід знати, що аплікативи є "замкненими під композицією", що означає, що ap
ніколи непроміняє типи контейнерів на нас (ще одна причина віддавати перевагу монадам). Це не означає, що ми не можемо мати кілька різних ефектів - ми можемо накопичувати наші типи, знаючи, що вони залишаться такими ж протягом усієї нашої програми.
Щоб продемонструвати:
const tOfM = compose(Task.of, Maybe.of);
liftA2(liftA2(concat), tOfM('Rainy Days and Mondays'), tOfM(' always get me down'));
// Task(Maybe(Rainy Days and Mondays always get me down))
Бачите, не потрібно турбуватися, що у суміш потраплять різні типи.
Час подивитися на наш улюблений категорійний закон: тотожність:
// тотожність
A.of(id).ap(v) === v;
Правильно, отже, застосування id
зсередини функтора не повинно змінювати значення у v
. Наприклад:
const v = Identity.of('Pillow Pets');
Identity.of(id).ap(v) === v;
Identity.of(id)
змушує мене сміятися через свою марність. В будь-якому випадку, цікаво, що, як ми вже встановили, of/ap
є тим самим, що і map
, тому цей закон прямо випливає з функторної тотожності: map(id) == id
.
Краса використання цих законів полягає в тому, що, як мілітаристичний тренер з фізкультури в дитячому садку, вони змушують всі наші інтерфейси гарно грати разом.
// гомоморфізм
A.of(f).ap(A.of(x)) === A.of(f(x));
Гомоморфізм - це просто карта, що зберігає структуру. Насправді функтор - це просто гомоморфізм між категоріями, оскільки він зберігає структуру оригінальної категорії під час мапінгу.
Ми дійсно просто запихаємо наші звичайні функції та значення в контейнер і виконуємо обчислення всередині, тому не повинно дивувати, що ми отримаємо той самий результат, якщо застосуємо все це всередині контейнера (ліва сторона рівняння) або застосуємо його зовні, а потім помістимо його туди (права сторона).
Коротенький приклад:
Either.of(toUpperCase).ap(Either.of('oreos')) === Either.of(toUpperCase('oreos'));
Закон обміну стверджує, що не має значення, чи вирішимо ми підняти нашу функцію в ліву або праву сторону ap
.
// обмін
v.ap(A.of(x)) === A.of(f => f(x)).ap(v);
Наприклад:
const v = Task.of(reverse);
const x = 'Sparklehorse';
v.ap(Task.of(x)) === Task.of(f => f(x)).ap(v);
І нарешті композиція, яка є просто способом перевірити, що наша стандартна композиція функцій відповідає застосуванню всередині контейнерів.
// композиція
A.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));
const u = IO.of(toUpperCase);
const v = IO.of(concat('& beyond'));
const w = IO.of('blood bath ');
IO.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));
Хороший випадок використання аплікативів, коли у нас є кілька аргументів функтора. Вони дають нам змогу застосовувати функції до аргументів усередині функторного світу. Хоча ми вже могли це робити з монадами, ми повинні віддавати перевагу аплікативним функторам, коли нам не потрібна монадична специфічна функціональність.
Ми майже завершили з контейнерними API. Ми навчилися мапити (map
) функції, поєднувати їх в ланцюги (chain
), і тепер ще й використовувати ap
. У наступному розділі ми дізнаємося, як краще працювати з кількома функторами та розбирати їх за принципами.
Глава 11: Трансформація Знову, Природньо
{% exercise %}
Напишіть функцію, яка додає два можливо нульові числа разом за допомогою Maybe
та ap
.
{% initial src="./exercises/ch10/exercise_a.js#L3;" %}
// safeAdd :: Maybe Number -> Maybe Number -> Maybe Number
const safeAdd = undefined;
{% solution src="./exercises/ch10/solution_a.js" %}
{% validation src="./exercises/ch10/validation_a.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}
{% exercise %}
Перепишіть safeAdd
з вправи exercise_b використовуючи liftA2
замість ap
.
{% initial src="./exercises/ch10/exercise_b.js#L3;" %}
// safeAdd :: Maybe Number -> Maybe Number -> Maybe Number
const safeAdd = undefined;
{% solution src="./exercises/ch10/solution_b.js" %}
{% validation src="./exercises/ch10/validation_b.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}
Для наступної вправи, ми візьмемо до уваги наступні допоміжні функції: For the next exercise, we consider the following helpers:
const localStorage = {
player1: { id:1, name: 'Albert' },
player2: { id:2, name: 'Theresa' },
};
// getFromCache :: String -> IO User
const getFromCache = x => new IO(() => localStorage[x]);
// game :: User -> User -> String
const game = curry((p1, p2) => `${p1.name} vs ${p2.name}`);
{% exercise %}
Напишіть IO, який дістає обох гравців
(player1 та player2) з кешу та розпочинає гру.
{% initial src="./exercises/ch10/exercise_c.js#L16;" %}
// startGame :: IO String
const startGame = undefined;
{% solution src="./exercises/ch10/solution_c.js" %}
{% validation src="./exercises/ch10/validation_c.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}