阅读 2477

搬砖时你需要一点奇技淫巧 -- Lens 原理及应用

原文发表在 Lambda Academy

前段时间 Composing Software 更新到 Lens 了,我看到掘金上也有人翻译了。我总算学的速度超过 Eric Elliott 更新的速度了(主要是他更新比较慢……)。他的那篇文章介绍了 Lens 的理论背景和简单应用,但还不够深入。本文将展示 Lens 的完整实现和更多的应用场景,并试图证明,搬砖时是可以用点奇技淫巧的。

Lens 最先诞生于 Haskell。它是函数式 getter 和 setter,用来处理对复杂数据集的操作。网上所有关于 JavaScript lenses 的文章,目前我还没找到介绍 Lens 怎么实现的,可能是因为代码太难解释了。而且,学会怎么用 lens 其实就行了,内部黑盒细节不需要明白。我最近一段时间在折腾 lens 的实现,弄出了好几个版本,都没 100% 还原,总是差一点。最终只好祭出大杀器,去逆向 Ramda 源码。

我先展示下我折腾出的最终版本 lens 实现。下面的代码可能会让你头和蛋一起疼。

// 工具函数,实现函数柯里化
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}
复制代码

lens 还能组合:

const lensFooBar = compose(
  lensFoo,
  lensBar
)

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

注意到 lens 是独立于被操作的数据的,这意味着 getter 和 setter 不用知道数据长什么样。这样做也意味着极大的复用性和代码的可组合性。

上面组合 lens 的写法可以提供很多灵活空间,但如果我想一下子取第三层数据,难道还要分别写三个 lens 然后组合吗?再加个辅助函数很容易做到:

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 }
复制代码

华氏温度和摄氏温度转换公式如下:

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

const cel2far = cel => (cel * 9) / 5 + 32
复制代码

如果让你根据华氏温度取出摄氏温度,你第一个想法肯定是先从 temperature 中取出华氏温度,再用 far2cel 转换一下。这样做看上去没什么,但还有更好的办法。

我们已经知道了华氏和摄氏是高耦合的两个单位,出现一个的时候一般都有转换成另一个单位的需求,我们可以利用 lens 让这个转换做到更顺滑一点。

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}
复制代码

如果用传统过程式写法,我猜没有更简洁的写法。当然过程式有合理的使用场景,我之前的文章实现惰性求值的 Lazy 函数,有大量过程式代码。

再举个例子。

假设有条记录时间的数据,该数据包含了小时和分钟数,对分钟数进行操作时,如果分钟数大于 60,则把分钟数减 60,同时把小时数加 1,如果分钟数小于 0,则把分钟数加 60,把小时数减 1。很好理解的需求:

const clock = { hours: 4, minutes: 50 }
复制代码

先实现两条数据的 lens:

const hours = lensProp('hours')
const minutes = lensProp('minutes')
复制代码

再根据需求定制化 setter:

// 先别蛋疼,这个函数很好用的
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}
复制代码

我这个版本的 lens 实现没有兼容数组。如果要在生产环境使用,建议还是用 Ramda。如果你有兴趣折腾,也可以基于本文代码实现兼容数组。

lens 在纯函数式编程里面还有更多玩法,比如在 Traversable 和 Foldable 数据中的应用。以后我可能会继续介绍。

关注下面的标签,发现更多相似文章
评论