[Программирование, Функциональное программирование, TypeScript] Функциональное программирование на TypeScript: задачи (tasks) как альтернатива промисам
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Предыдущие статьи цикла:
- Полиморфизм родов высших порядков
- Паттерн «класс типов»
- Option и Either как замены nullable-типам и исключениям
В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам — задачам (tasks). Они позволят нам подойти к понятию алгебраических эффектов, которые я подробно рассмотрю в следующих статьях.
Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.
Promise/A+, который мы потеряли заслужили
В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:
- Добавить статический метод конструирования промиса Promise.point:
Promise.point = function(a) {
// ...
};
- Добавить метод onRejected для обработки состояния неудачи:
Promise.prototype.onRejected = function(callback) {
// ...
};
- Сделать так, чтобы Promise.prototype.then принимал только один коллбэк, и этот коллбэк обязательно должен возвращать промис:
Promise.prototype.then = function(onFulfilled) {
// ...
};
- Наконец, сделать промис ленивым, добавив метод done:
Promise.prototype.done = function() {
// ...
};
Эти изменения позволили бы получить простое расширяемое API, которое в дальнейшем позволило бы элегантно отделять поведение контекста вычислений от непосредственной бизнес-логики — скажем, так, как это сделано в Haskell с его do-нотацией, или в Scala с for comprehension. К сожалению, так называемые «прагматики» в лице Доменика Дениколы и нескольких других контрибьюторов отвергли эти предложения, поэтому промисы в JS так и остались невнятным энергичным бастардом, которого достаточно проблематично использовать в идиоматичном ФП-коде, предполагающим equational reasoning и соблюдение принципа ссылочной прозрачности. Тем не менее, благодаря достаточно простому трюку можно сделать из промисов законопослушную абстракцию, для которой можно реализовать экземпляры функтора, аппликатива, монады и много чего еще.
Task<A> — ленивый промис
Первой абстракций, которая позволит сделать промис законопослушным, является Task. Task<A> — это примитив асинхронных вычислений, который олицетворяет задачу, которая всегда завершается успешно со значением типа A (то есть не содержит выразительных средств для представления ошибочного состояния):
// Task — ленивый примитив асинхронных вычислений
type Task<A> = () => Promise<A>;
// Уникальный идентификатор ресурса — тэг типа (type tag)
const URI = 'Task';
type URI = typeof URI;
// Определение Task как типа высшего порядка (higher-kinded type)
declare module 'fp-ts/HKT' {
interface URItoKind<A> {
[URI]: Task<A>;
}
}
Для Task можно определить экземпляры классов типов Functor, Apply, Applicative, Monad. Обратите внимание, как один из самых простых классов типов — функтор — порождает структуры, обладающие всё более и более сложным поведением.
N.B.: Также оговорюсь, что для простоты реализации код по обработке состояния rejected в промисах, использующихся внутри Task, не пишется — подразумевается, что конструирование экземпляров Task происходит при помощи функций-конструкторов, а не ad hoc.
Функтор позволяет преобразовывать значение, которое будет возвращено задачей, из типа A в тип B при помощи чистой функции:
const Functor: Functor1<URI> = {
URI,
map: <A, B>(
taskA: Task<A>,
transform: (a: A) => B
): Task<B> => async () => {
const prevResult = await taskA();
return transform(prevResult);
},
};
Apply позволяет применять некую функцию преобразования, получающуюся асинхронно, к данным, которые будут возвращены задачей. Для Task можно написать два экземпляра Apply — один будет вычислять результат и функцию преобразования последовательно, другой — параллельно:
const Apply: Apply1<URI> = {
...Functor,
ap: <A, B>(
taskA2B: Task<(a: A) => B>,
taskA: Task<A>
): Task<B> => async () => {
const transformer = await taskA2B();
const prevResult = await taskA();
return transformer(prevResult);
},
};
const ApplyPar: Apply1<URI> = {
...Functor,
ap: <A, B>(
taskA2B: Task<(a: A) => B>,
taskA: Task<A>
): Task<B> => async () => {
const [transformer, prevResult] = await Promise.all([taskA2B(), taskA()]);
return transformer(prevResult);
},
};
Аппликативный функтор (аппликатив) позволяет конструировать новые значения некоего типа F, «поднимая» (lift) их в вычислительный контекст F. В нашем случае — аппликатив оборачивает чистое значение в задачу. Для простоты я буду использовать последовательный экземпляр Apply для наследования:
const Applicative: Applicative1<URI> = {
...Apply,
of: <A>(a: A): Task<A> => async () => a,
};
Монада позволяет организовывать последовательные вычисления — сначала вычисляется результат предыдущей задачи, после чего полученный результат используется для последующих вычислений. Обратите внимание: хоть мы и можем использовать для определения монады любой экземпляр аппликатива — как базирующийся на последовательном Apply, так и на параллельном, — функция chain, являющаяся сердцем монады, вычисляется для Task строго последовательно. Это напрямую следует из типов, и, в целом, не является чем-то сложным — но я считаю своей обязанностью обратить на это внимание:
const Monad: Monad1<URI> = {
...Applicative,
chain: <A, B>(
taskA: Task<A>,
next: (a: A) => Task<B>
): Task<B> => async () => {
const prevResult = await taskA();
const nextTask = next(prevResult);
return nextTask();
},
};
N.B.: так как экземпляр монады для Task может наследоваться от одного из двух экземпляров аппликатива — параллельного или последовательного, — то подставляя нужный экземпляр монады в программы, написанные в стиле Tagless Final, можно получить разное поведение аппликативных операций. Про реализацию стиля Tagless Final на тайпскрипте можно почитать в этом треде #MonadicMondays.
Имея на руках такие выразительные способности, как монада и функтор, можно уже писать простые программы в императивном стиле: делать ветвление, вычислять что-либо рекурсивно. Но для работы над задачами реального мира необходимо уметь выражать ошибочное состояние, и в этом поможет следующая абстракция — TaskEither.
TaskEither<E, A> — задача, которая может вернуть ошибку
В предыдущей статье мы рассмотрели тип данных Either, который представляет вычисления, которые могут идти по одному из двух путей. Для типа Either можно реализовать экземпляры функтора, монады, альтернативы (Alt + Alternative, позволяет выражать fallback-значения), бифунктора (позволяет модифицировать одновременно как левую, так и правую часть Either) и много чего еще.
Комбинируя Task и Either, мы получаем абстракцию, которая обладает новой семантикой — TaskEither<E, A> это асинхронные вычисления, которые могут завершиться успешно со значением типа A или завершиться неудачей с ошибкой типа E. В fp-ts для TaskEither реализован ряд комбинаторов, как то:
- bracket позволяет безопасно получить (acquire), использовать (use) и утилизировать (release) какой-либо ресурс — например, соединение с базой данных или файловый дескриптор. При этом функция release вызовется вне зависмости от того, завершилась ли функция use успехом или неудачей:
bracket: <E, A, B>(
acquire: TaskEither<E, A>,
use: (a: A) => TaskEither<E, B>,
release: (a: A, e: E.Either<E, B>) => TaskEither<E, void>
) => TaskEither<E, B>
- tryCatch оборачивает промис, который может быть отклонен, в промис, который никогда не может быть отклонен и который возвращает Either. Эта функция вместе со следующей функцией taskify — один из краеугольных камней для адаптации функций сторонних библиотек к функциональному стилю. Также есть функция tryCatchK, которая умеет работать с функциями от нескольких аргументов:
tryCatch: <E, A>(
f: Lazy<Promise<A>>,
onRejected: (reason: unknown) => E
) => TaskEither<E, A>
tryCatchK: <E, A extends readonly unknown[], B>(
f: (...a: A) => Promise<B>,
onRejected: (reason: unknown) => E
) => (...a: A) => TaskEither<E, B>
- taskify — функция, которая позволяет превратить коллбэк в стиле Node.js в функцию, возвращающую TaskEither. taskify перегружена для оборачивания функций от 0 до 6 аргументов + коллбэк:
taskify<A, L, R>(
f: (a: A, cb: (e: L | null | undefined, r?: R) => void
) => void): (a: A) => TaskEither<L, R>
Благодаря тому, что для TaskEither реализованы экземпляры Traversable и Foldable, возможна простая работа по обходу массива задач. Функции traverseArray, traverseArrayWithIndex, sequenceArray и их последовательные вариации traverseSeqArray, traverseSeqArrayWithIndex, sequenceSeqArray позволяют обойти массив задач и получить как результат задачу, чьим результатом является массив результатов. Например, вот как можно написать программу, которая должна прочитать три файла с диска и записать их содержимое в единый новый файл:
import * as fs from 'fs';
import { pipe } from 'fp-ts/function';
import * as Console from 'fp-ts/Console';
import * as TE from 'fp-ts/TaskEither';
// Сначала я оберну функции из системного модуля `fs` при помощи `taskify`, сделав их чистыми:
const readFile = TE.taskify(fs.readFile);
const writeFile = TE.taskify(fs.writeFile);
const program = pipe(
// Входная точка — массив задач по чтению трёх файлов с диска:
[readFile('/tmp/file1'), readFile('/tmp/file2'), readFile('/tmp/file3')],
// Для текущей задачи важен порядок обхода массива, поэтому я использую
// последовательную, а не параллельную версию traverseArray:
TE.traverseSeqArray(TE.map(buffer => buffer.toString('utf8'))),
// При помощи функции `chain` из интерфейса монады я организую
// последовательность вычислений:
TE.chain(fileContents =>
writeFile('/tmp/combined-file', fileContents.join('\n\n'))),
// Наконец, в финале я хочу узнать, завершилась ли программа успешно или
// ошибочно, и залогировать это. Тут мне поможет модуль `fp-ts/Console`,
// содержащий чистые функции по работе с консолью:
TE.match(
err => TE.fromIO(Console.error(`An error happened: ${err.message}`)),
() => TE.fromIO(Console.log('Successfully written to combined file')),
)
);
// Наконец, запускаем нашу чистую программу на выполнение,
// выполняя все побочные эффекты:
await program();
N.B.: Если обратите внимание, то я пишу про функции, возвращающие TaskEither, как про чистые. В прошлых статьях я вскользь затрагивал эту тему: в функциональном подходе многое строится на создании описания вычислений с последующей интерпретацией их по необходимости. Когда я буду рассказывать про алгебраические эффекты и свободные монады, эта тема будет раскрыта более полно; сейчас же я просто скажу, что Task/TaskEither/ReaderTaskEither/etc. — это просто значения, а не запущенные вычисления, поэтому с ними можно обращаться более вольготно, чем с промисами. Именно ленивость Task'ов позволяет им быть настолько удобной и мощной абстракцией. Код, написанный с применением TaskEither, проще рефакторить с помощью принципа ссылочной прозрачности: задачи можно спокойно создавать, отменять и передавать в другие функции.
Казалось бы, TaskEither дает хорошие выразительные способности — в типах видно, какой результат и какую ошибку может вернуть функция. Но мы можем пойти еще немного дальше и добавить еще один уровень абстракции — Reader.
Reader — доступ к неизменному вычислительному контексту
Если мы возьмем тип функции A -> B, и зафиксируем тип аргумента A как неизменный, мы получим структуру, для которой можно определить экземпляры функтора, аппликатива, монады, профунктора, категории и т.п., которую назвали Reader:
// Reader это функция из некоторого окружения типа `E` в значение типа `A`:
type Reader<E, A> = (env: E) => A;
// Reader является типом высшего порядка, поэтому определим всё необходимое:
const URI = 'Reader';
type URI = typeof URI;
declare module 'fp-ts/HKT' {
interface URItoKind2<E, A> {
readonly [URI]: Reader<E, A>;
}
}
Для Reader можно определить экземпляры следующих классов типов:
// Функтор:
const Functor: Functor2<URI> = {
URI,
map: <R, A, B>(
fa: Reader<R, A>,
f: (a: A) => B
): Reader<R, B> => (env) => f(fa(env))
};
// Apply:
const Apply: Apply2<URI> = {
...Functor,
ap: <R, A, B>(
fab: Reader<R, (a: A) => B>,
fa: Reader<R, A>
): Reader<R, B> => (env) => {
const fn = fab(env);
const a = fa(env);
return fn(a);
}
};
// Аппликативный функтор:
const Applicative: Applicative2<URI> = {
...Apply,
of: <R, A>(a: A): Reader<R, A> => (_) => a
};
// Монада:
const Monad: Monad2<URI> = {
...Applicative,
chain: <R, A, B>(
fa: Reader<R, A>,
afb: (a: A) => Reader<R, B>
): Reader<R, B> => (env) => {
const a = fa(env);
const fb = afb(a);
return fb(env);
},
};
Reader позволяет реализовать интересный паттерн — доступ к некоторому неизменному окружению. Предположим, мы хотим, чтобы у приложения был доступ к конфигурации со следующим типом:
interface AppConfig {
readonly host: string; // имя хоста веб-сервера
readonly port: number; // порт, который будет слушать веб-сервер
readonly connectionString: string; // параметры соединения с некоторой БД
}
Для упрощения я сделаю типы БД и express алиасами для строковых литералов — сейчас мне не так важно, какой бизнес-тип будут возвращать функции; важнее продемонстрировать принципы работы с Reader:
type Database = 'connected to the db';
type Express = 'express is listening';
// Наше приложение — это *значение типа A*, вычисляемое *в контексте доступа
// к конфигурации типа AppConfig*:
type App<A> = Reader<AppConfig, A>;
Для начала напишем функцию, которая соединяется с нашим фейковым экспрессом:
const expressServer: App<Express> = pipe(
// `ask` позволяет «запросить» от окружения значение типа AppConfig.
// Ее реализация тривиальна:
// const ask = <R>(): Reader<R, R> => r => r;
R.ask<AppConfig>(),
// Я использую функтор, чтобы получить доступ к конфигу и что-то сделать
// на его основе — например, залогировать параметры и вернуть значение
// типа `Express`:
R.map(
config => {
console.log(`${config.host}:${config.port}`);
// В реальном приложении здесь нужно выполнять асинхронные операции
// по запуску сервера.
// Мы поговорим о работе с асинхронностью в следующей секции:
return 'express is listening';
},
),
);
Функция databaseConnection работает в контексте конфига и возвращает соединение с фейковой БД:
const databaseConnection: App<Database> = pipe(
// `asks` позволяет запросить значение определенного типа и сразу же
// преобразовать его в какое-то другое — например, здесь я просто достаю
// из конфига строку с параметрами соединения:
R.asks<AppConfig, string>(cfg => cfg.connectionString),
R.map(
connectionString => {
console.log(connectionString);
return 'connected to the db';
},
),
);
Наконец, наше приложение не будет ничего возвращать, но всё так же работать в контексте конфига. Здесь я воспользуюсь функцией sequenceS из модуля fp-ts/Apply, чтобы преобразовать структуру вида
interface AdHocStruct {
readonly db: App<Database>;
readonly express: App<Express>;
}
к типу App<{ readonly db: Database; readonly express: Express }>. Мы якобы «достаём» из структуры данные, обёрнутые в контекст App, и собираем новый контекст App с похожей структурой, только содержащей уже чистые данные:
import { sequenceS } from 'fp-ts/Apply';
const seq = sequenceS(R.Apply);
const application: App<void> = pipe(
seq({
db: databaseConnection,
express: expressServer
}),
R.map(
({ db, express }) => {
console.log([db, express].join('; '));
console.log('app was initialized');
return;
},
),
);
Чтобы «запустить» Reader<E, A> на выполнение, ему необходимо передать аргумент того типа, который зафиксирован в типопеременной E, и результатом будет значение типа A:
application({
host: 'localhost',
port: 8080,
connectionString: 'mongo://localhost:271017',
});
Наконец, объединяя две вышеописанные концепции, мы приходим к последней для данной статьи абстракции — ReaderTaskEither.
ReaderTaskEither<R, E, A> — задача, выполняющаяся в контексте окружения
Комбинируя Reader и TaskEither, мы получаем следующую абстракцию: ReaderTaskEither<R, E, A> — это асинхронные вычисления, которые имеют доступ к некоему неизменному окружению типа R, могут вернуть результат типа A или ошибку типа E. Оказалось, что такая конструкция позволяет описывать подавляющее большинство задач, с которыми в принципе приходится сталкиваться программисту при написании функций. Более того, заполняя типопараметры ReaderTaskEither значениями any и never, можно получить такие абстракции:
// Task никогда не может упасть и может быть запущен в любом окружении:
type Task<A> = ReaderTaskEither<any, never, A>;
// ReaderTask никогда не падает, но требует для работы окружения типа `R`:
type ReaderTask<R, A> = ReaderTaskEither<R, never, A>;
// TaskError может упасть с обобщенной ошибкой типа Error:
type TaskError<A> = ReaderTaskEither<any, Error, A>;
// ReaderTaskError может упасть с ошибкой типа Error и требует для работы
// окружение типа `R`:
type ReaderTaskError<R, A> = ReaderTaskEither<R, Error, A>;
// TaskEither, с которым мы познакомились ранее, может быть представлен как
// алиас для ReaderTaskEither, который может быть запущен в любом окружении:
type TaskEither<E, A> = ReaderTaskEither<any, E, A>;
Для ReaderTaskEither в соответствующем модуле fp-ts реализовано большое количество конструкторов, деструкторов и комбинаторов. Однако сам по себе ReaderTaskEither не так интересен, как схожая по семантике с ним ZIO-подобная конструкция, которая несёт дополнительный интересный механизм под капотом, называемый свободными монадами.
N.B. Про ReaderTaskEither я достаточно много говорил на камеру в пятом эпизоде видеоподкаста «ФП для чайника». Пример, который я там рассматриваю, можно найти здесь.
На этом данную статью я заканчиваю. Абстракция ReaderTaskEither плавно подвела нас к концепции алгебраических эффектов. Но перед тем, как рассмотреть их на примере ZIO-подобной библиотеки Effect-TS, в следующей статье я хочу поговорить о свободных конструкциях на примере свободных и более свободных монад (Free & Freer monads).
Вы можете найти примеры кода из этой статье у меня в Gist на гитхабе.
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, Java, Параллельное программирование, Конференции] Обзор программы JPoint 2021: воркшопы, Spring, «игра вдолгую»
- [Программирование, Java] Удаленный доступ к IDE при помощи Projector
- [Программирование, Математика, Учебный процесс в IT] Computer Science Center открыл приём заявок на новый учебный год
- [Управление e-commerce, Контент-маркетинг, Бизнес-модели, Социальные сети и сообщества] Как социальная коммерция захватывает Китай. И почему это неизбежно случится в России
- [Информационная безопасность, Конференции] Приглашаем на ZeroNights 2021
- [Разработка веб-сайтов, JavaScript, ООП, ReactJS, TypeScript] Еще один подход к построению архитектуры на фронте
- [Управление продуктом, Производство и разработка электроники, Компьютерное железо, Процессоры] Intel открывает новые заводы и передаёт часть производства сторонним компаниям
- [Фриланс, Законодательство в IT] Заказчик не платит: как защититься или как забрать свой аванс у арендодателя?
- [Big Data] Как саботировать данные, которые технологические гиганты используют для слежки за вами (перевод)
- [Программирование, IT-инфраструктура, Серверная оптимизация, Лайфхаки для гиков] Уход от проблемы TTL или Стратегии корректного и быстрого кэширования (перевод)
Теги для поиска: #_programmirovanie (Программирование), #_funktsionalnoe_programmirovanie (Функциональное программирование), #_typescript, #_typescript, #_ts, #_fp, #_fpts, #_funktsionalnoe_programmirovanie (функциональное программирование), #_programmirovanie (
Программирование
), #_funktsionalnoe_programmirovanie (
Функциональное программирование
), #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 08:47
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Предыдущие статьи цикла:
В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам — задачам (tasks). Они позволят нам подойти к понятию алгебраических эффектов, которые я подробно рассмотрю в следующих статьях. Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts. Promise/A+, который мы потеряли заслужили В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:
Эти изменения позволили бы получить простое расширяемое API, которое в дальнейшем позволило бы элегантно отделять поведение контекста вычислений от непосредственной бизнес-логики — скажем, так, как это сделано в Haskell с его do-нотацией, или в Scala с for comprehension. К сожалению, так называемые «прагматики» в лице Доменика Дениколы и нескольких других контрибьюторов отвергли эти предложения, поэтому промисы в JS так и остались невнятным энергичным бастардом, которого достаточно проблематично использовать в идиоматичном ФП-коде, предполагающим equational reasoning и соблюдение принципа ссылочной прозрачности. Тем не менее, благодаря достаточно простому трюку можно сделать из промисов законопослушную абстракцию, для которой можно реализовать экземпляры функтора, аппликатива, монады и много чего еще. Task<A> — ленивый промис Первой абстракций, которая позволит сделать промис законопослушным, является Task. Task<A> — это примитив асинхронных вычислений, который олицетворяет задачу, которая всегда завершается успешно со значением типа A (то есть не содержит выразительных средств для представления ошибочного состояния): // Task — ленивый примитив асинхронных вычислений
type Task<A> = () => Promise<A>; // Уникальный идентификатор ресурса — тэг типа (type tag) const URI = 'Task'; type URI = typeof URI; // Определение Task как типа высшего порядка (higher-kinded type) declare module 'fp-ts/HKT' { interface URItoKind<A> { [URI]: Task<A>; } } Для Task можно определить экземпляры классов типов Functor, Apply, Applicative, Monad. Обратите внимание, как один из самых простых классов типов — функтор — порождает структуры, обладающие всё более и более сложным поведением. N.B.: Также оговорюсь, что для простоты реализации код по обработке состояния rejected в промисах, использующихся внутри Task, не пишется — подразумевается, что конструирование экземпляров Task происходит при помощи функций-конструкторов, а не ad hoc.
const Functor: Functor1<URI> = {
URI, map: <A, B>( taskA: Task<A>, transform: (a: A) => B ): Task<B> => async () => { const prevResult = await taskA(); return transform(prevResult); }, }; Apply позволяет применять некую функцию преобразования, получающуюся асинхронно, к данным, которые будут возвращены задачей. Для Task можно написать два экземпляра Apply — один будет вычислять результат и функцию преобразования последовательно, другой — параллельно: const Apply: Apply1<URI> = {
...Functor, ap: <A, B>( taskA2B: Task<(a: A) => B>, taskA: Task<A> ): Task<B> => async () => { const transformer = await taskA2B(); const prevResult = await taskA(); return transformer(prevResult); }, }; const ApplyPar: Apply1<URI> = { ...Functor, ap: <A, B>( taskA2B: Task<(a: A) => B>, taskA: Task<A> ): Task<B> => async () => { const [transformer, prevResult] = await Promise.all([taskA2B(), taskA()]); return transformer(prevResult); }, }; Аппликативный функтор (аппликатив) позволяет конструировать новые значения некоего типа F, «поднимая» (lift) их в вычислительный контекст F. В нашем случае — аппликатив оборачивает чистое значение в задачу. Для простоты я буду использовать последовательный экземпляр Apply для наследования: const Applicative: Applicative1<URI> = {
...Apply, of: <A>(a: A): Task<A> => async () => a, }; Монада позволяет организовывать последовательные вычисления — сначала вычисляется результат предыдущей задачи, после чего полученный результат используется для последующих вычислений. Обратите внимание: хоть мы и можем использовать для определения монады любой экземпляр аппликатива — как базирующийся на последовательном Apply, так и на параллельном, — функция chain, являющаяся сердцем монады, вычисляется для Task строго последовательно. Это напрямую следует из типов, и, в целом, не является чем-то сложным — но я считаю своей обязанностью обратить на это внимание: const Monad: Monad1<URI> = {
...Applicative, chain: <A, B>( taskA: Task<A>, next: (a: A) => Task<B> ): Task<B> => async () => { const prevResult = await taskA(); const nextTask = next(prevResult); return nextTask(); }, }; N.B.: так как экземпляр монады для Task может наследоваться от одного из двух экземпляров аппликатива — параллельного или последовательного, — то подставляя нужный экземпляр монады в программы, написанные в стиле Tagless Final, можно получить разное поведение аппликативных операций. Про реализацию стиля Tagless Final на тайпскрипте можно почитать в этом треде #MonadicMondays.
TaskEither<E, A> — задача, которая может вернуть ошибку В предыдущей статье мы рассмотрели тип данных Either, который представляет вычисления, которые могут идти по одному из двух путей. Для типа Either можно реализовать экземпляры функтора, монады, альтернативы (Alt + Alternative, позволяет выражать fallback-значения), бифунктора (позволяет модифицировать одновременно как левую, так и правую часть Either) и много чего еще. Комбинируя Task и Either, мы получаем абстракцию, которая обладает новой семантикой — TaskEither<E, A> это асинхронные вычисления, которые могут завершиться успешно со значением типа A или завершиться неудачей с ошибкой типа E. В fp-ts для TaskEither реализован ряд комбинаторов, как то:
Благодаря тому, что для TaskEither реализованы экземпляры Traversable и Foldable, возможна простая работа по обходу массива задач. Функции traverseArray, traverseArrayWithIndex, sequenceArray и их последовательные вариации traverseSeqArray, traverseSeqArrayWithIndex, sequenceSeqArray позволяют обойти массив задач и получить как результат задачу, чьим результатом является массив результатов. Например, вот как можно написать программу, которая должна прочитать три файла с диска и записать их содержимое в единый новый файл: import * as fs from 'fs';
import { pipe } from 'fp-ts/function'; import * as Console from 'fp-ts/Console'; import * as TE from 'fp-ts/TaskEither'; // Сначала я оберну функции из системного модуля `fs` при помощи `taskify`, сделав их чистыми: const readFile = TE.taskify(fs.readFile); const writeFile = TE.taskify(fs.writeFile); const program = pipe( // Входная точка — массив задач по чтению трёх файлов с диска: [readFile('/tmp/file1'), readFile('/tmp/file2'), readFile('/tmp/file3')], // Для текущей задачи важен порядок обхода массива, поэтому я использую // последовательную, а не параллельную версию traverseArray: TE.traverseSeqArray(TE.map(buffer => buffer.toString('utf8'))), // При помощи функции `chain` из интерфейса монады я организую // последовательность вычислений: TE.chain(fileContents => writeFile('/tmp/combined-file', fileContents.join('\n\n'))), // Наконец, в финале я хочу узнать, завершилась ли программа успешно или // ошибочно, и залогировать это. Тут мне поможет модуль `fp-ts/Console`, // содержащий чистые функции по работе с консолью: TE.match( err => TE.fromIO(Console.error(`An error happened: ${err.message}`)), () => TE.fromIO(Console.log('Successfully written to combined file')), ) ); // Наконец, запускаем нашу чистую программу на выполнение, // выполняя все побочные эффекты: await program(); N.B.: Если обратите внимание, то я пишу про функции, возвращающие TaskEither, как про чистые. В прошлых статьях я вскользь затрагивал эту тему: в функциональном подходе многое строится на создании описания вычислений с последующей интерпретацией их по необходимости. Когда я буду рассказывать про алгебраические эффекты и свободные монады, эта тема будет раскрыта более полно; сейчас же я просто скажу, что Task/TaskEither/ReaderTaskEither/etc. — это просто значения, а не запущенные вычисления, поэтому с ними можно обращаться более вольготно, чем с промисами. Именно ленивость Task'ов позволяет им быть настолько удобной и мощной абстракцией. Код, написанный с применением TaskEither, проще рефакторить с помощью принципа ссылочной прозрачности: задачи можно спокойно создавать, отменять и передавать в другие функции.
Reader — доступ к неизменному вычислительному контексту Если мы возьмем тип функции A -> B, и зафиксируем тип аргумента A как неизменный, мы получим структуру, для которой можно определить экземпляры функтора, аппликатива, монады, профунктора, категории и т.п., которую назвали Reader: // Reader это функция из некоторого окружения типа `E` в значение типа `A`:
type Reader<E, A> = (env: E) => A; // Reader является типом высшего порядка, поэтому определим всё необходимое: const URI = 'Reader'; type URI = typeof URI; declare module 'fp-ts/HKT' { interface URItoKind2<E, A> { readonly [URI]: Reader<E, A>; } } Для Reader можно определить экземпляры следующих классов типов: // Функтор:
const Functor: Functor2<URI> = { URI, map: <R, A, B>( fa: Reader<R, A>, f: (a: A) => B ): Reader<R, B> => (env) => f(fa(env)) }; // Apply: const Apply: Apply2<URI> = { ...Functor, ap: <R, A, B>( fab: Reader<R, (a: A) => B>, fa: Reader<R, A> ): Reader<R, B> => (env) => { const fn = fab(env); const a = fa(env); return fn(a); } }; // Аппликативный функтор: const Applicative: Applicative2<URI> = { ...Apply, of: <R, A>(a: A): Reader<R, A> => (_) => a }; // Монада: const Monad: Monad2<URI> = { ...Applicative, chain: <R, A, B>( fa: Reader<R, A>, afb: (a: A) => Reader<R, B> ): Reader<R, B> => (env) => { const a = fa(env); const fb = afb(a); return fb(env); }, }; Reader позволяет реализовать интересный паттерн — доступ к некоторому неизменному окружению. Предположим, мы хотим, чтобы у приложения был доступ к конфигурации со следующим типом: interface AppConfig {
readonly host: string; // имя хоста веб-сервера readonly port: number; // порт, который будет слушать веб-сервер readonly connectionString: string; // параметры соединения с некоторой БД } Для упрощения я сделаю типы БД и express алиасами для строковых литералов — сейчас мне не так важно, какой бизнес-тип будут возвращать функции; важнее продемонстрировать принципы работы с Reader: type Database = 'connected to the db';
type Express = 'express is listening'; // Наше приложение — это *значение типа A*, вычисляемое *в контексте доступа // к конфигурации типа AppConfig*: type App<A> = Reader<AppConfig, A>; Для начала напишем функцию, которая соединяется с нашим фейковым экспрессом: const expressServer: App<Express> = pipe(
// `ask` позволяет «запросить» от окружения значение типа AppConfig. // Ее реализация тривиальна: // const ask = <R>(): Reader<R, R> => r => r; R.ask<AppConfig>(), // Я использую функтор, чтобы получить доступ к конфигу и что-то сделать // на его основе — например, залогировать параметры и вернуть значение // типа `Express`: R.map( config => { console.log(`${config.host}:${config.port}`); // В реальном приложении здесь нужно выполнять асинхронные операции // по запуску сервера. // Мы поговорим о работе с асинхронностью в следующей секции: return 'express is listening'; }, ), ); Функция databaseConnection работает в контексте конфига и возвращает соединение с фейковой БД: const databaseConnection: App<Database> = pipe(
// `asks` позволяет запросить значение определенного типа и сразу же // преобразовать его в какое-то другое — например, здесь я просто достаю // из конфига строку с параметрами соединения: R.asks<AppConfig, string>(cfg => cfg.connectionString), R.map( connectionString => { console.log(connectionString); return 'connected to the db'; }, ), ); Наконец, наше приложение не будет ничего возвращать, но всё так же работать в контексте конфига. Здесь я воспользуюсь функцией sequenceS из модуля fp-ts/Apply, чтобы преобразовать структуру вида interface AdHocStruct {
readonly db: App<Database>; readonly express: App<Express>; } к типу App<{ readonly db: Database; readonly express: Express }>. Мы якобы «достаём» из структуры данные, обёрнутые в контекст App, и собираем новый контекст App с похожей структурой, только содержащей уже чистые данные: import { sequenceS } from 'fp-ts/Apply';
const seq = sequenceS(R.Apply); const application: App<void> = pipe( seq({ db: databaseConnection, express: expressServer }), R.map( ({ db, express }) => { console.log([db, express].join('; ')); console.log('app was initialized'); return; }, ), ); Чтобы «запустить» Reader<E, A> на выполнение, ему необходимо передать аргумент того типа, который зафиксирован в типопеременной E, и результатом будет значение типа A: application({
host: 'localhost', port: 8080, connectionString: 'mongo://localhost:271017', }); Наконец, объединяя две вышеописанные концепции, мы приходим к последней для данной статьи абстракции — ReaderTaskEither. ReaderTaskEither<R, E, A> — задача, выполняющаяся в контексте окружения Комбинируя Reader и TaskEither, мы получаем следующую абстракцию: ReaderTaskEither<R, E, A> — это асинхронные вычисления, которые имеют доступ к некоему неизменному окружению типа R, могут вернуть результат типа A или ошибку типа E. Оказалось, что такая конструкция позволяет описывать подавляющее большинство задач, с которыми в принципе приходится сталкиваться программисту при написании функций. Более того, заполняя типопараметры ReaderTaskEither значениями any и never, можно получить такие абстракции: // Task никогда не может упасть и может быть запущен в любом окружении:
type Task<A> = ReaderTaskEither<any, never, A>; // ReaderTask никогда не падает, но требует для работы окружения типа `R`: type ReaderTask<R, A> = ReaderTaskEither<R, never, A>; // TaskError может упасть с обобщенной ошибкой типа Error: type TaskError<A> = ReaderTaskEither<any, Error, A>; // ReaderTaskError может упасть с ошибкой типа Error и требует для работы // окружение типа `R`: type ReaderTaskError<R, A> = ReaderTaskEither<R, Error, A>; // TaskEither, с которым мы познакомились ранее, может быть представлен как // алиас для ReaderTaskEither, который может быть запущен в любом окружении: type TaskEither<E, A> = ReaderTaskEither<any, E, A>; Для ReaderTaskEither в соответствующем модуле fp-ts реализовано большое количество конструкторов, деструкторов и комбинаторов. Однако сам по себе ReaderTaskEither не так интересен, как схожая по семантике с ним ZIO-подобная конструкция, которая несёт дополнительный интересный механизм под капотом, называемый свободными монадами. N.B. Про ReaderTaskEither я достаточно много говорил на камеру в пятом эпизоде видеоподкаста «ФП для чайника». Пример, который я там рассматриваю, можно найти здесь.
На этом данную статью я заканчиваю. Абстракция ReaderTaskEither плавно подвела нас к концепции алгебраических эффектов. Но перед тем, как рассмотреть их на примере ZIO-подобной библиотеки Effect-TS, в следующей статье я хочу поговорить о свободных конструкциях на примере свободных и более свободных монад (Free & Freer monads). Вы можете найти примеры кода из этой статье у меня в Gist на гитхабе. =========== Источник: habr.com =========== Похожие новости:
Программирование ), #_funktsionalnoe_programmirovanie ( Функциональное программирование ), #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 08:47
Часовой пояс: UTC + 5