Функциональное программирование на TypeScript: паттерн «класс типов»

ПрограммированиеФункциональное программированиеTypeScript

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков



В предыдущей статье я рассказал, как можно в TypeScript эмулировать полиморфизм родов высшего порядка. Давайте же теперь посмотрим, какие возможности это даёт функциональному программисту, и начнем мы с паттерна «класс типов» (type class).


Само понятие класса типов пришло из Haskell и было предложено впервые Филипом Уодлером и Стивеном Блоттом в 1988 году для реализации ad hoc-полиморфизма. Класс типов определяет множество типизированных функций и констант, которые должны существовать для каждого типа, который принадлежит данному классу. Поначалу звучит сложно, но на самом деле это достаточно простая и элегантная конструкция.


Что такое класс типов


Сразу оговорка для тех, кто хорошо разбирается в Haskell или Scala

В этой статье я буду давать упрощенное объяснение концепции классов типов, не затрагивающее словарь инстансов, разрешение конфликта инстансов и механизм вывода типов. Всё-таки 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 можно описать так:


  1. Функциональность, которая может быть обобщена, выносится из базового типа данных в отдельный интерфейс, полиморфный по типу данных или типу контейнера.
  2. Каждая функция, которая хочет использовать эту функциональность, «запрашивает» нужный набор классов типов как первый каррированный аргумент. Это делается для того, чтобы не завязываться на конкретный экземпляр/instance класса типов — в результате получается более гибкое и тестируемое решение.
  3. Для различения экземплятор классов типов от обычных аргументов функции есть смысл давать им имена в 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>, которую можно рассматривать с двух точек зрения:


  1. Функтор для какого-либо вычислительного контекста F знает, как применить чистую функцию A => B к значению F<A>, чтобы получилось F<B>.
  2. Функтор умеет поднять чистую функцию A => B в вычислительный контекст F так, что получается функция F<A> => F<B>.

Оба эти определения равноценны, но первое, по моему опыту, проще воспринимается разработчиками, а второе ближе математикам-теоркатегорщикам. В любом случае, суть одна — функтор позволяет изменить данные внутри какого-либо контекста без изменения структуры этого контекста.


Любой экземпляр функтора должен подчиняться двум законам:


  1. Сохранение идентичности: map(id) ≡ id
  2. Сохранение композиции функций: map(compose(f, g)) ≡ compose(map(f), map(g))

С функторами мы уже сталкивались в предыдущей и этой статье, и их полезность в целом нельзя преуменьшить — функторы дают начало целой плеяде других классов типов, поэтому если вы знаете, что у вас есть, к примеру, экземпляр монады, то автоматически у вас есть операция из класса типов Functor map.


Monad (fp-ts/lib/Monad)


О, эта ужасная монада, она же буррито, она же railway, она же моноид в моноидальной категории эндофункторов. На самом деле, монада это предельно простая штука. Внимание, сейчас будет самый короткий монадический туториал!


Монада определяется правилом «1-2-3»: 1 тип, 2 операции и 3 закона:


  1. Монада может быть определена для типа высшего порядка — скажем, для конструкторов типов вроде Array, List, Tree, Option, Reader и т.д. — словом, всего, что мы привыкли видеть в дженериковой форме.
  2. Монада может быть определена двумя операциями, причем одним из двух равнозначных путей — операции chain и join выражаются друг через друга, поэтому для описания монады достаточно только of и одной из этих двух операций:
    1. Первый способ:
      of : <A>(value: A) => F<A>
      chain : <A, B>(f: (a: A) => F<B>) => (fa: F<A>) => F<B>
    2. Второй способ:
      of : <A>(value: A) => F<A>
      join : <A>(ffa: F<F<A>>) => F<A>
  3. Наконец, любая монада должна подчиняться трём законам:
    1. Закон идентичности слева: chain(f)(of(a)) ≡ f(a)
    2. Закон идентичности справа: chain(of)(m) ≡ m
    3. Закон ассоциативности: chain(g)(chain(f)(m)) ≡ chain(x => chain(g)(f(x)))(m)

С синтаксисом хаскеля эти законы воспринимаются куда проще

В хаскеле of это pure, а chain это инфиксный оператор >>= (читается «bind»):


  1. Закон идентичности слева: pure a >>= f ≡ f a
  2. Закон идентичности справа: m >>= pure ≡ m
  3. Закон ассоциативности: (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)


Моноид состоит из:


  1. Нейтрального элемента, еще называемого единица/unit: empty : A
  2. Бинарной ассоциативной операции: combine : (left: A, right: A) => A

Моноид также должен подчиняться 3 законам:


  1. Закон идентичности слева: combine(empty, x) ≡ x
  2. Закон идентичности справа: combine(x, empty) ≡ x
  3. Закон ассоциативности: combine(combine(x, y), z) ≡ combine(x, combine(y, z))

Для чего может быть полезен моноид? В первую очередь — там, где мы хотим объединять сущности между собой, и таких мест может быть просто огромное количество. Я не стану здесь расписывать всё, а взамен предложу посмотреть прекрасный доклад Луки Якобовица «Monoids, monoids, monoids». Доклад на английском и для Scala, но суть любой инженер должен уловить достаточно легко — Лука не первый раз читает этот доклад и хорошо доносит мысль.




Существует еще масса полезных классов типов — например, Foldable/Traversable позволяют обходить структуры данных, применяя на каждом шаге определенную операцию в каком-то контексте; Applicative (который я не стал разбирать в этой статье, но обязательно вернусь в статье про типобезопасную валидацию) позволяет применять функцию в контексте к данным в контексте; Task/TaskEither/Future позволяют заменить хаотичные промисы на законопослушные примитивы синхронизации, и так далее. Но я не могу себе позволить раздувать эту статью еще больше. Поэтому на этом я предлагаю данную статью закончить, а в следующей поговорить о более конкретных и практически применимых классах типов и подойти к идее алгебраических эффектов.

Теги:typescripttsfpfp-tsфункциональное программирование
Хабы: Программирование Функциональное программирование TypeScript
+19
5k 73
Комментарии 25

Похожие публикации

Middle | High middle front-end разработчик (React + Typescript)
от 80 000 до 160 000 ₽CSSSRМожно удаленно
Senior TypeScript разработчик
от 3 500 $GrabrМоскваМожно удаленно
Frontend-разработчик React/Typescript
от 140 000 до 200 000 ₽KTSМосква
Backend TypeScript developer (Node.js)
от 250 000 ₽NetworkМоскваМожно удаленно
TypeScript Frontend Developer (Vue.js)
от 150 000 ₽NetworkМоскваМожно удаленно