Skip to content

Latest commit

 

History

History
401 lines (260 loc) · 26.1 KB

ch10-uk.md

File metadata and controls

401 lines (260 loc) · 26.1 KB

Розділ 10: Аплікативні Функтори

Застосування Аплікативів

Назва аплікативний функтор напрочуд приємно описана, беручи до уваги її функціональні витоки. Функціональні програмісти відомі своїм вмінням придумувати назви на кшталт 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) лише для задоволення послідовних вимог монади.

Насправді, було б чудово, якби ми могли стисло застосувати вміст одного функтора до значення іншого без цих непотрібних функцій і змінних, якщо ми опинимося в цій банці з огірками.

Кораблі в Пляшках

https://www.deviantart.com/hollycarden

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. Це дозволяє більш природно застосовувати функції та може допомогти усунути деякі дужки.

Безкоштовні Відкривачки

http://www.breannabeckmeyer.com/

Ми мало говорили про похідні функції. Зважаючи на те, що всі ці інтерфейси побудовані один на одному та дотримуються певного набору законів, ми можемо визначити деякі слабші інтерфейси в термінах сильніших.

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

Ця досконала комп'ютерна гармонія можлива, тому що ми працюємо в математичному контексті. Моцарт не міг би зробити краще, навіть якби він завантажив 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 %}