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

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
25-Дек-2020 17:31

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

В предыдущей статье я рассказал, как можно в TypeScript эмулировать полиморфизм родов высшего порядка. Давайте же теперь посмотрим, какие возможности это даёт функциональному программисту, и начнем мы с паттерна «класс типов» (type class).
Само понятие класса типов пришло из Haskell и было предложено впервые Филипом Уодлером и Стивеном Блоттом в 1988 году для реализации ad hoc-полиморфизма. Класс типов определяет множество типизированных функций и констант, которые должны существовать для каждого типа, который принадлежит данному классу. Поначалу звучит сложно, но на самом деле это достаточно простая и элегантная конструкция.
Что такое класс типов

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

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

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

SPL
В хаскеле 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 позволяют заменить хаотичные промисы на законопослушные примитивы синхронизации, и так далее. Но я не могу себе позволить раздувать эту статью еще больше. Поэтому на этом я предлагаю данную статью закончить, а в следующей поговорить о более конкретных и практически применимых классах типов и подойти к идее алгебраических эффектов.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_funktsionalnoe_programmirovanie (Функциональное программирование), #_typescript, #_typescript, #_ts, #_fp, #_fpts, #_funktsionalnoe_programmirovanie (функциональное программирование), #_programmirovanie (
Программирование
)
, #_funktsionalnoe_programmirovanie (
Функциональное программирование
)
, #_typescript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 18:03
Часовой пояс: UTC + 5