Функциональное программирование на 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 на гитхабе.

    Комментарии 32

      0
      of: <A>(a: A): Task<A> => async () => a

      Кажется, этот код поломается в случае когда a — Thenable (т.е. имеет метод then)


      Кстати, именно проверку на наличие этого метода я считаю основным недостатком промизов, а вовсе не то что перечислено в посте.

        0

        Ваша правда. Но смысл использования thenable в функциональном коде вместе с тасками для меня неясен. Подразумевается, что весь нечистый код (в т.ч. все сторонние промисы/thenables) остается на границе домена приложения.
        Можете привести пример, где бы могло быть полезным в функциональных программах?

          0

          Суть не в полезности, суть во вредности. Метод then может появиться у какого-нибудь объекта случайно.

            +1

            Тут выручает использование простых структур данных — рекордов, кортежей, литералов, примитивов и т.п. Другой вопрос в том, что насаждение этого принципа возможен в JS-мире только средствами ревью кода и прочим просветительством в команде, то есть слабо автоматизируемыми средствами. А вы как бы решали эту проблему?

              +2

              Вариант 1: обернуть значение внутреннего промиза.


              type Task<A> = () => Promise<{ value: A }>;
              
              of: <A>(a: A): Task<A> => () => Promise.resolve({ value: a});

              Вариант 2: сделать свои промизы (можно bluebird форкнуть и почистить), а не переиспользовать системные. Недостаток такого подхода — будет теряться информация об асинхронном стеке, но вроде бы он в предложенном подходе всё равно почти бесполезен.

                +1

                Ах да, вот вспомнил вариант 3. Главное достоинство ленивых задач — в том, что они выполняются всегда ровно с 1 ожидающим подписчиком. Соответственно, делать ленивую задачу через обычную — избыточно, и проще всего её сделать вот так:


                type Task<T> = (callback: (value: T) => void) => void;
                
                const URI = 'SimpleTask';
                type URI = typeof URI;
                
                declare module 'fp-ts/HKT' {
                  interface URItoKind<A> {
                    [URI]: Task<A>;
                  }
                }
                
                const monadSeq: Monad1<URI> = {
                    URI,
                    of: <A>(value: A): Task<A> => cb => cb(value),
                    map: <A, B>(
                        taskA: Task<A>, 
                        transform: (a: A) => B
                    ): Task<B> => cb => taskA(value => cb(transform(value))),
                    chain: <A, B>(
                        taskA: Task<A>, 
                        transform: (a: A) => Task<B>,
                    ): Task<B> => cb => taskA(value => transform(value)(cb)),
                    ap: <A, B>(
                        taskAB: Task<(a: A) => B>, 
                        taskA: Task<A>
                    ): Task<B> => cb => taskA(valueA => taskAB(valueAB => cb(valueAB(valueA)))),
                };

                Параллельная версия ap тут будет несколько сложнее, но ничего принципиально невозможного:


                const monadPar: Monad1<URI> = {
                    …,
                    ap: <A, B>(
                        taskAB: Task<(a: A) => B>, 
                        taskA: Task<A>
                    ): Task<B> => cb => {
                        let valueA : A;
                        let valueAB : (a: A) => B;
                        let state = 0;
                
                        taskA(x => { valueA = x; advance(); })
                        taskAB(x => { valueAB = x; advance(); })
                
                        function advance() {
                            if (++state == 2) {
                                cb(valueAB(valueA));
                            }
                        }
                    },
                };
                • НЛО прилетело и опубликовало эту надпись здесь
                    +1

                    Мне жаль, что мои размышления заставляют вас настолько истекать желчью и переходить на личности. Я нашел для себя работающий подход к программированию, который себя оправдал уже не в одном проекте, поэтому и делюсь им с хабрасообществом. Расскажете о подходе, который применяете вы в своих проектах? С радостью почитаю — может, я и правда глубоко заблуждаюсь в своих убеждениях.

                    • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        У монад же весьма ограниченное полезное применение — убрать под общий контекст множество { функций }. Даже список [фунций] с общим контекстом сложно использовать как locator, а только как жесткий pipe.

                        Это вы вообще о чём? Вроде отдельные слова понятны, но смысл этих предложений ускользает...

            +2

            UPD: обновил статью, убрав оттуда пункты про алгебраические эффекты. Как правильно заметили в чате моего телеграм-канала, алгебраические эффекты — это фича языка. В TS их нет.

              0

              Вместо TaskEither не проще ли было бы объявить трансформер EitherT и применить его к Task, чтобы получить автоматически и функтор TaskEither, и аппликатив, и монаду?

                0

                Многого от трансформеров не выиграть из-за слабости системы типов Typescript.


                Обратите внимание, что для того, чтобы типизация работала, все семейства интерфейсов требуется "регистрировать" в метаинтерфейсе URItoKind.


                Так что трансформер-то написать можно, но каждый из случаев его применения обязан иметь отдельное имя...

                  0

                  Нет, ну с тем, что у тайпскрипта вместо HKT их эмуляция, я не спорю. Но ведь трансформер это не только правильный тип, это еще и конкретная реализация, а с ней уже не должно быть проблем?

                +2

                Promises and compromises. Инженерные задачи, в отличие от чисто математических и общегуманитарных, всегда решаются в ограничениях. Было бы намного больше пользы, если б поклонники FP сели и написали свой правильный монадический JS с тайпклассами и эндофункторами, вместо того чтобы хейтить по мелочам и лепить костыли. С 2013 на то была ведь куча времени. Интересно, почему до сих пор нет?


                Насколько я понимаю все упирается в производительность и память. Для монадических промисов нужно аккуратно трекать весь стек вызовов (в энергичном языке за ленивость приходится платить), либо отказаться от стека совсем в пользу чего-то более легковесного типа call/cc. Обычные же промисы можно спокойно схлопывать — это открыло возможности для оптимизаций https://v8.dev/blog/fast-async

                  +1

                  Что-то по вашей ссылке я не вижу никакого "схлопывания" и "оптимизаций", только пара диаграмм в конце без пояснений.


                  Ну и, вообще говоря, промизы активно всяческим оптимизациям сопротивляются. Инлайнить асинхронные функции, к примеру, стандартом явно запрещено. Переиспользовать объекты в цепочке — тоже...

                    0

                    Монадические промисы будут сопротивляться ещё больше. Простой пока, по понятным причинам, это не является проблемой.


                    Например, сейчас Promise.resolve(Promise.resolve(42)) == Promise.resolve(42) какой-бы вложенность у них не была. Для поддержки видимости монадических законов в малом нужно сохранять всю цепочку. Причем без каких-либо гарантий выполнения этих законов в большем, ведь возможность мутаций в JS ни кто не отменял.


                    Фунциональщики для своих целей стараются использовать подмножество языка, считая остальные возможности поломанными. Это их право. Но JS уже такой как есть — он императивный.

                    +2
                    Было бы намного больше пользы, если б поклонники FP сели и написали свой правильный монадический JS с тайпклассами и эндофункторами, вместо того чтобы хейтить по мелочам и лепить костыли. С 2013 на то была ведь куча времени. Интересно, почему до сих пор нет?

                    PureScript?


                    Да и Haskell, например, спокойно компилируется в JS. Вот только недавно на хабре статья была.

                      0

                      Использование JS как универсаного ассемблера для специализированных языков это нормальная практика. Попробовать другой подход как разминку для ума, потрогать граничные кейсы, чтобы лучше понять дизайн языка, prod/cons — это все здорово. Мне не понятно зачем тащить в продакшн или стандарт такие "улучшения" — с той стороны, для которой этот язык явно не предназначен. Напишите свой язык с синтаксисом максимально близким к JS, если важна именно привычная внешняя атрибутика.

                    0

                    Я хоть и за ФП обеими руками, но вот не понимаю зачем тянуть из Haskella понятия совершенно чуждые для TS?


                    Kоторая позволит сделать промис законопослушным

                    Зачем? Эти законы в TS не существуют, они только у вас в голове. Нет там такого понятия как чистые и не чистые функции. С точки зрения Haskell и фундаментального ФП, в TS, Scala, Elm, F#, etc не существует такого понятия как чистые функции на уровне языка. Следовательно, это все излишества.

                      0

                      В хаскеле можно сделать так:


                      babah :: Int
                      babah = const 42 $! unsafePerformIO $ putStrLn "babah"

                      Значит ли это, что в хаскеле чистые функции также не существуют на уровне языка?


                      Второй момент. Что в в скала-сообществе, что в TS-сообществе распространена практика использования безопасного подмножества языка — т.е. такого подмножества, которое минимизирует негативные побочные эффекты. Для скалы это была инициатива Scalazzi, сейчас Typelevel stack и ZIO stack. Для TS — fp-ts и схожие с ним проекты (тот же Effect-TS). Использование абстракций из них позволяет писать безопасный, производительный, просто рефакторящийся код. Я не могу согласиться, что это излишества.

                        +2
                        В хаскеле можно сделать так

                        Да да, это так называемый сэндвич из Haskell, уберите обертки типа unsafePerformIO и у вас ничего не скомпилируется. Марк Зиман наглядно использует этот пример в своих докладах.


                        Значит ли это, что в хаскеле чистые функции так же не существуют на уровне языка?

                        Я же вам об обратном и говорю


                        Для скалы это была инициатива Scalazzi

                        Категорически с вами не согласен, инициатива Scalaz или Cats зиждиться на восполнение пробелов Scala 2, а именно функ. композиции, union types, и прицепом монад. Честно вам скажу, я на Scala пилю примерно с 2009, контор которые в продакшене используют Scalaz я не встречал. Я бы даже сказал что народ от Scalaz лица воротит что черт от ладана, из-за этого очень сложно продвигать такие няшные и нужные библиотеки как cats. На мой взгляд Scalaz у большинства вызывает негодование нежели желание им пользоваться.


                        тот же Effect-TS

                        Совершенно не вижу смысла в таких библиотеках на TS, на мой взгляд Ramda хватает с лихвой, а все остальное от лукавого.


                        безопасного подмножества языка… безопасный, производительный, просто рефакторящийся код

                        Совершенно не согласен с таким термином, в оригинале pure/unpure functions не имеет ничего общего с безопасностью. ФП это не про это. Вас просто люди не поймут если будете подменять понятия. На мой взгляд.

                          –2
                          т.е. такого подмножества, которое минимизирует негативные побочные эффекты

                          Что в них негативного и относительно чего?

                        0
                        Извините, что пишу сюда, просто «не видал предыдущие шесть». Как считаете, программисту надо понимать во что на нижнем уровне (хотя бы примерно С, не обязательно асм) разворачиваются все эти высокоуровневые асинхронные и функциональные штуки, или это лишнее?
                          0

                          Я считаю, что да, обязательно. Если говорить про ФП, то понимание, как под капотом реализованы те или иные абстракции, даёт уверенность в их использовании и простоту передачи знаний. Поэтому я люблю давать упражнения на написание своих Option/Either/Task и в дальнейшем ZIO-like конструкций своим ученикам из проектов, на которых я работаю. К сожалению, за время работы как на бэке, так и во фронте я видел очень много условных «верстальщиков на реакте», которые не понимают, как работает на базовом уровне язык, и во что превращаются в рантайме те или иные абстракции. Мне думается, что такого стоит избегать, и что умение разобрать досконально в той или иной технологии это то, что отличает инженера-программиста от кодера.

                            +1
                            Пользуясь случаем, спрошу: продолжения (continuation) правда без setjmp/longjmp в Си не делаются, или мне ещё подумать?)
                            0

                            Если вы пишете на Scala или F# то их байт/ИЛ код можно лего декомпилировать в Java/C# и смотреть что там за творчество выдает компилятор. На Scala так вообще инструментарий побогаче будет (GenBCode + --Xprint:cleanup). С Clojure тоже, вроде бы все ОК в этом плане.

                              0
                              JS не в си «разворачивается»
                              Более того, как он «разворачивается» — зависит от движка и от его версии
                                0
                                Вопрос был не про JS (haskell, lisp, prolog), а про необходимость в принципе уметь представить или переписать высокоуровневые абстракции на более низком уровне.
                                  0

                                  Переписать или "как будет выглядеть" ?

                                    0
                                    Хотя бы раз стоит переписать, а то получится как у меня в начале фриланса — в голове и на бумажке всё просто, а руками писать неделю вместо суток :(
                                    Мы в теме TS, так что )
                                    Я тему понимаю больше как «принесём побольше ФП туда, где его изначально особо не планировалось» :)
                                    0
                                    Вопрос был не про JS

                                    Мы в теме TS, так что )

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое