Функтори, Аплікативи, та Монади з ілюстраціями
Привіт! Мене звати Олександр. Вже близько 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
Монади
Як зрозуміти, що такое Монада:
- Здобути освіту на факультеті інформатики у Києво-могилянській академії.
- Викинути диплом, бо він тут не знадобиться!
Монади дають жару.
Функтори застосовують функцію до огорнутого значення:
Аплікативи застосовують огонуту функцію до огорнутого значення:
Монади застосовуть функцію, що вертає огорнуте значення, до огорнутого значення. Вони мають гарну функцію 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 тут не потрібна, отже я пропускаю цю частину.
Висновки
- Функтор — це клас, який імплементує map
- Аплікатив — це клас, який імплементує apply
- Монада — це клас, який імплементує bind
- Maybe — імплементує усі три, отже це функтор, аплікатив, та монада.
У чому між ними різниця?
- функтор: застосовує функцію до огорнутого значення
- аплікатив: застосовує огорнуту функцію до огорнутого значення
- монада: застосовує функцію, яка повертає огорнуте значення, до огорнутого значення.
Любі друзі (думаю, якщо ви дочитали до цього моменту, то ми точно вже друзі), я думаю ви погодитесь, що монада — це дуже проста та РОЗУМНА ІДЕЯ (тм). Тепер, коли ви вже промокнули горло цим мануалом, чому б не позвати Мела Гібсона, та не допити пляшку до дна? Подивіться розділ про монади на LYAH. Там багато чого, про що я навіть не заікався, тому що Міран розтлумачив усе дуже детально.
Дякую, що дочитали! Як я вже казав, Dart — не дуже вдалий вибір для функціонального програмування. Усі приклади мені довелося дещо спростити, а імплементацію Maybe та й зовсім спотворити. Гарна новина у тому, що вам не потрібно буде вигадувати велосипедів — усе вже є у fpdart та dartz. Ідеї, які закладені у ці пакети — такі самі, що ми тільки-но зараз з вами пройшли.
Бонусом для тих, хто зміг-таки прорватися через усю цю тарабарщину буде DartPad з усіма прикладами — Dart_Functors,_Applicatives,_and_Monads_in_Pictures.
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів