Предыдущие статьи цикла:
В предыдущей статье я рассказал, как можно в TypeScript эмулировать полиморфизм родов высшего порядка. Давайте же теперь посмотрим, какие возможности это даёт функциональному программисту, и начнем мы с паттерна «класс типов» (type class).
Само понятие класса типов пришло из Haskell и было предложено впервые Филипом Уодлером и Стивеном Блоттом в 1988 году для реализации ad hoc-полиморфизма. Класс типов определяет множество типизированных функций и констант, которые должны существовать для каждого типа, который принадлежит данному классу. Поначалу звучит сложно, но на самом деле это достаточно простая и элегантная конструкция.
Что такое класс типов
В этой статье я буду давать упрощенное объяснение концепции классов типов, не затрагивающее словарь инстансов, разрешение конфликта инстансов и механизм вывода типов. Всё-таки TypeScript и JavaScript как его рантайм обладают существенно более простой системой типов, в которой отсутствует механизм неявной передачи аргументов в функцию (кроме this
). Поэтому то, что будет описано ниже, скорее будет походить на GHC Core Language, где классы типов передаются как явные аргументы.
Рассмотрим в качестве примера один из простейших классов типов — Show
, — который определяет операцию приведения к строке. Он определен в модуле fp-ts/lib/Show
:
interface Show<A> {
readonly show: (a: A) => string;
}
Это определение читается так: тип A
принадлежит классу Show
, если для A
определена функция show : (a: A) => string
.
Реализуется класс типов следующим образом:
const showString: Show<string> = {
show: s => JSON.stringify(s)
};
const showNumber: Show<number> = {
show: n => n.toString()
};
// Предположим, что есть тип «пользователь» с полями name и age:
const showUser: Show<User> = {
show: user => `User "${user.name}", ${user.age} years old`
};
Вся сила классов типов проявляется в их композиции. К примеру, мы легко можем написать реализацию класса типов Show
для определенной структуры — скажем, кортежа, — если у нас будет экземпляр Show
для содержимого этой структуры:
// В данном случае использование any оправданно, т.к. для типа T не важна
// конкретная типизация Show — она будет уточнена далее с помощью infer.
// Такой трюк позволяет исключить из T все элементы, которые не являются
// экземплярами Show:
const getShowTuple = <T extends Array<Show<any>>>(
...shows: T
): Show<{ [K in keyof T]: T[K] extends Show<infer A> ? A : never }> => ({
show: t => `[${t.map((a, i) => shows[i].show(a)).join(', ')}]`
});
Использование классов типов позволяет использовать подход наименьшего знания (principle of least knowledge, principle of least power) — когда функция запрашивает от своих аргументов только тот набор функциональных возможностей, который будет ей использован. В TypeScript за счет структурной типизации этот подход воспринимается очень органично, и использование классов типов позволяет развить эту идею.
Давайте рассмотрим еще один синтетический пример — нам надо написать функцию, которая для произвольной структуры данных приводит ее содержимое к строкам. Благодаря трюку из предыдущей статьи, классы типов можно писать не только для конкретных типов, но и для типов высшего порядка. Тип Mappable, он же Functor — это как раз пример такого класса типов. Функтор позволяет выполнять преобразования с сохранением структуры — к примеру, если у нас есть список, то операция map
изменит тип элементов, но сохранит порядок в этом списке; если у нас есть дерево — то map
сохранит последовательность ветвей и узлов; если у нас есть хэш-таблица — map
сохранит ключи нетронутыми. Функтор как раз и позволит нам решить поставленную задачу:
import { Kind } from 'fp-ts/lib/HKT';
import { Functor } from 'fp-ts/lib/Functor';
import { Show } from 'fp-ts/lib/Show';
const stringify = <F extends URIS, A>(F: Functor<F>, A: Show<A>) =>
(structure: Kind<F, A>): Kind<F, string> => F.map(structure, A.show);
Казалось бы, много «синтаксического шума» и непонятные преимущества, так? Но не спешите скептически вздымать бровь — давайте посмотрим, насколько большую гибкость дает использование такого подхода.
Отделение интерфейса класса типов от конкретной реализации позволяет писать полиморфный код, который будет сохранять работоспособность даже в случае изменения структуры данных. Предположим, вы пишете модуль комментариев для своего блога, и в первой реализации решаете, что ваши нужды удовлетворит простая линейная структура — поэтому решаете хранить комментарии в обычном списке:
interface Comment {
readonly author: string;
readonly text: string;
readonly createdAt: Date;
}
const comments: Comment[] = ...;
const renderComments = (comments: Comment[]): Component => <List>{comments.map(renderOneComment)}</List>;
const renderOneComment = (comment: Comment): Component => <ListItem>{comment.text} by {comment.author} at {comment.createdAt}</ListItem>
Когда вы поймете, что хорошо бы комментарии хранить в дереве, а не списке, вам придется переписать все места, где с коллекцией comments
обращаются как со списком.
Но вы можете воспользоваться подходом с классами типов, и организовать код несколько иначе:
interface ToComponent<A> {
readonly render: (element: A) => Component;
}
const commentToComponent: ToComponent<Comment> = {
render: comment => <>{comment.text} by {comment.author} at {comment.createdAt}</>
};
const arrayToComponent = <A>(TCA: ToComponent<A>): ToComponent<Comment[]> => ({
render: as => <List>{as.map(a => <ListItem>{TCA.render(a)}</ListItem>)}</List>
});
const treeToComponent = <A>(TCA: ToComponent<A>): ToComponent<Tree<Comment>> => ({
render: treeA => <div class="node">
{TCA.render(treeA.value)}
<div class="inset-relative-to-parent">
{treeA.children.map(treeToComponent(TCA).render)}
</div>
</div>
});
const renderComments =
<F extends URIS>(TCF: ToComponent<Kind<F, Comment>>) =>
(comments: Kind<F, Comment>) => TCF.render(comments);
...
// где-то в родительском компоненте вы просто заменяете это:
const commentArray: Comment[] = getFlatComments();
renderComments(arrayToComponent(commentToComponent))(commentArray);
// ...на это, не трогая остальной код рендера:
const commentTree: Tree<Comment> = getCommentHierarchy();
renderComments(treeToComponent(commentToComponent))(commentTree);
В целом, использование классов типов как паттерна проектирования в TypeScript можно описать так:
- Функциональность, которая может быть обобщена, выносится из базового типа данных в отдельный интерфейс, полиморфный по типу данных или типу контейнера.
- Каждая функция, которая хочет использовать эту функциональность, «запрашивает» нужный набор классов типов как первый каррированный аргумент. Это делается для того, чтобы не завязываться на конкретный экземпляр/instance класса типов — в результате получается более гибкое и тестируемое решение.
- Для различения экземплятор классов типов от обычных аргументов функции есть смысл давать им имена в UPPER_SNAKE_CASE, чтобы их использование бросалось в глаза на фоне camelCase в остальном коде. Понятно, что это хорошо работает в случае, если вы пишете идиоматично — если же ваш код $tyled_like_php, то вам стоит придумать свою нотацию.
Некоторые полезные классы типов
В библиотеке fp-ts
представлено достаточно много классов типов, в которых есть смысл разбираться, если вы хотите понимать подходы «взрослого» ФП.
Functor (fp-ts/lib/Functor)
Функтор определен операцией map : <A, B>(f: (a: A) => B) => (fa: F<A>) => F<B>
, которую можно рассматривать с двух точек зрения:
- Функтор для какого-либо вычислительного контекста
F
знает, как применить чистую функциюA => B
к значениюF<A>
, чтобы получилосьF<B>
. - Функтор умеет поднять чистую функцию
A => B
в вычислительный контекстF
так, что получается функцияF<A> => F<B>
.
Оба эти определения равноценны, но первое, по моему опыту, проще воспринимается разработчиками, а второе ближе математикам-теоркатегорщикам. В любом случае, суть одна — функтор позволяет изменить данные внутри какого-либо контекста без изменения структуры этого контекста.
Любой экземпляр функтора должен подчиняться двум законам:
- Сохранение идентичности:
map(id) ? id
- Сохранение композиции функций:
map(compose(f, g)) ? compose(map(f), map(g))
С функторами мы уже сталкивались в предыдущей и этой статье, и их полезность в целом нельзя преуменьшить — функторы дают начало целой плеяде других классов типов, поэтому если вы знаете, что у вас есть, к примеру, экземпляр монады, то автоматически у вас есть операция из класса типов Functor map
.
Monad (fp-ts/lib/Monad)
О, эта ужасная монада, она же буррито, она же railway, она же моноид в моноидальной категории эндофункторов. На самом деле, монада это предельно простая штука. Внимание, сейчас будет самый короткий монадический туториал!
Монада определяется правилом «1-2-3»: 1 тип, 2 операции и 3 закона:
- Монада может быть определена для типа высшего порядка — скажем, для конструкторов типов вроде Array, List, Tree, Option, Reader и т.д. — словом, всего, что мы привыкли видеть в дженериковой форме.
- Монада может быть определена двумя операциями, причем одним из двух равнозначных путей — операции
chain
иjoin
выражаются друг через друга, поэтому для описания монады достаточно толькоof
и одной из этих двух операций:
- Первый способ:
of : <A>(value: A) => F<A> chain : <A, B>(f: (a: A) => F<B>) => (fa: F<A>) => F<B>
- Второй способ:
of : <A>(value: A) => F<A> join : <A>(ffa: F<F<A>>) => F<A>
- Первый способ:
- Наконец, любая монада должна подчиняться трём законам:
- Закон идентичности слева:
chain(f)(of(a)) ? f(a)
- Закон идентичности справа:
chain(of)(m) ? m
- Закон ассоциативности:
chain(g)(chain(f)(m)) ? chain(x => chain(g)(f(x)))(m)
- Закон идентичности слева:
В хаскеле of
это pure
, а chain
это инфиксный оператор >>=
(читается «bind»):
- Закон идентичности слева:
pure a >>= f ? f a
- Закон идентичности справа:
m >>= pure ? m
- Закон ассоциативности:
(m >>= f) >>= g ? m >>= (\x -> f x >>= g)
Всё, туториал окончен, всем спасибо, все свободны. Домашнее задание: написать экземпляр монады для типа type Reader<R, A> = (env: R) => A
.
Зная это определение, вы можете сказать, что знаете, что такое монада. В них нет ничего мистического, ничего неявного и ничего сакрального — это просто тип, две операции и три закона, точка. С законами в языках без зависимой типизации дела обстоят несколько сложно, поэтому их есть смысл проверять с помощью тестирования через свойства (property-based testing).
Монада выражает идею последовательных вычислений. Посмотрите внимательно на сигнатуру функции chain
: один из ее аргументов это «упакованное» в вычислительный контекст F
значение типа A
, а другое — функция, которая принимает чистое значение типа A
, и которая возвращает новый вычислительный контекст со значением типа B
. И нет никакого другого способа получить значение типа A
из аргумента типа F<A>
, кроме как обработать этот вычислительный контекст F
. Простейший пример такого поведения — если у нас есть Promise<A>
, то получить оттуда значение типа A
можно только «подождав» выполнение промиса. К сожалению, сам промис как таковой не соответствует интерфейсу и поведению монады, но концепцию последовательности вычислений им проиллюстрировать можно.
Для удобной работы с монадическими цепочками в нормальных ФП-языках есть синтакцический сахар — do-нотация, for comprehension, — у нас же в TS нет ничего такого. Есть попытки сделать что-то на генераторах, но наиболее типобезопасным вариантом является Do из fp-ts-contrib. В следующих статьях я постараюсь показать его использование.
Monoid (fp-ts/lib/Monoid)
Моноид состоит из:
- Нейтрального элемента, еще называемого единица/unit:
empty : A
- Бинарной ассоциативной операции:
combine : (left: A, right: A) => A
Моноид также должен подчиняться 3 законам:
- Закон идентичности слева:
combine(empty, x) ? x
- Закон идентичности справа:
combine(x, empty) ? x
- Закон ассоциативности:
combine(combine(x, y), z) ? combine(x, combine(y, z))
Для чего может быть полезен моноид? В первую очередь — там, где мы хотим объединять сущности между собой, и таких мест может быть просто огромное количество. Я не стану здесь расписывать всё, а взамен предложу посмотреть прекрасный доклад Луки Якобовица «Monoids, monoids, monoids». Доклад на английском и для Scala, но суть любой инженер должен уловить достаточно легко — Лука не первый раз читает этот доклад и хорошо доносит мысль.
Существует еще масса полезных классов типов — например, Foldable/Traversable позволяют обходить структуры данных, применяя на каждом шаге определенную операцию в каком-то контексте; Applicative (который я не стал разбирать в этой статье, но обязательно вернусь в статье про типобезопасную валидацию) позволяет применять функцию в контексте к данным в контексте; Task/TaskEither/Future позволяют заменить хаотичные промисы на законопослушные примитивы синхронизации, и так далее. Но я не могу себе позволить раздувать эту статью еще больше. Поэтому на этом я предлагаю данную статью закончить, а в следующей поговорить о более конкретных и практически применимых классах типов и подойти к идее алгебраических эффектов.
san-smith
На удивление, определение моноида показалось на порядок проще определения монады — особенно, если провести аналогию с уже знакомыми операциями из математики — сложения или умножения.
Вот тут даже улыбнулся — пока не заглянул под спойлер, казалось, что разобрался, а в синтаксисе хаскеля сломал себе мозг.
В целом хочу сказать спасибо за интересный материал. Читалось довольно легко, даже удивился, когда увидел сетования на раздутость статьи — пришлось пролистать к началу, чтобы убедиться, что статья действительно объёмная.