Русский
Русский
English
Статистика
Реклама

Функциональное программирование на TypeScript задачи (tasks) как альтернатива промисам

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


  1. Полиморфизм родов высших порядков
  2. Паттерн класс типов
  3. Option и Either как замены nullable-типам и исключениям



В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам задачам (tasks). Они позволят нам подойти к понятию алгебраических эффектов, которые я подробно рассмотрю в следующих статьях.


Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.


Promise/A+, который мы потеряли заслужили


В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:


  1. Добавить статический метод конструирования промиса Promise.point:
    Promise.point = function(a) {  // ...};
    
  2. Добавить метод onRejected для обработки состояния неудачи:
    Promise.prototype.onRejected = function(callback) {  // ...};
    
  3. Сделать так, чтобы Promise.prototype.then принимал только один коллбэк, и этот коллбэк обязательно должен возвращать промис:
    Promise.prototype.then = function(onFulfilled) {  // ...};
    
  4. Наконец, сделать промис ленивым, добавив метод 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
К списку статей
Опубликовано: 24.03.2021 20:12:56
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Программирование

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

Typescript

Ts

Fp

Fp-ts

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru