Принцип линзы и применение

JavaScript функциональное программирование

некоторое время назадComposing SoftwareОбновился до Lens, и я видел, как кто-то на Nuggets перевел его. Наконец-то я учусь быстрее, чем обновления Эрика Эллиотта (главным образом потому, что он медленнее...). В его статье рассматриваются теоретические основы и простые приложения Lens, но она недостаточно глубока. В этой статье будет показана полная реализация Lens и другие сценарии применения, а также попытка доказать, что можно использовать некоторые приемы и приемы при перемещении кирпичей.

Ленс впервые родился в Хаскелле. Это функциональный геттер и сеттер, который обрабатывает операции со сложными наборами данных. Со всеми статьями в Интернете о линзах JavaScript я не смог найти введение в то, как реализована линза, вероятно, потому, что код слишком сложен для объяснения. При этом достаточно научиться пользоваться объективом, а в деталях внутреннего черного ящика разбираться не нужно. Я долго забрасывался реализацией объектива, и у меня появилось несколько вариантов, но они не восстанавливаются на 100%, и они всегда близки. В конце концов, мне пришлось прибегнуть к большому убийце, чтобы реверсировать исходный код Ramda.

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

// 工具函数,实现函数柯里化
const curry = fn => (...args) =>
  args.length >= fn.length ? fn(...args) : curry(fn.bind(undefined, ...args))

// 先别蛋疼,这是 K combinator,放在特定上下文才有意义
const always = a => b => a

// 实现函数组合
const compose = (...fns) => args => fns.reduceRight((x, f) => f(x), args)

// Functor,提供计算上下文,我之前的文章介绍过
const getFunctor = x =>
  Object.freeze({
    value: x,
    map: f => getFunctor(x),
  })

// 同上,注意比较和上面的区别
const setFunctor = x =>
  Object.freeze({
    value: x,
    map: f => setFunctor(f(x)),
  })

// 简单,取对象的 key 对应的值
const prop = curry((k, obj) => (obj ? obj[k] : undefined))

// 简单,更新对象的 key 对应的值并返回新对象
const assoc = curry((k, v, obj) => ({ ...obj, [k]: v }))

// 黑魔法发生的地方,复习下惰性求值
const lens = curry((getter, setter) => F => target =>
  F(getter(target)).map(focus => setter(focus, target))
)

// lens 的简写,避免上面函数调用时都要手动传 getter 和 setter
const lensProp = k => lens(prop(k), assoc(k))

// 读取 lens 聚焦的值
const view = curry((lens, obj) => lens(getFunctor)(obj).value)

// 对 lens 聚焦的值进行操作
const over = curry((lens, f, obj) => lens(y => setFunctor(f(y)))(obj).value)

// 更新 lens 聚焦的值
const set = curry((lens, val, obj) => over(lens, always(val), obj))

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

мои предыдущие статьи«Элегантное руководство по коду — умелое использование Ramda»В этой статье представлено применение Lens в React и REDUX. В этой статье рассказывается о других сценариях применения.

Давайте сначала посмотрим на простую работу Lens.

const obj = { foo: { bar: { ha: 6 } } }

const lensFoo = lensProp('foo')
const lensBar = lensProp('bar')
const lensHa = lensProp('ha')

view(lensFoo, obj) // => {bar: {ha: 6}}
set(lensFoo, 5, obj) // => {foo: 5}

линзы также можно комбинировать:

const lensFooBar = compose(
  lensFoo,
  lensBar
)

view(lensFooBar, obj) // => {ha: 6}
set(lensFooBar, 10, obj) // => {foo: {bar: 10}}

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

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

const lensPath = path => compose(...path.map(lensProp))

const lensHa = lensPath(['foo', 'bar', 'ha'])

const add = a => b => a + b

view(lensHa, obj) // => 6
over(lensHa, add(2), obj) // => {foo: {bar: {ha: 8}}}

Давайте рассмотрим несколько практических примеров.

Предположим, есть такой кусок данных, который записывает текущую температуру в Фаренгейтах:

const temperature = { fahrenheit: 68 }

Формула по Фаренгейту к Celsius Conversion является следующим:

const far2cel = far => (far - 32) * (5 / 9)

const cel2far = cel => (cel * 9) / 5 + 32

Если бы вас попросили перевести градусы Цельсия в градусы Фаренгейта, вашей первой мыслью было бы начать сtemperatureУдалите Фаренгейт со среды и используйте сноваfar2celПреобразовать его. Это похоже на это, но есть лучший способ.

const fahrenheit = lensProp('fahrenheit')
const lcelsius = lens(far2cel, cel2far)
const celsius = compose(
  fahrenheit,
  lcelsius
)

view(celsius, temperature) // => 20

viewФункция предоставляет разные «линзы», она возвращает разные данные, и я не научил его преобразовать данные (конечно, Celsius Lens имеет данные преобразования, но он скрыт, когда я это называю). Более того, я просто читаю данные с другой «объектив», и я не переместил исходные данные. Если бизнес-сценарий немного сложнее, представьте, как это круто.

Есть и более мощные.

Предполагая, что пользователь напрямую манипулирует значением Цельсия, мы хотим синхронно обновлять значение Фаренгейта. Угадайте как?

set(celsius, -30, temperature) // => {fahrenheit: -22}
over(celsius, add(10), temperature) // => {fahrenheit: 86}

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

Другой пример.

Предположим, есть запись данных о времени, данные включают часы и минуты.При работе с минутами, если минуты больше 60, минуты вычитаются на 60, а часы прибавляются на 1. Если минуты меньше 0, Затем добавьте 60 к минутам и вычтите 1 из часов. Хорошо понятные требования:

const clock = { hours: 4, minutes: 50 }

Сначала реализуйте линзу двух данных:

const hours = lensProp('hours')
const minutes = lensProp('minutes')

Затем настройте сеттер в соответствии с вашими потребностями:

// 先别蛋疼,这个函数很好用的
const flip = fn => a => b => fn(b)(a)

const minutesInvariant = lens(view(minutes), (value, target) => {
  if (value > 60) {
    return compose(
      set(minutes, value - 60),
      over(hours, add(1))
    )(target)
  } else if (value < 0) {
    return compose(
      set(minutes, value + 60),
      over(hours, flip(add)(-1))
    )(target)
  }
  return set(minutes, value, target)
})

Затем вы можете напрямую управлять минутами:

view(minutesInvariant, clock) // => 50
set(minutesInvariant, 62, clock) // => {hours: 5, minutes: 2}
over(minutesInvariant, add(59), clock) // => {hours: 5, minutes: 49}
over(minutesInvariant, add(-70), clock) // => {hours: 3, minutes: 40}

Моя версия реализации объектива не совместима с массивом. Если вы хотите использовать его в производственной среде, рекомендуется использовать Ramda. Если вы заинтересованы, вы можете реализовать совместимый массив на основе этого кода.

Есть и другие способы использования объектива в чисто функциональном программировании, например, его применение к данным Traversable и Foldable. Я могу продолжить знакомство позже.