搬磚時你需要一點奇技淫巧 -- Lens 原理及應用

serialcoder發表於2019-01-17

原文發表在 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 資料中的應用。以後我可能會繼續介紹。

相關文章