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

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


    1. Полиморфизм родов высших порядков
    2. Паттерн «класс типов»



    В предыдущей статье мы рассмотрели понятие класса типов (type class) и бегло познакомились с классами типов «функтор», «монада», «моноид». В этой статье я обещал подойти к идее алгебраических эффектов, но решил всё-таки написать про работу с nullable-типами и исключительными ситуациями, чтобы дальнейшее изложение было понятнее, когда мы перейдем к работе с задачами (tasks) и эффектами. Поэтому в этой статье, всё еще рассчитанной на начинающих ФП-разработчиков, я хочу поговорить о функциональном подходе к решению некоторых прикладных проблем, с которыми приходится иметь дело каждый день.


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


    Стало уже некоторым моветоном цитировать Тони Хоара с его «ошибкой на миллиард» — введению в язык ALGOL W понятия нулевого указателя. Эта ошибка, как опухоль, расползлась по другим языкам — C, C++, Java, и, наконец, JS. Возможность присвоения переменной любого типа значения null приводит к нежелательным побочным эффектам при попытке доступа по этому указателю — среда исполнения выбрасывает исключение, поэтому код приходится обмазывать логикой обработки таких ситуаций. Думаю, вы все встречали (а то и писали) лапшеобразный код вида:


    function foo(arg1, arg2, arg3) {
      if (!arg1) {
        return null;
      }
    
      if (!arg2) {
        throw new Error("arg2 is required")
      }
    
      if (arg3 && arg3.length === 0) {
        return null;
      }
    
      // наконец-то начинается бизнес-логика, использующая arg1, arg2, arg3
    }

    TypeScript позволяет снять небольшую часть этой проблемы — с флагом strictNullChecks компилятор не позволяет присвоить не-nullable переменной значение null, выбрасывая ошибку TS2322. Но при этом из-за того, что тип never является подтипом всех других типов, компилятор никак не ограничивает программиста от выбрасывания исключения в произвольном участке кода. Получается до смешного нелепая ситуация, когда вы видите в публичном API библиотеки функцию add :: (x: number, y: number) => number, но не можете использовать её с уверенностью из-за того, что её реализация может включать выбрасывание исключения в самом неожиданном месте. Более того, если в той же Java метод класса можно пометить ключевым словом throws, что обяжет вызывающую сторону поместить вызов в try-catch или пометить свой метод аналогичной сигнатурой цепочки исключений, то в TypeScript что-то, кроме (полу)бесполезных JSDoc-аннотаций, придумать для типизации выбрасываемых исключений сложно.


    Также стоит отметить, что зачастую путают понятия ошибки и исключительной ситуации. Мне импонирует разделение, принятое в JVM-мире: Error (ошибка) — это проблема, от которой нет возможности восстановиться (скажем, закончилась память); exception (исключение) — это особый случай поток исполнения программы, который необходимо обработать (скажем, произошло переполнение или выход за границы массива). В JS/TS-мире мы выбрасываем не исключения, а ошибки (throw new Error()), что немного запутывает. В последующем изложении я буду говорить именно об исключениях как о сущностях, генерируемых пользовательским кодом и несущими вполне конкретную семантику — «исключительная ситуация, которую было бы неплохо обработать».

    Функциональные подходы к решению этих двух проблем — «ошибки на миллиард» и исключительных ситуаций — мы сегодня и будем рассматривать.


    Option<A> — замена nullable-типам


    В современном JS и TS для безопасной работы с nullable-типам есть возможность использовать optional chaining и nullish coalescing. Тем не менее, эти синтаксические возможности не покрывают всех потребностей, с которыми приходится сталкиваться программисту. Вот пример кода, который нельзя переписать с помощью optional chaining — только путём монотонной работы с if (a != null) {}, как в Go:


    const getNumber = (): number | null => Math.random() > 0.5 ? 42 : null;
    const add5 = (n: number): number => n + 5;
    const format = (n: number): string => n.toFixed(2);
    
    const app = (): string | null => {
      const n = getNumber();
      const nPlus5 = n != null ? add5(n) : null;
      const formatted = nPlus5 != null ? format(nPlus5) : null;
      return formatted;
    };

    Тип Option<A> можно рассматривать как контейнер, который может находиться в одном из двух возможных состояний: None в случае отсутствия значения, и Some в случае наличия значения типа A:


    type Option<A> = None | Some<A>;
    
    interface None {
      readonly _tag: 'None';
    }
    
    interface Some<A> {
      readonly _tag: 'Some';
      readonly value: A;
    }

    Оказалось, что для такой структуры можно определить экземпляры функтора, монады и некоторых других. Для сокращения кодовых выкладок я покажу реализацию класса типов «монада», а дальше мы проведем параллели между императивным кодом с обработкой ошибок обращения к null, приведенным выше, и кодом в функциональном стиле.


    import { Monad1 } from 'fp-ts/Monad';
    
    const URI = 'Option';
    type URI = typeof URI;
    
    declare module 'fp-ts/HKT' {
      interface URItoKind<A> {
        readonly [URI]: Option<A>;
      }
    }
    
    const none: None = { _tag: 'None' };
    const some = <A>(value: A) => ({ _tag: 'Some', value });
    
    const Monad: Monad1<URI> = {
      URI,
      // Функтор:
      map: <A, B>(optA: Option<A>, f: (a: A) => B): Option<B> => {
        switch (optA._tag) {
          case 'None': return none;
          case 'Some': return some(f(optA.value));
        }
      },
      // Аппликативный функтор:
      of: some,
      ap: <A, B>(optAB: Option<(a: A) => B>, optA: Option<A>): Option<B> => {
        switch (optAB._tag) {
          case 'None': return none;
          case 'Some': {
            switch (optA._tag) {
              case 'None': return none;
              case 'Some': return some(optAB.value(optA.value));
            }
          }
        }
      },
      // Монада:
      chain: <A, B>(optA: Option<A>, f: (a: A) => Option<B>): Option<B> => {
        switch (optA._tag) {
          case 'None': return none;
          case 'Some': return f(optA.value);
        }
      }
    };

    Как я писал в предыдущей статье, монада позволяет организовывать последовательные вычисления. Интерфейс монады один и тот же для разных типов высшего порядка — это наличие функций chain (она же bind или flatMap в других языках) и of (pure или return).


    Если бы в JS/TS был синтаксический сахар для более простой работы с интерфейсом монады, как в Haskell или Scala, то мы единообразно работали бы с nullable-типам, промисами, кодом с исключениями, массивами и много чем еще — вместо того, чтобы раздувать язык большим количеством точечных (и, зачастую, частичных) решений частных случаев (Promise/A+, потом async/await, потом optional chaining). К сожалению, подведение под основу языка какой-либо математической базы не является приоритетным направлением работы комитета TC39, поэтому мы работаем с тем, что есть.

    Контейнер Option доступен в модуле fp-ts/Option, поэтому я просто импортирую его оттуда, и перепишу императивный пример выше в функциональном стиле:


    import { pipe, flow } from 'fp-ts/function';
    import * as O from 'fp-ts/Option';
    
    import Option = O.Option;
    
    const getNumber = (): Option<number> => Math.random() > 0.5 ? O.some(42) : O.none;
    // эти функции модифицировать не нужно!
    const add5 = (n: number): number => n + 5;
    const format = (n: number): string => n.toFixed(2);
    
    const app = (): Option<string> => pipe(
      getNumber(),
      O.map(n => add5(n)), // или просто O.map(add5)
      O.map(format)
    );

    Благодаря тому, что один из законов для функтора подразумевает сохранение композиции функций, мы можем переписать app еще короче:


    const app = (): Option<string> => pipe(
      getNumber(),
      O.map(flow(add5, format)),
    );

    N.B. В этом крохотном примере не нужно смотреть на конкретную бизнес-логику (она умышленно сделана примитивной), а важно подметить одну вещу касательно функциональной парадигмы в целом: мы не просто «использовали функцию по-другому», мы абстрагировали общее поведение для вычислительного контекста контейнера Option (изменение значения в случае его наличия) от бизнес-логики (работа с числами). При этом само вынесенное в функтор/монаду/аппликатив/etc поведение можно переиспользовать в других местах приложения, получив один и тот же предсказуемый порядок вычислений в контексте разной бизнес-логики. Как это сделать — мы рассмотрим в последующих статьях, когда будем говорить про Free-монады и паттерн Tagless Final. С моей точки зрения, это одна из сильнейших сторон функциональной парадигмы — отделение общих абстрактных вещей с последующим переиспользованием их для композиции в более сложные структуры.

    Either<E, A> — вычисления, которые могут идти двумя путями


    Теперь поговорим про исключения. Как я уже писал выше, исключительная ситуация — это нарушение обычного потока исполнения логики программы, на которое как-то необходимо среагировать. При этом выразительных средств в самом языке у нас нет — но мы сможем обойтись структурой данных, несколько схожей с Option, которая называется Either:


    type Either<E, A> = Left<E> | Right<A>;
    
    interface Left<E> {
      readonly _tag: 'Left';
      readonly left: E;
    }
    
    interface Right<A> {
      readonly _tag: 'Right';
      readonly right: A;
    }

    Тип Either<E, A> выражает идею вычислений, которые могут пойти по двум путям: левому, завершающемуся значением типа E, или правому, завершающемуся значением типа A. Исторически сложилось соглашение, в котором левый путь считается носителем данных об ошибке, а правый — об успешном результате. Для Either точно так же можно реализовать множество классов типов — функтор/монаду/альтернативу/бифунктор/etc, и всё это уже есть реализовано в fp-ts/Either. Я же приведу реализацию интерфейса монады для общей справки:


    import { Monad2 } from 'fp-ts/Monad';
    
    const URI = 'Either';
    type URI = typeof URI;
    
    declare module 'fp-ts/HKT' {
      interface URItoKind2<E, A> {
        readonly [URI]: Either<E, A>;
      }
    }
    
    const left = <E, A>(e: E) => ({ _tag: 'Left', left: e });
    const right = <E, A>(a: A) => ({ _tag: 'Right', right: a });
    
    const Monad: Monad2<URI> = {
      URI,
      // Функтор:
      map: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => B): Either<E, B> => {
        switch (eitherEA._tag) {
          case 'Left':  return eitherEA;
          case 'Right': return right(f(eitherEA.right));
        }
      },
      // Аппликативный функтор:
      of: right,
      ap: <E, A, B>(eitherEAB: Either<(a: A) => B>, eitherEA: Either<A>): Either<B> => {
        switch (eitherEAB._tag) {
          case 'Left': return eitherEAB;
          case 'Right': {
            switch (eitherEA._tag) {
              case 'Left':  return eitherEA;
              case 'Right': return right(eitherEAB.right(eitherEA.right));
            }
          }
        }
      },
      // Монада:
      chain: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> => {
        switch (eitherEA._tag) {
          case 'Left':  return eitherEA;
          case 'Right': return f(eitherEA.right);
        }
      }
    };

    Рассмотрим пример императивного кода, который бросает исключения, и перепишем его в функциональном стиле. Классической предметной областью, на которой демонстрируют работу с Either, является валидация. Предположим, мы пишем API регистрации нового аккаунта, принимающий email пользователя и пароль, и проверяющий следующие условия:


    1. Email содержит знак «@»;
    2. Email хотя бы символ до знака «@»;
    3. Email содержит домен после знака «@», состоящий из не менее 1 символа до точки, самой точки и не менее 2 символов после точки;
    4. Пароль имеет длину не менее 1 символа.

    После завершения всех проверок возвращается некий аккаунт, либо же ошибка валидации. Типы данных, составляющий наш домен, описываются очень просто:


    interface Account {
      readonly email: string;
      readonly password: string;
    }
    
    class AtSignMissingError extends Error { }
    class LocalPartMissingError extends Error { }
    class ImproperDomainError extends Error { }
    class EmptyPasswordError extends Error { }
    
    type AppError =
      | AtSignMissingError
      | LocalPartMissingError
      | ImproperDomainError
      | EmptyPasswordError;

    Императивную реализацию можно представить как-нибудь так:


    const validateAtSign = (email: string): string => {
      if (!email.includes('@')) {
        throw new AtSignMissingError('Email must contain "@" sign');
      }
      return email;
    };
    const validateAddress = (email: string): string => {
      if (email.split('@')[0]?.length === 0) {
        throw new LocalPartMissingError('Email local-part must be present');
      }
      return email;
    };
    const validateDomain = (email: string): string => {
      if (!/\w+\.\w{2,}/ui.test(email.split('@')[1])) {
        throw new ImproperDomainError('Email domain must be in form "example.tld"');
      }
      return email;
    };
    const validatePassword = (pwd: string): string => {
      if (pwd.length === 0) {
        throw new EmptyPasswordError('Password must not be empty');
      }
      return pwd;
    };
    
    const handler = (email: string, pwd: string): Account => {
      const validatedEmail = validateDomain(validateAddress(validateAtSign(email)));
      const validatedPwd = validatePassword(pwd);
    
      return {
        email: validatedEmail,
        password: validatedPwd,
      };
    };

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


    import * as E from 'fp-ts/Either';
    import { pipe } from 'fp-ts/function';
    import * as A from 'fp-ts/NonEmptyArray';
    
    import Either = E.Either;

    Переписать императивный код, выбрасывающий исключения, на код с Either'ами достаточно просто — в месте, где был оператор throw, пишется возврат левого (Left) значения:


    // Было:
    const validateAtSign = (email: string): string => {
      if (!email.includes('@')) {
        throw new AtSignMissingError('Email must contain "@" sign');
      }
      return email;
    };
    
    // Стало:
    const validateAtSign = (email: string): Either<AtSignMissingError, string> => {
      if (!email.includes('@')) {
        return E.left(new AtSignMissingError('Email must contain "@" sign'));
      }
      return E.right(email);
    };
    
    // После упрощения через тернарный оператор и инверсии условия:
    const validateAtSign = (email: string): Either<AtSignMissingError, string> =>
      email.includes('@') ?
        E.right(email) :
        E.left(new AtSignMissingError('Email must contain "@" sign'));

    Аналогичным образом переписываются другие функции:


    const validateAddress = (email: string): Either<LocalPartMissingError, string> =>
      email.split('@')[0]?.length > 0 ?
        E.right(email) :
        E.left(new LocalPartMissingError('Email local-part must be present'));
    
    const validateDomain = (email: string): Either<ImproperDomainError, string> =>
      /\w+\.\w{2,}/ui.test(email.split('@')[1]) ?
        E.right(email) :
        E.left(new ImproperDomainError('Email domain must be in form "example.tld"'));
    
    const validatePassword = (pwd: string): Either<EmptyPasswordError, string> =>
      pwd.length > 0 ? 
        E.right(pwd) : 
        E.left(new EmptyPasswordError('Password must not be empty'));

    Остается теперь собрать всё воедино в функции handler. Для этого я воспользуюсь функцией chainW — это функция chain из интерфейса монады, которая умеет делать расширение типов (type widening). Вообще, есть смысл рассказать немного о конвенции именования функций, принятой в fp-ts:


    • Суффикс W означает type Widening — расширение типов. Благодаря этому можно в одну цепочку поместить функции, возвращающие разные типы в левых частях Either/TaskEither/ReaderTaskEither и прочих структурах, основанных на типах-суммах:


      // Предположим, есть некие типы A, B, C, D, типы ошибок E1, E2, E3, 
      // и функции foo, bar, baz, работающие с ними:
      declare const foo: (a: A) => Either<E1, B>
      declare const bar: (b: B) => Either<E2, C>
      declare const baz: (c: C) => Either<E3, D>
      declare const a: A;
      // Не скомпилируется, потому что chain ожидает мономорфный по типу левой части Either:
      const willFail = pipe(
        foo(a),
        E.chain(bar),
        E.chain(baz)
      );
      
      // Скомпилируется корректно:
      const willSucceed = pipe(
        foo(a),
        E.chainW(bar),
        E.chainW(baz)
      );

    • Суффикс T может означать две вещи — либо Tuple (например, как в функции sequenceT), либо монадные трансформеры (как в модулях EitherT, OptionT и тому подобное).
    • Суффикс S означает structure — например, как в функциях traverseS и sequenceS, которые принимают на вход объект вида «ключ — функция преобразования».
    • Суффикс L раньше означал lazy, но в последних релизах от него отказались в пользу ленивости по умолчанию.

    Эти суффиксы могут объединяться — например, как в функции apSW: это функция ap из класса типов Apply, которая умеет делать type widening и принимает на вход структуру, по ключам которой итерирует.


    Возвращаемся к написанию handler. Я использую chainW, чтобы собрать тип возможных ошибок как тип-сумму AppError:


    const handler = (email: string, pwd: string): Either<AppError, Account> => pipe(
      validateAtSign(email),
      E.chainW(validateAddress),
      E.chainW(validateDomain),
      E.chainW(validEmail => pipe(
        validatePassword(pwd),
        E.map(validPwd => ({ email: validEmail, password: validPwd })),
      )),
    );

    Что же мы получили в результате такого переписывания? Во-первых, функция handler явно сообщает о своих побочных эффектах — она может не только вернуть объект типа Account, но и вернуть ошибки типов AtSignMissingError, LocalPartMissingError, ImproperDomainError, EmptyPasswordError. Во-вторых, функция handler стала чистой — контейнер Either это просто значение, не содержащее дополнительной логики, поэтому с ним можно работать без боязни, что произойдет что-то нехорошее в месте вызова.


    NB: Разумеется, эта оговорка — просто соглашение. TypeScript как язык и JavaScript как рантайм никак нас не ограничивают от того, чтобы написать код в духе:
    const bad = (cond: boolean): Either<never, string> => {
      if (!cond) {
        throw new Error('COND MUST BE TRUE!!!');
      }
      return E.right('Yay, it is true!');
    };


    Понятное дело, что в приличном обществе за такой код бьют канделябром по лицу на код ревью, а после просят переписать с использованием безопасных методов и комбинаторов. Скажем, если вы работаете со сторонними синхронными функциями, их стоит оборачивать в Either/IOEither с помощью комбинатора tryCatch, если с промисами — через TaskEither.tryCatch и так далее.

    У императивного и функционального примеров есть один общий недостаток — они оба сообщают только о первой встреченной ошибке. То самое отделение поведения структуры данных от бизнес-логики, о котором я писал в секции про Option, позволит нам написать вариант программы, собирающей все ошибки, с минимальными усилиями. Для этого понадобится познакомиться с некоторыми новыми концепциями.


    Есть у Either брат-близнец — тип Validation. Это точно такой же тип-сумма, у которого правая часть означает успех, а левая — ошибку валидации. Нюанс заключается в том, что Validation требует, чтобы для левой части типа E была определена операция concat :: (a: E, b: E) => E из класса типов Semigroup. Это позволяет использовать Validation вместо Either в задачах, где необходимо собирать все возможные ошибки. Например, мы можем переписать предыдущий пример (функцию handler) так, чтобы собрать все возможные ошибки валидации входных данных, не переписывая при этом остальные функции валидации (validateAtSign, validateAddress, validateDomain, validatePassword).


    Расскажу пару слов об алгебраических структурах, умеющих объединять два элемента

    Они выстраиваюся в следующую иерархию:


    • Magma (Магма), или группоид — базовый класс типов, определяющий операцию concat :: (a: A, b: A) => A. На эту операцию не налагается никаких других ограничений.
    • Если к магме добавить ограничение ассоциативности для операции concat, получим полугруппу (Semigroup). На практике оказывается, что полугруппы более полезны, так как чаще всего работа ведется со структурами, в которых порядок элементов имеет значимость — вроде массивов или деревьев.
    • Если к полугруппе добавить единицу (unit) — значение, которое можно сконструировать в любой момент просто так, — получим моноид (Monoid).
    • Наконец, если к моноиду добавим операцию inverse :: (a: A) => A, которая позволяет получить для произвольного значения его инверсию, получим группу (Group).

    Groupoid hierarchy
    Детальнее об иерархии алгебраических структур можно почитать в вики.


    Иерархию классов типов, соответствующих таким алгебраическим структурам, можно продолжать и дальше: в библиотеке fp-ts определены классы типов Semiring, Ring, HeytingAlgebra, BooleanAlgebra, разного рода решётки (lattices) и т.п.


    Нам для решения задачи получения списка всех ошибок валидации понадобится две вещи: тип NonEmptyArray (непустой массив) и полугруппа, которую можно определить для этого типа. Вначале напишем вспомогательную функцию lift, которая будет переводить функцию вида A => Either<E, B> в функцию A => Either<NonEmptyArray<E>, B>:


    const lift = <Err, Res>(check: (a: Res) => Either<Err, Res>) => (a: Res): Either<NonEmptyArray<Err>, Res> => pipe(
      check(a),
      E.mapLeft(e => [e]),
    );

    Для того, чтобы собрать все ошибки в большой кортеж, я возпользуюсь функцией sequenceT из модуля fp-ts/Apply:


    import { sequenceT } from 'fp-ts/Apply';
    import NonEmptyArray = A.NonEmptyArray;
    
    const NonEmptyArraySemigroup = A.getSemigroup<AppError>();
    const ValidationApplicative = E.getApplicativeValidation(NonEmptyArraySemigroup);
    
    const collectAllErrors = sequenceT(ValidationApplicative);
    
    const handlerAllErrors = (email: string, password: string): Either<NonEmptyArray<AppError>, Account> => pipe(
      collectAllErrors(
        lift(validateAtSign)(email),
        lift(validateAddress)(email),
        lift(validateDomain)(email),
        lift(validatePassword)(password),
      ),
      E.map(() => ({ email, password })),
    );

    Если запустим эти функции с одним и тем же некорректным примером, содержащим более одной ошибки, то получим разное поведение:


    > handler('user@host.tld', '123')
    { _tag: 'Right', right: { email: 'user@host.tld', password: '123' } }
    
    > handler('user_host', '')
    { _tag: 'Left', left: AtSignMissingError: Email must contain "@" sign }
    
    > handlerAllErrors('user_host', '')
    {
      _tag: 'Left',
      left: [
        AtSignMissingError: Email must contain "@" sign,
        ImproperDomainError: Email domain must be in form "example.tld",
        EmptyPasswordError: Password must not be empty
      ]
    }

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



    На этом текущую статью я заканчиваю, а в следующей будем говорить уже про Task, TaskEither и ReaderTaskEither. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.

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

      –1

      Интересная статья. Не думал, что на typescript есть функциональность.

        0
        Вообще-то практически все описанное вполне можно сделать давно сделано на простом JS.
          +2

          А еще более вообще-то всё описанное реализовано на хаскеле задолго до того, как Брендан Эйх написал JS ;)

            +1
            Ну, я немного более простую вещь имел в виду — что и Option, и Either на голом JS уже писали, причем неоднократно. То есть, это не будет конечно идентично, но суть ровно такая же.
            0

            Вообще-то практически все описанное вполне можно сделать давно сделано на простом ASM.

            +3

            функционального программирования в ТС столько же сколько и в JS, только тут еще можно делать проверку на типобезопасноть

            +1

            А можете объяснить, почему для обработки ошибок используют именно Either<E, A> = Left<E> | Right<A>, в котором нужны соглашения о том, какая ветка относится к ошибке (и при этом еще и выбрана левая, что на мой взгля еще ухудшает читаемость, но это уже все-таки субъективно), а не какой-нибудь Result<A, E> = Ok<A> | Err<E>, в котором четко видно какая ветвь за что отвечает?

              +1

              Спасибо за вопрос!


              Во-первых, это более абстрактно — а, значит, более общó. Either выражает не просто пару «ошибка/успех», а вообще любые вычисления, которые могут пойти по одному из двух путей. Проще говоря, Either более абстрактная вещь, чем Result из вашего примера — поэтому годится для выражения большего количества алгоритмов. В Scala Result вообще называется Try и содержит ветки Success и Failure, в Rust — Result с ветками Ok и Err и т.п. Я лично за унификацию, поэтому рад, что в fp-ts не выдуман свой велосипед для наименования Either'а.


              Во-вторых, так сложилось исторически. На самом деле, в fp-ts большинство концепций перенесено либо из Haskell, либо из Scala, так что имена контейнеров и классов типов взяты оттуда. Поэтому Either это Either как в хаскеле, а Option это Option как в скале (а не Maybe, как в хаскеле). Короче, всё немного запутанно, если начать раскапывать исторические слои :)


              Возможно, понять причины такого наименования Either'а поможет аргументация из вот этого обсуждения в Haskell Cafe от 2010 года.

                0
                Ну, вообще в скале есть и Try, который по сути Result. Ну т.е. такой способ тоже распространен, хотя и в других местах.
                  +2
                  Either более абстрактная вещь, чем Result из вашего примера

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


                  В Scala Result вообще называется Try и содержит ветки Success и Failure

                  Не совсем — в Scala в Either ограничений на типы вроде нет, а вот в Try тип, завернутый в Failure внезапно должен быть Throwable, и почему-то все гайды, которые я видел рекомендуют использовать Try для заворачивания Java-кода, который может бросать исключения, а для нативного Scala кода использовать именно Either — и это мне до сих пор немного рвет шаблон после Rustа. А тут еще ваша статья про ровно тот же Either в TS :)

                    +3
                    И кстати, можете привести хотя бы один не синтетический пример использования Either не для обработки ошибок?

                    Да вот хотя бы этот: https://doc.rust-lang.org/std/vec/struct.Vec.html#method.binary_search


                    Result тут не смотрится, вариант "не нашли" никакая не ошибка, а полноценный вариан (наравне c entry API, никто же не считает Vacant вариант за ошибку?).
                    Также можно посмотреть на futures::Either, например. или itertools::Either.


                    Тысячи их

                      +3

                      Спасибо, пример с поиском действительно хороший. Правда с другой стороны — хотя в данном случае result с его ok и err немного и выглядит как натягивание совы на глобус, но всё ещё интуитивно читается проще, чем left и right. Но возможно это уже вопрос привычки.

                        +1

                        Если совсем абсолютно, то Either как структура является типом-суммой. По поведению Either реализует стратегию fail-early. Обработка ошибок просто популярный юзкейс. Но можно найти применение в парсинге, поиске и т.п.


                        Я в принципе тоже за то, чтобы использовать конкретные имена, там где это возможно. В хаскеле все уже исторически все привыкли к такому сленгу. Но тащить это как кальку в другие языки, по-моему не стоит, и правы те кто для результатов выбрал Result.


                        Есть близкая по структуре штука: data Validation e a — которая тоже тип-сумма из двух значений, но имеет другую семантику (и вообще аппликатив, а не монада). Вместо fail-early она собирает слева все сообщения об ошибках, а не только первое.

                        0
                        Result тут не смотрится, вариант "не нашли" никакая не ошибка, а полноценный вариан (наравне c entry API, никто же не считает Vacant вариант за ошибку?).

                        Вот по аналогии с entry API тут неплохо смотрелось бы отдельное именованное перечисление. Жаль только создатели стандартной библиотеки вовремя не додумались так и сделать.

                    0

                    Потому что Ether используется не только для ошибок, а в принципе для двух любых вариантов. Ну так же, как вы тапл используете для возврата любых двух значений, хотя возможно класс с именованными полями был бы понятнее, чем просто структурка с Item1/Item2.


                    А в контексте ошибок запоминать очень просто: Right означает ещё и "правильный", соответственно он несет в себе результат, ну а другой вариант соответственно ошибку. Это если что на полном серьезе обоснование, почему варианты так называются и почему они без переименования используются именно как Left/Right для ошибок. Pun intended так сказать


                    К слову, в том же расте Either не так часто используется, поэтому там в СТД есть как раз Result = Ok | Err.

                      0
                      Потому что Ether используется не только для ошибок, а в принципе для двух любых вариантов.

                      Но если варианты больше не делятся на результат и ошибку — Ether перестаёт быть монадой (точнее, теряет очевидную семантику монады).

                        0

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

                        +1
                        Потому что Ether используется не только для ошибок, а в принципе для двух любых вариантов. Ну так же, как вы тапл используете для возврата любых двух значений

                        Применение тапла действительно удобно во многих случаях, особенно если в языке есть деструктуризация, и тапл не обязательно из двух значений. А вот для Either популярного применения кроме обработки ошибок я даже придумать сходу не могу. Поможете?


                        К слову, в том же расте Either не так часто используется, поэтому там в СТД есть как раз Result = Ok | Err.

                        Да-да, именно в расте я с такой обработкой ошибок и познакомился, а теперь языки с Either немного удивляют и расстраивают меня.

                          +1

                          Ответил выше

                      0

                      Чувство прекрасного и желание привнести элементы из математики это интересно, но не уверен, что всегда практично. Несколько раз пробовал Either в typescript, но потом обнаруживал, что код с обычными try/catch выглядит чище. Думаю, что try/catch можно воспринимать как Either, зашитый в язык, как вы считаете?
                      Microsoft много вкладывается в разработку хаскеля и других языков. Может есть причины, по которым пути развития у них не пересекаются?

                        +1
                        Может есть причины, по которым пути развития у них не пересекаются?

                        Да, и эти причины, на мой взгляд — отсутствие в комитетах стандартизации JS/TS людей, мало-мальски разбирающихся в теоркате и современном CS. Достаточно добавить в язык синтаксический сахар, позволяющий делать monadic comprehension, и использование любых контейнерных/контекстных конструкций вроде Option, Either, Task, Reader и их производных станет делом обыденным и таким же чистым, как с try/catch. Трагедия с Promise/A+ и узколобость Доменика Дениколы, например, яркий тому пример — если бы промисы соответствовали монадическому интерфейсу, то таким comprehension для JS стал бы сахар async/await, и не пришлось бы городить всякие костыли вроде имитации хаскельной do-нотации через генераторы.

                          0
                          если бы промисы соответствовали монадическому интерфейсу, то таким comprehension для JS стал бы сахар async/await

                          Два раза нет. Во-первых, нет, не стал бы: этот сахар с самого начала задумывался как императивный, и превратить его в do-нотацию не так-то просто. Просто вспомните что


                          1. среди монад есть List;
                          2. в языке есть изменяемые переменные;
                          3. цель sync/await — сделать асинхронный код похожим на обычный синхронный, а вовсе не сделать нечто принципиально иное.

                          А теперь сложите всё это и объясните как должен работать следующий код в монаде List:


                          async function foo(n) {
                              let sum = 0;
                              for (let i=0; i<n; i++)
                                  sum += await bar(i);
                          }

                          Это было во-первых. А во вторых — несоответствие промисов монадическому интерфейсу никак не мешает ввести в язык do-нотацию!

                            0

                            Очевидно чтобы иметь один синтаксис для разных монад (Асинк и Лист в вашем примере) нужно иметь возможность объявлять трансформеры. А это думаю для жаваскрипта может оказаться непосильной ношей, не только в плане того как этому обучать, но и тупо как это реализовать эффективно.

                              0

                              Э-э-э, а трансформеры-то тут зачем?


                              Да, это запросто может оказаться непосильной ношей, но никак не Доменик Деникола тому виной :-)

                                0

                                Ну затем что у нас должно быть тут StateT Intger (AsyncT MyError [] Integer), иначе как мы авейтить будем и лист и асинк?


                                А дальше просто:


                                async function foo(n) {
                                    const i = await Array(n).keys()
                                    const value = await bar(i);
                                    await put(await get() + value)
                                }
                                  0

                                  А асинк зачем? Я предлагаю придумать, как код выше может работать в чистой монаде List.

                                    0

                                    ну так вы же написали async function, значит асинк. Ваш бар же асинхронный, нет?

                                      0

                                      async function я написал потому что у async/await именно такой синтаксис.
                                      bar не асинхронный, bar возвращает List.


                                      Менять код, как вы сделали, нельзя, потому что смысл async/await — в том, что он не заставляет менять код, позволяя писать код привычный обычному программисту.

                                        –1
                                        А теперь сложите всё это и объясните как должен работать следующий код в монаде List:

                                        Ну ваш код просуммирует все результаты всех баров от 0 до n.

                              –1
                              А теперь сложите всё это и объясните как должен работать следующий код в монаде List:

                              Это очень просто, достаточно перевести в стандартную cont-семантику. await захватывает текущее продолжение до установленных async'ом границ и применяет его к своему аргументу через монадический bind. С-но, await/async — это наиболее вменяемый пример do-нотации как раз. Единственная проблема — что нельзя применять его внутри лямбды, например: async f() { return [1,2,3,4].map((x) => await g(x)) } — не работает, хотя должно.


                              Сколько раз файл должен оказаться закрыт в монадах List или Option?

                              Сперва открывается файл, потом происходит захват продолжения. И т.к. в момент захвата файл уже открыт, дважды он открываться не будет.


                              PsyHaSTe


                              Ну ваш код просуммирует все результаты всех баров от 0 до n.

                              Тут, кстати, прикольная штука. Дело в том, что sum = sum + (await bar(i)); и sum = (await bar(i)) + sum; — разные вещи, и чйорт его знает чему на самом деле соответствует sum += (await bar(i)). По логике — второй вариант, т.е. будет суммирование. Но мало ли)

                                –1
                                Сперва открывается файл, потом происходит захват продолжения. И т.к. в момент захвата файл уже открыт, дважды он открываться не будет.

                                Я спрашивал не сколько раз он будет открыт (это очевидно), а сколько раз он будет закрыт.

                                  0
                                  Я спрашивал не сколько раз он будет открыт (это очевидно), а сколько раз он будет закрыт.

                                  Очевидно, закрытый файл закрыть нельзя :)


                                  Но вообще, с полноценным синтаксисом — зависит от кода. Если вы напишите:


                                  function foo() {
                                  async {
                                      let file = openFile(…);
                                      try {
                                          await bar();
                                      } finally {
                                          file.close();
                                      }
                                  }
                                  }

                                  то вызов file.close должен сработать для каждого элемента списка. А вот если:


                                  function foo() {
                                      let file = openFile(…);
                                      try {
                                        async {  await bar(); }
                                      } finally {
                                          file.close();
                                      }
                                  }

                                  то первое же исключение прервет все дальнейшее выполнение.


                                  Ну и еще есть:
                                  https://docs.racket-lang.org/reference/cont.html?q=dynamic-wind#%28def._%28%28quote._~23~25kernel%29._dynamic-wind%29%29


                                  если мы хотим запускать пре/пост ф-и при каждом входе/выходе в контекст (т.е. в случае async { dynamic-wind(openFile, () => await [1,2,3], closeFile) }) — файл будет открыт и закрыт трижды.

                                    0
                                    то вызов file.close должен сработать для каждого элемента списка

                                    Ну так это же и плохо, потому что конфликтует с семантикой finally.

                                      0
                                      Ну так это же и плохо, потому что конфликтует с семантикой finally.

                                      Почему? finally должно быть обязательно вызвано при выходе из try-блока. Именно это и происходит — многократный вход выход из try-блока (при каждом применении фунарга fmap), и многократное finally.
                                      Вас же не смущает, что в:


                                      function foo() {
                                        let file = openFile(…);
                                        [1, 2, 3].forEach(() => {
                                          try {
                                            bar();
                                          } finally {
                                            file.close();
                                          }
                                        });
                                      }

                                      finally трижды вызывается? А это, по сути, тот же самый код.

                                  0

                                  Вот кстати про Cont-семантику:


                                  const do = () => {
                                    const x = foo();
                                    console.log("Wassup?!", x);
                                    const y = bar();
                                    return y
                                  }

                                  При сильном желании ведь и здесь можно разглядеть do нотацию, встроенную непосредственно в синтаксис языка. Следите за руками: оператор присваивания = захватывает продолжение до установленных точкой-с-запятой; границ справа (и далее по тексту).


                                  async/await это же чисто механическое расширение языка для работы с промисами, без изменения семантики императивного программирования, которая в языке присутствует с самого рождения. Почему все мечты о монадах связаны именно с расширением этой конструкции, а не, допустим с более универсальной — точки с запятой?

                                    0
                                    Почему все мечты о монадах связаны именно с расширением этой конструкции, а не, допустим с более универсальной — точки с запятой?

                                    Я просто оставлю это здесь: http://book.realworldhaskell.org/read/monads.html#id642960

                                      0

                                      Именно. Просто заметьте, что хаскеле do нотация это синтаксический сахар конкретно для композиции монадических вычислений, что накладывает свои ограничения, которые для JS разработчика будут довольно неожиданными. Например вы не сможете заколать внутри do функции, которые возвращают результаты разного типа (a -> List a; a -> Maybe a — ведь монады в общем случае не композятся). JS всё-таки императивный язык с динамической типизацией (где все уже привыкли к композиции по понятиям, а не по законам) и такие ограничения будут выглядеть по меньшей мере странно.


                                      То, что вы хотите, скорее похоже на сахар не для монад, а более низкоуровневой штуки без ограничения на типы данных — continuations (goto, ну или call/cc для эстетов). Тогда и управление потоком вычислений кода между точками с запятой — с помощью стека вызовов функций (который так же неявно используется в JS) — можно представить лишь как частный случай. Идеальный язык? Не уверен.


                                      Если посмотреть в реализацию движка V8, то можно увидеть, что под капотом async/await и */yield используются структуры сильно напоминающие continuations. Это значит, что добавить какую угодно нотацию с семантикой "вот мы сейчас тут прервемся и пойдем посчитаем вон там" — это работы на полдня. Вот только этот механизм не экспортируется на уровень языка. Я думаю это сделано специально, чтобы не нарушать консистентность. В таких штуках нет ни чего плохого, но это будет уже другой язык, с другими своими компромиссами и представлениями о юзабилити (как в классической дилемме: оптимизация хвостовых рекурсий или честный кол стек в дебагере и стектрейсах — выберите одно из двух).

                                        –1
                                        Вот только этот механизм не экспортируется на уровень языка.

                                        Прекрасно экспортируется.

                                          0

                                          Можете привести пример?

                                  –1

                                  Реализовал Ваш пример (правда на F#), который собственно показывает, что для разных монадических контейнеров реализация может совпадать.


                                  F#
                                  #r "nuget: FSharpPlus"
                                  
                                  open FSharpPlus
                                  open FSharpPlus.Data
                                  
                                  let bar(v : int) : list<int> = monad' {
                                      return v * 2
                                  }
                                  let foo(n : int) : list<int> = monad' {
                                      let! values = 
                                          [0 .. (n - 1)] 
                                          |> Seq.map bar
                                          |> sequence
                                  
                                      return Seq.reduce (+) values
                                  }
                                  
                                  let barAsync(v : int) : Async<int> = monad' {
                                      return v * 2
                                  }
                                  let fooAsync(n : int) : Async<int> = monad' {
                                      let! values = 
                                          [0 .. (n - 1)] 
                                          |> Seq.map barAsync
                                          |> sequence
                                      return Seq.reduce (+) values
                                  }
                                  
                                  printfn "List %A" (foo 5)
                                  printfn "Async %A" (fooAsync 5 |> Async.RunSynchronously)

                                  По поведению асинхронная реализация совпадает с аналогичной императивной. Другое дело, что замена Task на List сделала код по сути бессмысленным

                                    0

                                    Это вы местный аналог do-нотации использовали, а не await. И суммировали через reduce, а не через цикл.


                                    Другое дело, что замена Task на List сделала код по сути бессмысленным

                                    Ну так верните из bar несколько значений, а не одно.

                                      0

                                      Думаю, что на JS вообще это сделать проблематично, ввиду существующих ограничений на await (справа от него должен быть либо промиз, либо объект с методом then). Но вот в C#, где требования к awaitable более гибкие, это отлично реализуется (например как тут и тут)


                                      И суммировали через reduce, а не через цикл.

                                      Ну мешать императивный и функциональный подход имхо моветон, особенно когда вопрос касается именно функциональных паттернов

                                        +1
                                        Думаю, что на JS вообще это сделать проблематично, ввиду существующих ограничений на await (справа от него должен быть либо промиз, либо объект с методом then).

                                        Вы вообще ветку читали?


                                        Но вот в C#, где требования к awaitable более гибкие, это отлично реализуется (например как тут и тут)

                                        Вот как раз монада List на протоколе Awaitable никак не реализуется, даже в C#.


                                        Ну мешать императивный и функциональный подход имхо моветон, особенно когда вопрос касается именно функциональных паттернов

                                        Ну так именно это я и пытаюсь объяснить (в том числе).

                                          +1

                                          Да, вы правы. Все же do-нотация это про вложенные bind'ы, а в JS и CS await можно понамешать со всякими циклами и try-catch'ами.


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


                                          JS
                                          Object.defineProperty(Array.prototype, 'then', {
                                              value: function(f) { 
                                                  return this.flatMap(f); 
                                              }
                                          });
                                          
                                          async function foo() {
                                              const i = await [1,2,3];
                                              const j = await [10, 20];
                                              console.log(i, j); // prints [1, 10]
                                              return i + j;
                                          }
                                          
                                          console.log(foo()) // prints Promise, expected [11, 21, 12, 22, 13, 32]
                                            0

                                            Не лучше. Почему никто не рассматривает вариант "добавить do-нотацию отдельно, а await оставить для императивного кода?"

                                              0

                                              Сообщество ФП-программистов на JS ничтожно мало, чтобы получилось продавить нужные им фичи. Не получилось с ленивыми промисами, не получилось с кастомными await'ами. И не получится с do-нотацией. Обычно таким сразу предлагают перейти на какой-нибудь Elm или Fable и не усложнять JS. Поэтому приходится работать с тем, что есть.

                                                0
                                                Не лучше. Почему никто не рассматривает вариант "добавить do-нотацию отдельно, а await оставить для императивного кода?"

                                                await это же и есть do-нотация. Т.е. будет await/async и какие-нибудь monad/do? Но зачем, если они работают абсолютно одинаково и с точки зрения синтаксиса и с точки зрения семантики, а отличие — только в используемых кейвордах?


                                                funca


                                                Почему все мечты о монадах связаны именно с расширением этой конструкции, а не, допустим с более универсальной — точки с запятой?

                                                Ответ прост — explicit is better than implicit. Если вы будете автоматом расставлять бинды вместо ;, который можно написать в подобной системе, резко уменьшится.

                                                  +2
                                                  если они работают абсолютно одинаково

                                                  Но они не работают абсолютно одинаково. Техника async/await рассчитана на максимально полную интеграцию в существующую императивную модель исполнения, в то время как от monad/do — напротив, ожидается ограничение побочных эффектов и прочие прелести ФП.


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


                                                  Т.е. будет await/async и какие-нибудь monad/do?

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

                                                    0
                                                    Но они не работают абсолютно одинаково.

                                                    Как же не работают, если у них по определению семантика совпадает?


                                                    в то время как от monad/do — напротив, ожидается ограничение побочных эффектов и прочие прелести ФП.

                                                    Да нет, ничего не ожидается. Вы свой монадический "await" можете вызвать в любой контексте, и все будет работать прекрасно четко определенным и предсказуемым образом со строгой семантикой.


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

                                                    Во-первых, динамика тут не при делах. Во-вторых — делаем whole-program cps-преобразование и все прекрасно работает с нулевым оверхедом.


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

                                                    Еще раз — зачем, если отличие между ними только и исключительно в названиях кейвордов? Или вы можете какое-то другое отличие назвать? Ну тогда назовите. А то вы говорите, что это якобы "разные вещи", противореча общим имеющимся представлениям на этот счет. Но не уточняете, в чем именно разные. Вот я написал код с await/async (который ваш злостно-императивный) и такой же код но с monad/do, каково, по-вашему, отличие между семантиками этих двух кусков кода?


                                                    funca


                                                    Можете привести пример?

                                                    Любой нормальный scheme-derived диалект лиспа.

                                                      0

                                                      Тезис был о том, почему движок v8 для JavaScript не экспортирует call/cc в чистом виде. В других местах может быть все по-другому.

                                                        –1
                                                        Тезис был о том, почему движок v8 для JavaScript не экспортирует call/cc в чистом виде.

                                                        А, ну v8 не поддерживает потому, что никто поддержки продолжений, собственно, в нем не запилил. Если же поддержка запилена — то и нет проблем.


                                                        Непонятно только, пример чего вы тогда спрашивали.

                                        0

                                        Кстати, забавный факт: ваша интерпретация моего псевдокода явно отличается от моей. Я вот считаю, что если в этом коде есть хоть какой-то смысл — то только такой (пишу на Хаскеле, потому что в F# нет traverse):


                                        foo n = fmap sum $ traverse bar [0..(n-1)] 
                                          0

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


                                          let foo (n : int) : list<int> = 
                                              let tmp = traverse bar [0 .. (n - 1)]
                                              map sum tmp

                                          По умолчанию F# не поддерживает тайпклассы, так что я использую библиотеку f#+ — там есть их реализация. Поддержка тайпклассов сделана на inline-функциях и constraint'ах на наличие у аргументов определенных статических методов. Из-за этого у меня не получилось написать это в одну строчку, компилятор просто не справляется с таким:


                                          map sum <| traverse bar [0 .. (n - 1)]
                                      0

                                      Мне кажется теоркат это давно не секрет Полишинеля. При обсуждении optional chaining, разновидности Maybe упоминались с первых строк. Но в итоге аргументы против, перевесили за. В конце-концов сайдэффекты в JS были отродясь и ни куда не денутся, а для математики нужны сильные гарантии чистоты. Async/await хоть и похоже на do нотацию, но семантика совершенно другая.


                                      Вообще было бы интересно посмотреть на язык, в котором категории являются first citizens (не как в хаскеле).

                                        0
                                        Async/await хоть и похоже на do нотацию, но семантика совершенно другая.

                                        Абсолютно та же семантика там.

                                          –1

                                          Ну вот и объясните как эта самая "абсолютно та же семантика" выглядит в коде из моего комментария применительно к монаде List.


                                          Вот ещё псевдокод, чтобы легче думалось:


                                          async function foo() {
                                              let file = openFile(…);
                                              try {
                                                  await bar();
                                              } finally {
                                                  file.close();
                                              }
                                          }

                                          Сколько раз файл должен оказаться закрыт в монадах List или Option? Если ровно один — то как это вообще реализовать? Если какое-то другое число раз — то не кажется ли вам, что код полностью сломан?

                                            –1

                                            Для таких вещей используется паттерн bracket где мы явно указываем скоупы и когда файл должен быть закрытю. Собственно так же, как и в любом другом ГЦ языке: try-with-resources в джаве, using в шарпе и так далее.


                                            Если же ни во что не оборачивать, то всё зависит как раз от того самого трансформера и того, в каком порякде мы монаду заворачиваем. Option (FileMonad ..) не то же самое, что FileMonad (Option ...)

                                              +1

                                              Вот только смысл async-await именно в том, чтобы не вводить никаких новых "паттернов", а использовать имеющиеся управляющие конструкции языка.

                                                0

                                                Так вы этот же паттерн и написали, просто трай кетчем. От того будет это функцией или встроенными кейвордами — ничего не меняется.


                                                Семантика do и await одна и та же. Прямой пример

                                                  0

                                                  Почему не поменяется? Как раз поменяется: встроенные ключевые слова изменятся на функцию.

                                                    0

                                                    Это другой вопрос совсем — вопрос ресурсов. Можно сделать чтобы ключевые слова могли работать в любой монаде, это собственно не сложно.


                                                    Но я по-моему 10 раз сказал (и выше, и ниже), что жс — не тот язык, где стоит подобные нововведения делать.

                                                      0

                                                      Ну вот и расскажите как это будет работать. Я пока что этого не представляю.


                                                      И да, операции bind для этого явно недостаточно.

                                            –1

                                            Давайте посмотрим:


                                            const foo = async () => ({
                                              bar: await bar(),
                                              baz: console.log(await baz()),
                                              rnd: Math.random()
                                            })

                                            Вполне валидная с точки зрения языка и здравого смысла конструкция. Прикиньте, если бы это было правда do нотацией, а код, связанный с async/await, был вынужден строго подчиняться монадическим законам (хотя это ж ещё потом проверить нужно)?

                                              0

                                              И что в этом ужасного? Как раз 3 разных синтаксиса для одного и того же меня не очень воодушевляют.

                                                0

                                                Попробуйте записать на хаскеле

                                                  +1
                                                  foo :: (State Int m, ConsoleIO m, Async m) => m Foo
                                                  foo = do
                                                    barr <- bar
                                                    bazz <- baz
                                                    print bazz
                                                    rnd <- getRandom
                                                    pure $ MkFoo barr bazz rnd

                                                  Пожалуйста, записал

                                                    –1

                                                    Спасибо. JS разработчик ожидает тип вроде () => Promise<object>. Стоит-ли усложнять?

                                          –1
                                          Достаточно добавить в язык синтаксический сахар, позволяющий делать monadic comprehension

                                          Вы так говорите, будто в природе есть какой-то язык, в котором есть monadic comprehension с человеческим лицом.

                                          0

                                          Трайкетч не чище, он просто скрывает сам факт возможности ошибки. Но да, практичность в языках без поддержки монадических вычислений страдает. Сколько боли было с портянками .then() а потом появился асинк-авейт и стало жить куда проще. Для Either тоже нужны свои асинк/авейты, но их нет, и без них получается портянка всё тех же then'ов, только они означают не продолжение в случае выплнения асинк операции, а продолжение если мы отработали без ошибок. Смысл ровно тот же: полотно коллбеков на все возможные случаи.


                                          Поэтому пока в языке нет какой-то поддержки обобщенных монадических вычислений, или хотя бы костыля конкретно под Either как в расте, практичность подобных библиотек — вопрос весьма спорный. Хотя, не спорю, контролировать в типах возможные ошибки — очень крутая возможность. Но учитывая стоимость саппортинга всего этого без помощи от языка — реально проще иногда ловить баги и чинить чем поддерживать лесенки комбинаторов (просто оставлю здесь пример из сишарпа):
                                          img

                                            0

                                            Может, хватит уже приводить подобные "лесенки"? В C# более чем достаточно инструментов, чтобы написать подобный код понятнее даже без языковой поддержки монад — просто для автора кода по какой-то неведомой мне причине именно "лесенка" оказалась самым красивым вариантом.

                                              0

                                              Не получится кардинально ничего с этим сделать. Можно разве что разнести по переменным, но все равно лапша из коллбеков никуда не девается. Напомню, что именно это было изначальной мотивацией асинк авейта — внешне линейное исполнение, обработка ошибок в одном месте и т.п.

                                                0

                                                Как минимум, все эти вызовы можно "вытянуть" в цепочку вместо вложения.

                                                  0

                                                  Не всегда можно. Я просто как-то ради интереса на акка стримах это использовал (потому что там эксепшны пропагируются до арбитра и нормально обработать ошибку в итоге нельзя). Максимально негативный опыт в итоге

                                                    0

                                                    Конкретно в данном случае — можно. Ваших стримов не видел, но если там серия map или там filter — то тоже можно. Или если там не очень важен порядок — то можно BidiFlow использовать вместо Flow, а "обратную" ветку использовать как короткий путь для результата.

                                          +2

                                          Боги, как же хорошо объясняете. Жду продолжения.

                                            0

                                            Спасибо! Я рад, если моя работа приносит кому-то пользу :)

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

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