Функтори, Аплікативи, та Монади з ілюстраціями

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт! Мене звати Олександр. Вже близько 10 років я створюю кросс-платформені мобільні додатки, а ще я — GDE у Flutter та Dart. Сьогодні я пропоную вам подвійний переклад статті «Functors, Applicatives, And Monads In Pictures» з англійської на українську, та з Haskell на Dart. Якщо ви тільки починаєте занурення у функціональне програмування і слова «функтор» та «монада» вас може й не лякають, але викликають занепокоєння — цей переклад для вас. Сподіваюсь, що він призведе до того ж самого «АГА!» моменту, який оригінальна стаття багато років тому подарувала мені.

Я намагався залишити гумор та вислови оригіналу. Деякі ідеї я запозичив з перекладів на JavaScript, Python, Swift, та Kotlin. Дякую авторам!

Якщо вам сподобається переклад — не забудьте подякувати авторові оригіналу: Aditya Bhargava.

Dart НЕ Є функціональною мовою, незабаром ви помітите, як незграбно можуть виглядати елегантні конструкції з Haskell, грубо перекладені на Dart. Проте, деякі пакети (накшталт fpdart або dartz) можуть сильно спростити розробку на Flutter та Dart, зробити ваше життя щасливішим, а додатки — кращими. Цей переклад допоможе вам з основами, які необхідні для свідомішого використання цих пакетів.

Ось звичайне значення:

Ми знаємо, як застосувати функцію до цього значення:

Начебто усе просто. Підемо далі, і припустимо, що значення може бути у контексті (не плутайте з BuildContext з Flutter). Поки що, можете уявити контекст, як коробку, у яку ми можемо покласти значення:

Тепер, коли ми застосовуємо функцію до значення, ми можемо отримати різні результати, в залежності від контексту. Це і є основна ідея Функторів, Аплікативів, Монад, та усякого такого різного. Ось наприклад абстрактний клас Maybe, нащадки якого (Just та Nothing) визначають два контексти:

/// The Maybe type encapsulates an optional value.
/// Using Maybe is a good way to deal with errors or exceptional cases
/// without resorting to drastic measures such as error.
abstract class Maybe<T> {}

/// Represents a value of type Maybe that contains a value
/// (represented as Just a).
class Just<T> extends Maybe<T> {
 Just(this.value);
 final T value;
}

/// Represents an empty Maybe that holds nothing
/// (in which case it has the value of Nothing)
class Nothing<T> extends Maybe<T> {}

Примітка: я залишив оригінальни назви (здебільшого для того, щоб не перемальовувати ці файні ілюстрації), але часто (принаймні у пакетах fpdart або dartz) цей клас називається Option з нащадками Some та None.

За мить ми побачимо різницю між Just та Nothing, але спочатку давайте поговорим про Функтори (Functors).

Функтори

Коли значення огорнуто у контекст, не можна просто взяти і застосувати до нього функцію:

Тут на допомогу приходить fmap. Fmap — свій хлопець, він шарить у контекстах. Fmap знає, як застосовувати функцію до значення у контексті. Наприклад, якщо вам потрібно застосувати plus3 до Just 2, то ось як це буде з fmap:

num plus3(num x) => x + 3;
Just(2).fmap(plus3); // Just 5

Бам! Я ж казав, fmap шарить, як це робиться! Але, усе ж, як він це зробив?

То що таке функтор?

Функтор — це абстрактний клас (typeclass у Haskell). Ось його визначення:

У Dart:

abstract class Functor<T> {
  Functor<U> fmap<U>(U Function(T) f);
}

Функтор — це будь-який клас, який визначає fmap. Ось як це працює:

Тож ми можемо зробити так:

Just(2).fmap(plus3); // Just 5

І fmap магічно застосує функцію, бо Maybe — це функтор, він описує поведінку fmap для Just та Nothing:

abstract class Maybe<T> implements Functor<T> {}

class Just<T> extends Maybe<T> {
 Just(this.value);
 final T value;

 @override
 Maybe<U> fmap<U>(U Function(T) f) => Just(f(value));
}

class Nothing<T> extends Maybe<T> {
 @override
 Maybe<U> fmap<U>(U Function(T) f) => Nothing();
}

Тож, ось що насправді відбувається, коли ви пишете Just(2).fmap(plus3):

А якщо ми попросимо fmap застосувати plus3 до Nothing?

Nothing().fmap(plus3); // Nothing

Як Морфей у Матрці, fmap просто знає що робити. Якщо спочатку було Nothing, то й у результаті буде Nothing! Fmap — це дзен. Отже, навіщо Maybe існує? Наприклад, ось так ми працюємо з базою даних без Maybe:

 final post = Post.findByID(1);
 if (post != null) {
   return post.title;
 } else {
   return null;
 }

А ось так з Maybe:

 final getPostTitle = (Post post) => post.title;
 return Post.findByID(42).fmap(getPostTitle);

Якщо Post.findByID поверне post, то ми отримаємо його title за допомогою getPostTitle. Якщо ж він поверне Nothing, то ми повернемо Nothing! Круто, еге ж?

А ось ще приклад: що буде, якщо застосувати функцію до списку?

[2, 4, 6].map((x) => x + 3); // [5, 7, 9]

Списки — це також функтори! Ось як це виглядає:

Iterable<T> map<T>(T toElement(E e)) => MappedIterable<E, T>(this, toElement);

Добре-добре, останній приклад: що буде, якщо застосувати функцію до іншої функції?

fmap((x) => x + 3, (x) => x + 2);

Ось функція:

Ось функція, яка застосовується до іншої функції:

У результаті отримаємо нову функцію! У Dart це виглядає так:

typedef IntFunction = int Function(int);

IntFunction fmap(IntFunction f, IntFunction g) => (x) => f(g(x));


 final foo = fmap((x) => x + 3, (x) => x + 2);
 foo(10); // 15

Тобто функції — також функтори! Fmap на функції — це просто композиція функцій!

Аплікативи (або аплікативний функтор)

Аплікативи — то вже інший рівень. З аплікативами значення огорнуті у контекст (так само як з функторами):

Але функції також огорнуті у контекст!

Угу. Дайте собі хвильку. Аплікативи — то вже не жарти. Нажаль, на відміну від Haskell, у Dart немає механізму роботи з аплікативами. Але його легко додати! Ми можемо визначити функцію apply для кожного типу, що наслідує Applicative, який знає як застосовувати функції, огорнуті у контекст, до значень, також огорнутих у контекст:

abstract class Applicative<T> {
 Applicative<U> apply<U>(Applicative<U Function(T)> f);
}


abstract class Maybe<T> implements ..., Applicative<T> {}


class Just<T> extends Maybe<T> {
  ...
  @override
  Maybe<U> apply<U>(covariant Maybe<U Function(T)> f) => f.fmap((ff) => ff(value)) as Maybe<U>;
}


class Nothing<T> extends Maybe<T> {
  ...
  @override
  Maybe<U> apply<U>(covariant Maybe<U Function(T)> f) => Nothing();
}

тобто:

Just(2).apply(Just((x) => x + 3)); // Just 5

Якщо ж ми маємо кілька функцій та кілька значень, то було б логічно, якби існовало щось подібне до:

extension ApplicativeList on List {
 Iterable<U> apply<T, U>(List<U Function(T)> list) sync* {
   for (final item in list) {
     for (var i = 0; i < length; i++) {
       yield item(this[i]);
     }
   }
 }
}

З цим тепер можна застосовувати масив функцій до масиву значень:

 [1, 2, 3].apply<int, int>([
   (x) => x * 2,
   (x) => x + 3,
 ]); // [2, 4, 6, 4, 5, 6]

Примітка: у оригінальній статті тут показується чому Аплікативи потужніші ніж Функтори, проте для того, щоб це показати за допомогою Dart, нам потрібно буде звернутися до карування.

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

 final curriedAddition = (num x) => (num y) => x + y;
 final a = Just(3).fmap(curriedAddition); // Just<(int) => int>
 Just(5).fmap(a); // COMPILATION ERROR 
 // ??? WHAT DOES THIS EVEN MEAN WHY IS THE FUNCTION WRAPPED IN A JUST

Але з Аплікативом:

Just(5).apply(a); // Just 8

Аплікативи кажуть Функторам: «Посунься, дорослі хлопці можуть користуватись функціями з будь-яким набором аргументів». «За домопогою карування, map, та apply, я можу взяти функцію, що очікує будь-яке число неогорнутих аргументів! А потім, я передаю їй огорнуте значення, та отримую огорнуте значення назад! Мухахахаха!»

 final curriedTimes = (int x) => (int y) => x * y;
 Just(5).apply(Just(3).fmap(curriedTimes)); // Just 15

Монади

Як зрозуміти, що такое Монада:

  1. Здобути освіту на факультеті інформатики у Києво-могилянській академії.
  2. Викинути диплом, бо він тут не знадобиться!

Монади дають жару.

Функтори застосовують функцію до огорнутого значення:

Аплікативи застосовують огонуту функцію до огорнутого значення:

Монади застосовуть функцію, що вертає огорнуте значення, до огорнутого значення. Вони мають гарну функцію bind для цього.

Подивимось на прикладі. Ось стара добра монада Maybe:

Припустимо, що маємо функцію half, яка працює тільки з парними числами:

Maybe<num> half(num a) => a % 2 == 0 ? Just(a / 2) : Nothing();

А якщо ми спробуємо відправити огорнуте значення?

Тут нам потрібен bind ( >>= у Haskell) щоб проштовхнути огорнуте значення у функцію! Ось фото bind:

Отак це працює:

 Just(3).bind(half);        // Nothing
 Just(4).bind(half);        // Just 2
 Nothing<num>().bind(half); // Nothing

А що там усередині? Монада — це ще один абстрактний клас! Ось його визначення:

abstract class Monad<T> {
  Maybe<U> bind<U>(Maybe<U> Function(T) f);
}

Тут "bind"це:

Отже, Maybe — це Монада:

abstract class Maybe<T> implements ..., Monad<T> {}


class Just<T> extends Maybe<T> {
  ...
  @override
  Maybe<U> bind<U>(covariant Maybe<U> Function(T) f) => f(value);
}

class Nothing<T> extends Maybe<T> {
  ...
  @override
  Maybe<U> bind<U>(covariant Maybe<U> Function(T) f) => Nothing();
}

Ось так вона розправляється з Just 3!

А якщо на вході Nothing, то усе навіть простіше:

Можна робити ланцюжки з викликів:

Just(20).bind(half).bind(half).bind(half); // Nothing

Крутяк! Тепер ми знаємо, що Maybe — це Функтор, Аплікатив, та Монада.

Примітка: у оригінальній статті далі описується монада IO з Haskell. Проте Dart не розрізняє чисті та функції без сайд ефектів, тому монада IO тут не потрібна, отже я пропускаю цю частину.

Висновки

  1. Функтор — це клас, який імплементує map
  2. Аплікатив — це клас, який імплементує apply
  3. Монада — це клас, який імплементує bind
  4. Maybe — імплементує усі три, отже це функтор, аплікатив, та монада.

У чому між ними різниця?

  • функтор: застосовує функцію до огорнутого значення
  • аплікатив: застосовує огорнуту функцію до огорнутого значення
  • монада: застосовує функцію, яка повертає огорнуте значення, до огорнутого значення.

Любі друзі (думаю, якщо ви дочитали до цього моменту, то ми точно вже друзі), я думаю ви погодитесь, що монада — це дуже проста та РОЗУМНА ІДЕЯ (тм). Тепер, коли ви вже промокнули горло цим мануалом, чому б не позвати Мела Гібсона, та не допити пляшку до дна? Подивіться розділ про монади на LYAH. Там багато чого, про що я навіть не заікався, тому що Міран розтлумачив усе дуже детально.

Дякую, що дочитали! Як я вже казав, Dart — не дуже вдалий вибір для функціонального програмування. Усі приклади мені довелося дещо спростити, а імплементацію Maybe та й зовсім спотворити. Гарна новина у тому, що вам не потрібно буде вигадувати велосипедів — усе вже є у fpdart та dartz. Ідеї, які закладені у ці пакети — такі самі, що ми тільки-но зараз з вами пройшли.

Бонусом для тих, хто зміг-таки прорватися через усю цю тарабарщину буде DartPad з усіма прикладами — Dart_Functors,_Applicatives,_and_Monads_in_Pictures.

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Подключить ФП в проект = необосновано повысить порог входа)

Почему необосновано? По вашему ФП не даёт никаких плюшек?

Преимущества получаемые от ФП нивелируются порогом входа, для меня.

Можно где-то посмотреть пример не игрушечного backend приложения написанного в функциональном стиле? Чтобы было видно как их писать и какие преимущества это дает. Максимум что я использую это filter/map/reduce/maybe/either/promise. Как и зачем использовать остальное в реальном backend мне не понятно...

Для меня, основное преимущество — это максимальное отсутствие непредвиденных побочных эффектов, поскольку FP требует любые побочные эффекты описывать явным образом. Код максимально предсказуем благодаря тому, что всё «ядро» (бизнес-логика) — как правило, может и должна быть написана в виде pure функций.

Там, где нужно изменяемое состояние — опять же, есть специальные конструкции для этого, и «нельзя просто так взять и» поменять в произвольном месте значение произвольной переменной.

Наконец, более строгая обработка ошибок благодаря упомянутым вами Maybe / Either. Это действительно крайне удобно, когда проигнорировать возможную ошибку или отсутствие значения не даст уже компилятор.

Функтор — це будь-який клас, який визначає fmap

Не зовсім точно, там ще є додаткові умови, яким має відповідати fmap, а саме збереження ідентеті та композиції, тобто fmap id a має повернути a, також fmap (f . g) a має повертати те саме значення, що й fmap f . fmap g $ a.

Тому, якщо для типа Maybe реалізувати fmap таким чином, щоб вона завжди повертала fmap _ _ = Nothing, то це не буде функтором, тому що fmap id $ Just 42 має повертати Just 42, але ніяк не Nothing

Це призводить до того, що часто існує лише єдина можлива реалізація для fmap яка задовільнає цім умовам. Компілятор GHC дозволяє навіть автоматично виводити код для fmap, який би задовільняв цим умовам (розширення {-# LANGUAGE GeneralizedNewtypeDeriving #-}), тобто все що нам потрібно, це сказати deriving Monad, або додай до цього класу метод fmap сам дотями який він має бути

Також усі інші визначення.

Повністю з вами згоден, визначення спрощені і неточні, а код, що я тут набросав, — той і зовсім не підходить для використання у продакшн. Проте це переклад статті для початківців, його мета — зробити так, щоб коли людина підключила fpdart чи dartz до свого проєкту, то для неї було хоча б трошки менше «магії». Дайте людям шанс зацікавитись підходом, вони потім самі нам розкажуть де ми неправі 🙂

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

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

По статті дуже важко читати код на Dart, дуже багато якихось углових дужок, сміття... А якщо брати монади, та подумати як вони будуть виглядати без do блоків... Стекінг монад... Не знаю...

По статті дуже важко читати код на Dart, дуже багато якихось углових дужок, сміття

Думаю мені потрібно було б якось це ще більш виділити в інтро, що ця стаття — саме для Dart/Flutter девелоперів, які підключили fpdart/dartz до своіх проєктів і тепер максимально розгублені і не розуміють що тут взагалі коїться. Перепрошую за непорозуміння.

Якщо девелопер просто підключив fpdart/dartz і тепер максимально розгублений і не розуміє що відбувається — отже він точно щось зробив не зовсім правильно

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

Це ви гарно підмітили. Я б був радий сам почитати статтю на подібну тему. Ви не думали про те, щоб її написати? :)

Трохи не зараз у планах... Може на пенсії буде час

Підписатись на коментарі