Сбер AIJ 11.12.24
Сбер AIJ 11.12.24
Сбер AIJ 11.12.24

Функторы и монады: do or do not, there is no try

Рассказали о функторах и монадах — мощных дополнениях для управления выполнением кода и обработки ошибок в фунциональном программировании.

605 открытий9К показов

Основными строительными блоками функционального программирования являются чистые функции. А побочные эффекты являются их злейшими врагами, потому что из-за них функции перестают быть чистыми.

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

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

			const bob = JSON.parse('{"name": "Bob"}') // -> Bob
const jackson = JSON.parse('Beat me, hate me') // -> Error!
		

Впрочем, такое поведение задумано по дизайну, и чтобы обойти эту проблему, нужно просто завернуть вызов функции в try/catch:

			let jackson 
try {
  jackson = JSON.parse('You can never break me')
}
catch(error) {
  jackson = 'n/a'
}
		

При этом нам пришлось немного увеличить код и использовать мутабельную переменную. Использование let не всегда желательно, так как потребуется более внимательный анализ кода, чтобы отследить все места, где переменная может быть изменена, особенно это усложняется при большем объеме кода.

Хотелось бы найти более элегантное решение для этой проблемы. Как вариант, можно попробовать добавить метод onError и получить однострочное решение:

			const jackson = JSON.parse('Beat me, hate me').onError(() => 'n/a')
		

Выглядит красиво, но расширение объектов или функций через прототипы – это игра с огнем, к тому же это не универсальное решение. Каждый раз, когда нам нужно обрабатывать исключение, нам придется сначала изменить исходную функцию, а затем вызывать её. Звучит не очень.

Но что, если мы создадим функцию-обертку, которая временно расширит любую функцию с методом onError, не изменяя саму функцию?

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

			const jackson = trap(JSON.parse('Beat me, hate me')).onError(() => 'n/a')
		

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

			const userName = trap('You can never break me')
  .then(JSON.parse)
  .then(user => user.name)
  .onError(() => 'n/a')
  .finally()
		

После чего, она стала сильно напоминать Promise, не так ли? Правда, вместо метода onError в Promise используется метод catch. В принципе, можно просто использовать Promise без каких-либо дополнительных функций оберток:

			const parseName = str => Promise.resolve(str)
  .then(JSON.parse)
  .then(user => user.name)
  .then(str => str.trim())
  .then(str => str.toUpperCase())
  .catch(() => 'n/a')
  .finally()

const bob = await parseName('{"name": " Bob  "}') // -> 'BOB'
const sam = await parseName('{"nick": " Sam  "}') // -> 'n/a'
const one = await parseName('{"name": 1}') // -> 'n/a'
const jackson = await parseName('You can never break me') // 'n/a'
		

Такая последовательность функций с использованием then и catch позволяет наглядно управлять потоком и обработкой ошибок. Однако есть небольшая проблема: теперь функция parseName превратилась в асинхронную, значения к переменной будут присвоены не сразу после выполнения функции, а на следующем тике цикла событий (event loop). Это не совсем то, что хотелось бы.

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

			const flow = x => ({
  then: f => trapError(() => f(x)),
  catch: () => flow(x),
  finally: (f = x => x) => f(x),
})

const fail = x => ({
  then: f => fail(x),
  catch: f => trapError(() => f(x)),
  finally: () => {throw x},
})

function trapError(f) {
  try {
    return flow(f())
  }
  catch (error) {
    return fail(error)
  }
}
		

Ниже приведен пример наглядного использования. Здесь следует отметить, что для прерывания цепочки и вызова исполнения функции необходимо использовать метод finally:

			const findUser = id => flow(localStorage.getItem('users'))
  .then(JSON.parse)
  .then(users => users.find(user => user.id === id))
  .then(user => user.name)
  .catch(() => 'n/a')
	.then(str => str.toUpperCase())
  .finally() // trigger execution of the pipeline

const bob = findUser(12)
		

Интересный факт если вызвать функцию flow с await то она может выполнится без вызова finally, хотя функция синхронная.

			const ten = flow(7).then(x => x + 3).finally() // -> 10
const two = await flow(7).then(x => x + 3) // -> 10
		

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

			const isPromise = x => x instanceof Promise

const future = p => ({
  then: f => future(p.then(f)),
  catch: f => future(p.catch(f)),
  finally: (f = x => x) => p.then(f),
})

const flow = x => isPromise(x) ? future(x) : ({
  then: f => trapError(() => f(x)),
  catch: () => flow(x),
  finally: (f = x => x) => f(x),
})
		

И теперь мы можем комбинировать асинхронные функции в нашем конвейере (pipeline) и не беспокоится где начинается синхронный код, а где асинхронный.

			const refreshToken = salt => flow(localStorage.getItem('app'))
  .then(JSON.parse)
  .then(data => data.session.refreshToken)
  .then(saltedToken => atob(saltedToken).replace(salt, ''))
  .then(token => fetch(`/api/refresh-token/${token}`)) // now it is async func
  .then(res => res.json())
  .then(json => json.token)
  .catch(() => window.location.href = '/login')

const token = await refreshToken('Never store token in the local storage!')
		

Функторы и монады

Незаметно для себя, от примера к примеру мы стали применять функторы. Если вы удивлены этим фактом, то спешу сообщить, что функции future, flow, fail являются теми самыми функторами.

Функторы представляют собой контейнер, который хранит значение. Через функцию, переданную в методы then/catch, мы сможем иметь доступ к значению и изменить его при желании.

Функтор flow рекурсивно вызывает себя, передавая новое значение, именно поэтому мы можем бесконечно выстраивать цепочки из then/catch, тем самым строить композиции из функций.

Более того, каждый из then/catch обернуты в try/catch, что позволяет безопасно вызывать функции и контролировать двумя ветками выполнения: успешной веткой (then) и веткой для обработки ошибок (catch). Как только нам понадобится итоговое значение, нужно вызывать функцию finally. После вызова этой функции мы получим результат, и цепочка из then/catch оборвется.

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

			const greet = flow('bob')
  .then(str => str.toUpperCase())
  .then(str => 'Hello '.concat(str))
  .finally(str => str.concat('!'))

console.log(greet) // -> Hello BOB!
		

Вроде бы всё замечательно, но есть один момент. Если передать в наш поток другой поток, то на выходе мы получим не значение, а функтор.

			// welcome without finally
const welcome = name => flow(name)
  .then(str => str.toUpperCase())
  .then(str => 'Hello '.concat(str))
  .catch(() => 'Oops')

flow('{"name": "Bob"}')
  .then(JSON.parse)
  .then(user => user.name)
  .then(welcome) // nesting another flow will break ↓
  .finally(console.log) // -> {then: ƒ, catch: ƒ, finally: ƒ}
		

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

			const isFunctor = x => x?.finally instanceof Function

const flow = x => {
  if (isPromise(x)) return future(x)
  if (isFunctor(x)) return x
  return {
    then: f => trapError(() => f(x)),
    catch: () => flow(x),
    finally: (f = x => x) => f(x),
  }
}

const fail = x => ({
  then: (f, r) => r?.(x) ?? fail(x), // handle fail in promise
  catch: f => trapError(() => f(x)),
  finally: () => {throw x},
})
		

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

			flow('{"name": "Bob"}')
  .then(JSON.parse)
  .then(user => user.name)
  .catch(() => 'guest')
  .then(welcome) // nesting another flow
  .then(str => str.concat('!'))
  .finally(console.log) // -> Hello BOB!
		

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

Однако есть случаи, когда использование функторов и монад в методах then/catch может быть оправдано. С помощью функтора fail мы можем прервать выполнение потока без генерации исключения.

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

			const findUser = id => flow(localStorage.getItem('users'))
  .then(JSON.parse)
  .then(users => users.find(user => user.id === id))
  .then(user => user || fail('User not found')) // jump to the next catch on null
  .then(user => user.name)
  .catch(() => 'n/a')
  .then(str => str.toUpperCase())
  .finally()

const bob = findUser(12)
		

Другой пример – это функтор halt, который дает возможность прервать выполнение конвейера с определенным результатом.

			const halt = x => ({
  then: () => halt(x),
  catch: () => halt(x),
  finally: (f = x => x) => f(x),
})

const findUser = id => flow(localStorage.getItem('users'))
  .then(JSON.parse)
  .then(users => users.find(user => user.id === id))
  .then(user => user || halt('N/A')) // quit with value
  .then(user => user.name)
	.then(str => str.toUpperCase())
  .finally()
		

Заключение

Является ли данное решение полной заменой для try/catch? Это не замена, а скорее мощное дополнение для управления выполнением кода и обработки ошибок.

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

Кроме того, этот шаблон проектирования можно применять не только для обработки ошибок, но и для других целей. Вот некоторые возможные применения:

			const Component = select(role)
	.when('admin', AdminView)
	.when('editor', EditorView)
	.when('user', UserView)
	.otherwise(GuestView)

const selectGrade = mark => select(mark)
  .when(val => val > 90, 'A'),
  .when(between(80, 89), 'B'),
  .when(between(70, 79), 'C'),
  .when(between(50, 69), 'D'),
	.otherwise('F')

const grade = selectGrade(78) // -> C

const customers = await select('name', 'email', 'country')
  .from('users')
  .where({age: between(21, 60)})
  .and({favorites: in('beer', 'bbq')})
  .sortBy('country')
		

В этой статье я намеренно не следовал общепринятым конвенциям и законам монад.

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

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

Следите за новыми постами
Следите за новыми постами по любимым темам
605 открытий9К показов