Предыдущие статьи цикла:
В предыдущей статье мы рассмотрели понятие класса типов (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 пользователя и пароль, и проверяющий следующие условия:
- Email содержит знак «@»;
- Email хотя бы символ до знака «@»;
- Email содержит домен после знака «@», состоящий из не менее 1 символа до точки, самой точки и не менее 2 символов после точки;
- Пароль имеет длину не менее 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
была определена операция contact :: (a: E, b: E) => E
из класса типов Semigroup. Это позволяет использовать Validation вместо Either в задачах, где необходимо собирать все возможные ошибки. Например, мы можем переписать предыдущий пример (функцию handler
) так, чтобы собрать все возможные ошибки валидации входных данных, не переписывая при этом остальные функции валидации (validateAtSign, validateAddress, validateDomain, validatePassword).
Они выстраиваюся в следующую иерархию:
- Magma (Магма), или группоид — базовый класс типов, определяющий операцию
contact :: (a: A, b: A) => A
. На эту операцию не налагается никаких других ограничений. - Если к магме добавить ограничение ассоциативности для операции
concat
, получим полугруппу (Semigroup). На практике оказывается, что полугруппы более полезны, так как чаще всего работа ведется со структурами, в которых порядок элементов имеет значимость — вроде массивов или деревьев. - Если к полугруппе добавить единицу (unit) — значение, которое можно сконструировать в любой момент просто так, — получим моноид (Monoid).
- Наконец, если к моноиду добавим операцию
inverse :: (a: A) => A
, которая позволяет получить для произвольного значения его инверсию, получим группу (Group).
Детальнее об иерархии алгебраических структур можно почитать в вики.
Иерархию классов типов, соответствующих таким алгебраическим структурам, можно продолжать и дальше: в библиотеке 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. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.
ссылка на оригинал статьи https://habr.com/ru/post/544636/
Добавить комментарий