Предыдущие статьи цикла:
В предыдущей статье мы рассмотрели понятие класса типов (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. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.