Skip to content

Latest commit

 

History

History
812 lines (542 loc) · 62 KB

ch08-uk.md

File metadata and controls

812 lines (542 loc) · 62 KB

Chapter 08: Tupperware

Всемогутній Контейнер

http://blog.dwinegar.com/2011/06/another-jar.html

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

Спочатку ми створимо контейнер. Цей контейнер повинен тримати будь-який тип значення; ziplock, в якому може міститись лише тільки пудинг тапіока, рідко буває корисним. Це буде об'єкт, але ми не надамо йому властивостей та методів у сенсі ООП. Ні, ми будемо ставитися до нього як до скарбнички — особливої скриньки, яка оберігає наші цінні дані.

class Container {
  constructor(x) {
    this.$value = x;
  }
  
  static of(x) {
    return new Container(x);
  }
}

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

Давайте протестуємо нашу абсолютно нову скриньку...

Container.of(3);
// Container(3)

Container.of('hotdogs');
// Container("hotdogs")

Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))

Якщо ви використовуєте Node, ви побачите {$value: x}, хоча у нас є Container(x). Chrome виведе тип правильно, але це не так важливо; до тих пір допоки ми розуміємо, як виглядає Container - з нами все буде добре. В деяких середовищах ви можете перевизначити метод inspect, якщо хочете, але ми не будемо так детально зупинятися на цьому. Для цієї книги ми будемо писати концептуальний вивід так, ніби ми перевизначили inspect, оскільки це набагато повчальніше, ніж {$value: x}, з педагогічних, а також естетичних причин.

Давайте уточнимо кілька речей, перш ніж продовжити:

  • Container це об'єкт з однією властивістю. Багато контейнерів містять лише одну річ, хоча вони не обмежені однією. Ми довільно назвали його властивість $value.

  • $value не може бути одного конкретного типу, інакше наш Container навряд чи виправдовував би свою назву.

  • Як тільки дані потрапляють у Container, вони залишаються там. Ми можемо витягнути їх, використовуючи .$value, але це суперечило б меті.

Причини, чому ми це робимо, стануть прозорими немов склянна банка, але наразі просто побудьте зі мною.

Мій Перший Функтор

Як тільки наше значення, яким би воно не було, потрапило до контейнеру, нам подрібен спосіб запускати на ньому функції.

// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

Це точно так само, як відомий map у масивах, тільки у нас є Container a замість [a]. І це працює по суті так само:

Container.of(2).map(two => two + 2); 
// Container(4)

Container.of('flamethrowers').map(s => s.toUpperCase()); 
// Container('FLAMETHROWERS')

Container.of('bombs').map(append(' away')).map(prop('length')); 
// Container(10)

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

Заждіть хвилинку, якщо ми продовжуємо викликати map, це виглядає як якась композиція! Яка математична магія тут працює? Ну що ж, друзі, ми щойно відкрили Функтори.

Функтор — це тип, який реалізує map та дотримується деяких законів

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

З якої причини ми можемо захотіти запечатати значення та використовувати map для доступу до нього? Відповідь зʼявиться, якщо ми підберемо краще питання: Що ми отримуємо, попросивши наш контейнер застосовувати функції замість нас? Що ж... абстракція застосування функцій. Коли ми застосовуємо map до функції, ми просимо тип контейнера виконати її за нас. Це, насправді, дуже потужна концепція.

Можливо Шредінгера

cool cat, need reference

Container is fairly boring. In fact, it is usually called Identity and has about the same impact as our id function (again there is a mathematical connection we'll look at when the time is right). However, there are other functors, that is, container-like types that have a proper map function, which can provide useful behaviour whilst mapping. Let's define one now. Container є досить нудним. Насправді його зазвичай називають Identity і він має приблизно такий же вплив, як наша функція id (знову ж таки, є математичний зв'язок, на який ми подивимося, коли настане час). Однак, існують інші функтори, тобто типи, схожі на контейнери, які мають належну функцію map, що може забезпечити корисну поведінку під час мапінгу. Давайте визначимо один зараз.

A complete implementation is given in the Appendix B

class Maybe {
  static of(x) {
    return new Maybe(x);
  }

  get isNothing() {
    return this.$value === null || this.$value === undefined;
  }

  constructor(x) {
    this.$value = x;
  }

  map(fn) {
    return this.isNothing ? this : Maybe.of(fn(this.$value));
  }

  inspect() {
    return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
  }
}

Тепер, Maybe виглядає майже як Container з однією маленькою відмінністю: воно спочатку перевірить чи існує значення перед тим як викликати передану функцію. Це дозволяє позбутися тих набридливих null в ході використання map (Зауважте, що імлементація цього є спрощеною задля навчальних цілей).

Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)

Maybe.of(null).map(match(/a/ig));
// Nothing

Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing

Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)

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

Цей синтакс із точкою цілком нормальний та функціональний, але, через причини зазначені в Розіділі 1, ми б хотіли зберегти наш безточковий стиль. Як виявляється, map повністю обладнаний для делегування будь-якому функтору, який він отримує:

// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

Це чудово, оскільки ми можемо продовжувати використовувати композицію, як зазвичай, і map працюватиме, як очікується. Це стосується і map з бібліотеки Ramda. Ми будемо використовувати точкову нотацію, коли це повчально, і безточкову версію, коли це зручно. Ви помітили це? Я хитро ввів додаткову нотацію в нашу сигнатуру типу. Functor f => вказує нам, що f повинен бути функтором. Це не так складно, але я відчував, що повинен це згадати.

Випадки Використання

У реальних умовах ми зазвичай можемо побачити, що Maybe використовується у функціях, які можуть не повернути результат.

// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);

// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));

streetName({ addresses: [] });
// Nothing

streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')

safeHead подібна до нашої звичайної head, але з додатковою типобезпекою. Цікава річ трапляється, коли до нашого коду додається Maybe; ми змушені мати справу з тими підступними нульовими значеннями null. Функція safeHead чесно і відкрито повідомляє про можливу невдачу — в цьому немає нічого ганебного — і тому вона повертає Maybe, щоб поінформувати нас про це. Однак ми не просто поінформовані, тому що змушені використовувати map, щоб дістатися до потрібного значення, яке сховане всередині об'єкта Maybe. По суті, це перевірка на null, яку здійснює сама функція safeHead. Тепер ми можемо спокійніше спати, знаючи, що значення null не з'явиться несподівано, коли ми цього найменше очікуємо. Такі API перетворюють ненадійний додаток із паперу та кнопок на міцну конструкцію з дерева та цвяхів. Вони гарантують безпечніше програмне забезпечення.

Іноді функція може явно повертати Nothing, щоб сигналізувати про невдачу. Наприклад:

// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
  Maybe.of(balance >= amount ? { balance: balance - amount } : null));

// Ця функція є гіпотетичною, не реалізованою тут... і ніде інде.
// updateLedger :: Account -> Account 
const updateLedger = account => account;

// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;

// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);


// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// Just('Your balance is $180')

getTwenty({ balance: 10.00 });
// Nothing

withdraw поверне Nothing, якщо у нас недостатньо коштів. Ця функція також повідомляє про свою непостійність і залишає нам лише один варіант - використовувати map для всього, що йде після цього. Різниця в тому, що тут null був навмисним. Замість Just('..'), ми отримуємо Nothing, щоб сигналізувати про невдачу, і наш додаток фактично зупиняється. Це важливо зазначити: якщо withdraw не вдається, тоді map припиняє решту обчислень, оскільки він не виконує відображені функції, а саме finishTransaction. Це саме та поведінка, яку ми очікуємо, оскільки ми б не хотіли оновлювати наші рахунки або показувати новий баланс, якщо ми не встигли успішно зняти кошти.

Вивільнення Значення

Одна річ, яку люди часто пропускають, це те, що завжди буде кінцева точка; якась функція, що відправляє JSON, або друкує на екран, або змінює файлову систему, чи щось подібне. Ми не можемо доставити вихідні дані за допомогою return, ми повинні виконати якусь функцію, щоб відправити їх у світ. Ми можемо сформулювати це як коан дзен-буддиста: "Якщо програма не має видимого ефекту, чи вона взагалі виконується?". Чи виконується вона правильно? Я підозрюю, що вона просто проходить якимись циклами і знову засинає...

Завдання нашого додатка - отримувати, трансформувати і переносити дані доти, поки не прийде час попрощатися, і функція, яка це робить, може бути відображена, таким чином, значення не обов'язково повинно залишати теплий "середовище" свого контейнера. Справді, поширена помилка - намагатися будь-яким способом видалити значення з нашого Maybe, ніби можливе значення всередині раптом матеріалізується і все буде прощено. Ми повинні розуміти, що може бути гілка коду, де наше значення не зможе виконати свою долю. Наш код, подібно до кота Шредінгера, одночасно перебуває у двох станах і повинен підтримувати цей факт до завершальної функції. Це надає нашому коду лінійний потік, незважаючи на логічне розгалуження.

Однак, є запасний вихід. Якщо ми хочемо повернути власне значення і продовжити, ми можемо використовувати невелику допоміжну функцію під назвою maybe.

// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
  if (m.isNothing) {
    return v;
  }

  return f(m.$value);
});

// getTwenty :: Account -> String
const getTwenty = compose(maybe('You\'re broke!', finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// 'Your balance is $180.00'

getTwenty({ balance: 10.00 }); 
// 'You\'re broke!'

Тепер ми можемо або повернути статичне значення (того ж типу, що й finishTransaction), або продовжити весело завершувати транзакцію без Maybe. За допомогою maybe ми спостерігаємо еквівалент оператора if/else, тоді як з map імперативний аналог був би: if (x !== null) { return f(x) }.

Введення Maybe може викликати початковий дискомфорт. Користувачі Swift і Scala зрозуміють, про що я говорю, оскільки це вбудовано в основні бібліотеки під виглядом Option(al). Коли нас змушують постійно мати справу з перевірками null (а іноді ми з абсолютною впевненістю знаємо, що значення існує), більшість людей не можуть не відчувати, що це трохи клопітно. Однак з часом це стане другою натурою, і ви, ймовірно, оціните безпеку. Зрештою, найчастіше це дозволить уникнути ризиків і зберегти наші зусилля.

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

Я б був недбалим, якби не згадав, що "реальна" реалізація розділить Maybe на два типи: один для чогось і інший для нічого. Це дозволяє нам дотримуватися параметричності в map, щоб такі значення, як null і undefined, все ще можна було обробити, і загальна кваліфікація значення в функторі буде дотримана. Ви часто побачите типи, такі як Some(x) / None або Just(x) / Nothing, замість Maybe, який робить перевірку на null свого значення.

Чиста Обробка Помилок

pick a hand... need a reference

Вас це може шокувати, але throw/catch не є дуже чистим методом. Коли виникає помилка, замість того, щоб повернути вихідне значення, ми підіймаємо тривогу! Функція атакує, розкидаючи тисячі 0 і 1, як щити та списи в електричній битві проти нашого загарбницького вводу. З нашим новим другом Either, ми можемо діяти краще, ніж оголошувати війну вводу, ми можемо відповісти ввічливим повідомленням. Давайте подивимося:

Повна реалізація продемонстрована в Appendix B

class Either {
  static of(x) {
    return new Right(x);
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this;
  }

  inspect() {
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value));
  }

  inspect() {
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

Left та Right є двома підкласами абстрактного типу, який ми називаємо Either. Я пропустив церемонію створення суперкласу Either, оскільки ми ніколи не будемо його використовувати, але добре знати про його існування. Отже, тут немає нічого нового, крім двох типів. Давайте подивимося, як вони діють:

Either.of('rain').map(str => `b${str}`); 
// Right('brain')

left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`); 
// Left('rain')

Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')

left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')

Left є підлітковим типом та ігнорує наш запит на застосування map до нього. Right буде працювати так само, як Container (відомий також як Identity). Сила Either полягає в здатності вбудовувати повідомлення про помилку всередині Left.

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

const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

Тепер, так само як і з Nothing, ми припиняємо виконання нашого додатку, коли повертаємо Left. Різниця в тому, що тепер у нас є підказка, чому наша програма зазнала невдачі. Зверніть увагу, що ми повертаємо Either(String, Number), де String є значенням зліва, а Number — значенням справа (як його Right). Ця сигнатура типу трохи неформальна, оскільки ми не витратили час на визначення реального суперкласу Either, проте ми багато чого дізнаємося з цього типу. Він повідомляє нам, що ми або отримуємо повідомлення про помилку, або вік.

// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));

// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)

zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')

Коли дата народження (birthDate) є дійсною, програма виводить своє містичне передбачення на екран для нас. Інакше ми отримуємо Left з повідомленням про помилку, яке добре видно, хоча й заховане в контейнері. Це діє так, ніби ми кинули помилку, але в спокійній, стриманій манері, а не кричачи, як дитина, коли щось йде не так.

У цьому прикладі ми логічно розгалужуємо потік управління залежно від дійсності дати народження, однак це читається як один лінійний рух зправа налівої, а не як карабкання через фігурні дужки умовного оператора. Зазвичай ми виносили б console.log за межі нашої функції zoltar і використовували б map під час виклику, але корисно побачити, як відрізняється гілка Right. Ми використовуємо _ в сигнатурі типу правої гілки, щоб вказати, що це значення слід ігнорувати (в деяких браузерах потрібно використовувати console.log.bind(console), щоб використовувати його як перший клас).

Хочу скористатися цією можливістю, щоб звернути увагу на щось, що ви могли пропустити: fortune, незважаючи на своє використання з Either у цьому прикладі, абсолютно не знає про наявність будь-яких функцій. Це також стосується finishTransaction у попередньому прикладі. Під час виклику функція може бути обгорнута в map, що перетворює її з нефункторної функції на функцію з функтором, в неформальних термінах. Ми називаємо цей процес підйомом (lifting). Функції зазвичай краще працюють з нормальними типами даних, а не з контейнерними типами, а потім піднімаються в потрібний контейнер за необхідності. Це веде до простіших, більш універсальних функцій, які можна змінювати для роботи з будь-яким функтором на вимогу.

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

Зараз я не можу не відчувати, що я заподіяв Either шкоду, представивши його лише як контейнер для повідомлень про помилки. Насправді Either захоплює логічну диз'юнкцію (відоме як ||) у типі. Він також кодує ідею копродукту (Coproduct) з теорії категорій, яку ми не будемо розглядати в цій книзі, але варто ознайомитися з нею, оскільки там є властивості, які можна використати. Це канонічний сумарний тип (або диз'юнктне об'єднання множин), оскільки кількість можливих значень дорівнює сумі двох вкладених типів (я знаю, що це трохи абстрактно, тому ось чудова стаття). Either може бути багатьма речами, але як функтор він використовується для обробки помилок.

Так само, як і з Maybe, у нас є маленький either, який поводиться схоже, але приймає дві функції замість однієї і статичне значення. Кожна функція повинна повертати один і той самий тип:

// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
  let result;

  switch (e.constructor) {
    case Left:
      result = f(e.$value);
      break;

    case Right:
      result = g(e.$value);
      break;

    // No Default
  }

  return result;
});

// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined

zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined

Нарешті, знайшлося застосування для тієї загадкової функції id. Вона просто повертає значення з Left, щоб передати повідомлення про помилку до console.log. Ми зробили наш додаток для передбачення майбутнього більш надійним, впровадивши обробку помилок безпосередньо у функцію getAge. Ми або надаємо користувачеві жорстку правду, як облизень від ворожки, або продовжуємо процес. І з цим усим ми готові перейти до абсолютно іншого типу функтора.

Старий МакДональд Мав Ефекти...

dominoes.. need a reference

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

// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];

Якби ми не обгорнули її внутрішню частину іншою функцією, getFromStorage змінювала б свій вивід залежно від зовнішніх обставин. З міцною обгорткою ми завжди отримаємо однаковий вихід для заданого входу: функцію, яка при виклику витягує певний елемент з localStorage. І ось так (можливо, додати кілька молитов) ми очистили свою совість, і все пробачено.

Однак це не дуже корисно, чи не так? Як з колекційною фігуркою в оригінальній упаковці, ми не можемо насправді гратися з нею. Якби тільки був спосіб дістати з контейнера його вміст... Зустрічайте IO.

class IO {
  static of(x) {
    return new IO(() => x);
  }

  constructor(fn) {
    this.$value = fn;
  }

  map(fn) {
    return new IO(compose(fn, this.$value));
  }

  inspect() {
    return `IO(${inspect(this.$value)})`;
  }
}

IO відрізняється від попередніх функторів тим, що його $value завжди є функцією. Однак ми не думаємо про його $value як про функцію - це деталь реалізації, яку краще ігнорувати. Що відбувається насправді, це те, що ми бачили в прикладі з getFromStorage: IO відкладає нечисту дію, захоплюючи її в обгортку-функцію. Таким чином, ми вважаємо, що IO містить значення, яке повертається обгорнутою дією, а не саму обгортку. Це очевидно в функції of: ми маємо IO(x), і IO(() => x) є лише необхідним для уникнення оцінки. Зазначимо, що для спрощення читання ми будемо показувати гіпотетичне значення, що міститься в IO, як результат; однак на практиці ви не можете дізнатися, що це за значення, доки ви не здійсните ефекти!

Давайте подивимось, як це працює на практиці:

// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

Тут, ioWindow є справжнім IO, над яким ми можемо відразу ж виконати map, тоді як $ - це функція, яка повертає IO після її виклику. Я написав концептуальні значення, що повертаються, щоб краще висловити суть IO, хоча насправді це завжди буде { $value: [Function] }. Коли ми виконуємо map над нашим IO, ми додаємо цю функцію в кінець композиції, яка, у свою чергу, стає новим $value, і так далі. Наші функції, які ми мапимо, не виконуються, вони додаються в кінець обчислення, яке ми будуємо, функція за функцією, як акуратно розставлені доміно, які ми не наважуємося перекинути. Результат нагадує шаблон команд Gang of Four або чергу.

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

Тепер ми загнали звіра в клітку, але все одно доведеться випустити його в якийсь момент. Мапування над нашим IO створило потужне нечисте обчислення, і його запуск, безумовно, порушить спокій. Отже, де і коли ми можемо натиснути на курок? Чи можливо взагалі виконати наш IO і при цьому залишатися чистими? Відповідь - так, якщо ми покладемо відповідальність на викликаючий код. Наш чистий код, незважаючи на злісні змови та інтриги, зберігає свою невинність, а викликаючий код бере на себе відповідальність за фактичне виконання ефектів. Давайте розглянемо приклад, щоб зробити це конкретним.

// url :: IO String
const url = new IO(() => window.location.href);

// toPairs :: String -> [[String]]
const toPairs = compose(map(split('=')), split('&'));

// params :: String -> [[String]]
const params = compose(toPairs, last, split('?'));

// findParam :: String -> IO Maybe [String]
const findParam = key => map(compose(Maybe.of, find(compose(eq(key), head)), params), url);

// -- Нечисте викликання коду ----------------------------------------------

// виконайте це викликом $value()!
findParam('searchTerm').$value();
// Just(['searchTerm', 'wafflehouse'])

Наша бібліотека зберігає чистоту коду, обгортаючи url у IO і перекладаючи відповідальність на викликаючий код. Ви, можливо, також помітили, що ми накопичили наші контейнери; цілком розумно мати IO(Maybe([x])), що є трьома функторними рівнями (Array є контейнером, який можна мапити) і є надзвичайно виразним.

Є щось, що мене турбує, і ми повинні негайно це виправити: $value у IO не є дійсно його вкладеним значенням, а також не є приватною властивістю. Це шпилька у гранаті, яку повинен витягнути викликаючий код найбільш публічним чином. Давайте перейменуємо цю властивість на unsafePerformIO, щоб нагадати нашим користувачам про її нестабільність.

class IO {
  constructor(io) {
    this.unsafePerformIO = io;
  }

  map(fn) {
    return new IO(compose(fn, this.unsafePerformIO));
  }
}

Ось, так значно краще. Зараз наш викликаючий код стає findParam('searchTerm').unsafePerformIO(), що є абсолютно зрозумілим для користувачів (та читачів) додатку.

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

Асинхронні Завдання

Зворотні виклики (callbacks) - це вузькі спіральні сходи до пекла. Це керування потоком, розроблене М. К. Ешером. Кожен вкладений зворотний виклик, втиснутий між джунглями фігурних і звичайних дужок, відчувається як лімб у пастці забуття (як низько ми можемо піти?!). У мене мурашки по шкірі від однієї думки про них. Не хвилюйтеся, у нас є набагато кращий спосіб роботи з асинхронним кодом, і він починається з "F".

Внутрішня механіка трохи занадто складна, щоб викладати її тут, тому ми будемо використовувати Data.Task (раніше Data.Future) з чудової бібліотеки Куілдріна Мотти - Folktale. Ось приклади використання:

// -- Node readFile example ------------------------------------------

const fs = require('fs');

// readFile :: String -> Task Error String
const readFile = filename => new Task((reject, result) => {
  fs.readFile(filename, (err, data) => (err ? reject(err) : result(data)));
});

readFile('metamorphosis').map(split('\n')).map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.')


// -- jQuery getJSON example -----------------------------------------

// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry((url, params) => new Task((reject, result) => {
  $.getJSON(url, params, result).fail(reject);
}));

getJSON('/video', { id: 10 }).map(prop('title'));
// Task('Family Matters ep 15')


// -- Default Minimal Context ----------------------------------------

// We can put normal, non futuristic values inside as well
Task.of(3).map(three => three + 1);
// Task(4)

Функції, які я називаю reject і result, відповідно, є нашими зворотними викликами для обробки помилок і успішного виконання. Як ви можете бачити, ми просто використовуємо map над Task, щоб працювати з майбутнім значенням так, ніби воно вже у нас під рукою. На цей момент map має бути вже добре відомим.

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

Як і IO, Task буде терпляче чекати, поки ми дамо йому зелене світло перед запуском. Насправді, оскільки він чекає на нашу команду, IO фактично підпорядковується Task для всіх асинхронних операцій; readFile і getJSON не потребують додаткового контейнера IO, щоб бути чистими. Більше того, Task працює схожим чином, коли ми використовуємо map над ним: ми розміщуємо інструкції для майбутнього, як графік завдань у капсулі часу - акт складного технологічного відкладання.

Для запуску нашого Task, ми повинні викликати метод fork. Це працює як unsafePerformIO, але, як випливає з назви, він розгалужує наш процес і оцінка продовжується без блокування нашого потоку. Це можна реалізувати різними способами за допомогою потоків і так далі, але тут він діє як звичайний асинхронний виклик, і велике колесо подій продовжує крутитися. Давайте розглянемо fork:

// -- Pure application -------------------------------------------------
// blogPage :: Posts -> HTML
const blogPage = Handlebars.compile(blogTemplate);

// renderPage :: Posts -> HTML
const renderPage = compose(blogPage, sortBy(prop('date')));

// blog :: Params -> Task Error HTML
const blog = compose(map(renderPage), getJSON('/posts'));


// -- Impure calling code ----------------------------------------------
blog({}).fork(
  error => $('#error').html(error.message),
  page => $('#main').html(page),
);

$('#spinner').show();

При виклику fork, Task швидко відправляється шукати пости та рендерити сторінку. Тим часом, ми показуємо спінер, оскільки fork не чекає на відповідь. Нарешті, ми або відображаємо помилку, або рендеримо сторінку на екран в залежності від того, чи виклик getJSON успішний чи ні.

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

Ви тільки гляньте но на це, Task також поглинає Either! Це необхідно для обробки майбутніх помилок, оскільки наш звичайний потік управління не застосовується у асинхронному світі. Це добре, оскільки забезпечує достатню і чисту обробку помилок прямо з коробки.

Навіть з Task, наші функтори IO та Either не залишаються без роботи. Давайте розглянемо швидкий приклад, який схиляється до більш складної і гіпотетичної сторони, але корисний для ілюстративних цілей.

// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String

// -- Pure application -------------------------------------------------

// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
  if (uname && pass && host && db) {
    return Either.of(`db:pg://${uname}:${pass}@${host}5432/${db}`);
  }

  return left(Error('Invalid config!'));
};

// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);

// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);


// -- Impure calling code ----------------------------------------------

getConfig('db.json').fork(
  logErr('couldn\'t read file'),
  either(console.log, map(runQuery)),
);

У цьому прикладі ми все ще використовуємо Either і IO в гілці успіху readFile. Task займається нечистими операціями читання файлу асинхронно, але ми все ще маємо справу з валідацією конфігурації за допомогою Either та налаштуванням з'єднання з базою даних за допомогою IO. Отже, ми все ще маємо роботу для всіх синхронних операцій.

Я міг би продовжувати, але на цьому все. Це так просто, як map.

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

Трохи Теорії

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

// identity
map(id) === id;

// composition
compose(map(f), map(g)) === map(compose(f, g));

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

const idLaw1 = map(id);
const idLaw2 = id;

idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)

Як ви бачите - вони однакові. Давайте тепер поглянемо на композицію.

const compLaw1 = compose(map(append(' world')), map(append(' cruel')));
const compLaw2 = map(compose(append(' world'), append(' cruel')));

compLaw1(Container.of('Goodbye')); // Container('Goodbye cruel world')
compLaw2(Container.of('Goodbye')); // Container('Goodbye cruel world')

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

Можливо, наше визначення категорії все ще трохи нечітке. Ви можете думати про категорію як про мережу об'єктів із морфізмами, що їх з'єднують. Отже, функтор мапитиме одну категорію в іншу, не порушуючи мережу. Якщо об'єкт a знаходиться у нашій вихідній категорії C, коли ми мапимо його в категорію D за допомогою функтора F, ми називаємо цей об'єкт F a (Якщо ви з'єднаєте це разом, що це означає?!). Можливо, краще поглянути на діаграму:

Categories mapped

Наприклад, Maybe мапить нашу категорію типів і функцій у категорію, де кожен об'єкт може не існувати, а кожен морфізм має перевірку на null. Ми досягаємо цього в коді, обгортаючи кожну функцію в map і кожен тип у наш функтор. Ми знаємо, що кожен з наших звичайних типів і функцій продовжить композицію в цьому новому світі. Технічно, кожен функтор у нашому коді мапиться у підкатегорію типів і функцій, що робить всі функтори особливим "брендом", який називається ендофунктори, але для наших цілей ми будемо вважати це різною категорією.

Ми також можемо візуалізувати мапування морфізму та його відповідних об'єктів за допомогою цієї діаграми:

functor diagram

На додаток до візуалізації мапованого морфізму з однієї категорії в іншу за допомогою функтора F, ми бачимо, що діаграма комутує, тобто, якщо ви слідуєте стрілкам, кожен шлях дає той самий результат. Різні шляхи означають різну поведінку, але ми завжди закінчуємо на тому ж типі. Ця формалізація дає нам принципові способи міркувати про наш код - ми можемо сміливо застосовувати формули, не потрібно аналізувати і розглядати кожен окремий сценарій. Давайте розглянемо конкретний приклад.

// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse);

// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of);

topRoute('hi'); // Just('ih')
bottomRoute('hi'); // Just('ih')

Або візуально:

functor diagram 2

We can instantly see and refactor code based on properties held by all functors.

Функтори можуть накопичуватись:

const nested = Task.of([Either.of('pillows'), left('no sleep for you')]);

map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])

Те, що ми маємо тут з nested, — це майбутній масив елементів, які можуть бути помилками. Ми використовуємо map, щоб зняти кожен шар і запустити нашу функцію на елементах. Ми не бачимо зворотних викликів, if/else або циклів for; лише явний контекст. Проте нам доводиться використовувати map(map(map(f))). Натомість ми можемо компонувати функтори. Ви правильно мене почули:

class Compose {
  constructor(fgx) {
    this.getCompose = fgx;
  }

  static of(fgx) {
    return new Compose(fgx);
  }

  map(fn) {
    return new Compose(map(map(fn), this.getCompose));
  }
}

const tmd = Task.of(Maybe.of('Rock over London'));

const ctmd = Compose.of(tmd);

const ctmd2 = map(append(', rock on, Chicago'), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))

ctmd2.getCompose;
// Task(Just('Rock over London, rock on, Chicago'))

Ось, один map. Композиція функторів є асоціативною, і раніше ми визначили Container, який насправді називається функтором Identity. Якщо у нас є ідентичність і асоціативна композиція, ми маємо категорію. Ця конкретна категорія має категорії як об'єкти і функтори як морфізми, що може змусити мозок працювати інтенсивніше. Ми не будемо заглиблюватися занадто далеко в це, але приємно оцінити архітектурні наслідки або навіть просто просту абстрактну красу в цьому шаблоні.

In Summary

Ми бачили кілька різних функторів, але їх є незліченна кількість. Деякі помітні пропуски — це ітерабельні структури даних, такі як дерева, списки, мапи, пари та інші. Потоки подій та спостережувані (observables) також є функторними. Інші можуть бути для інкапсуляції або навіть просто для моделювання типів. Функтори всюди навколо нас, і ми будемо активно використовувати їх протягом усієї книги.

А як щодо виклику функції з кількома функторними аргументами? Як щодо роботи з послідовністю нечистих або асинхронних дій? Ми ще не маємо повного інструментарію для роботи в цьому "запакованому" світі. Наступним кроком буде ознайомлення з монадами.

Chapter 09: Монадна Цибуля

Вправи

{% exercise %}
Використайте add та map щоб зробити функцію, яка збільшує значення всередині функтора.

{% initial src="./exercises/ch08/exercise_a.js#L3;" %}

// incrF :: Functor f => f Int -> f Int  
const incrF = undefined;  

{% solution src="./exercises/ch08/solution_a.js" %}
{% validation src="./exercises/ch08/validation_a.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}


Дано наспуний обʼєкт User:

const user = { id: 2, name: 'Albert', active: true };  

{% exercise %}
Використайте safeProp та head щоб знайти перший ініціал користувача (user).

{% initial src="./exercises/ch08/exercise_b.js#L7;" %}

// initial :: User -> Maybe String  
const initial = undefined;  

{% solution src="./exercises/ch08/solution_b.js" %}
{% validation src="./exercises/ch08/validation_b.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}


Дано наступні допоміжні функції:

// showWelcome :: User -> String
const showWelcome = compose(concat('Welcome '), prop('name'));

// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
  return user.active
    ? Either.of(user)
    : left('Your account is not active');
};

{% exercise %}
Напишиіть функцію яка використовує checkActive та showWelcome щоб надавати доступ або повертати помилку.

{% initial src="./exercises/ch08/exercise_c.js#L15;" %}

// eitherWelcome :: User -> Either String String
const eitherWelcome = undefined;

{% solution src="./exercises/ch08/solution_c.js" %}
{% validation src="./exercises/ch08/validation_c.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}


Тепер ми розглянемо наступні функції:

// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map(_ => user));

// save :: User -> IO User
const save = user => new IO(() => ({ ...user, saved: true }));

{% exercise %}
Напишіть функцію validateName яка перевіряє чи імʼя користувача довше ніж 3 букви, або поверніть помилку. Потім використайте either, showWelcome та save щоб написати функцію register яка дозволить реєструвати і вітати користувача, якщо валідація була вдалою.

Пам’ятайте, що обидва аргументи мають повертати однаковий тип.

{% initial src="./exercises/ch08/exercise_d.js#L15;" %}

// validateName :: User -> Either String ()
const validateName = undefined;

// register :: User -> IO String
const register = compose(undefined, validateUser(validateName));

{% solution src="./exercises/ch08/solution_d.js" %}
{% validation src="./exercises/ch08/validation_d.js" %}
{% context src="./exercises/support.js" %}
{% endexercise %}