原文發表在 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 和 setterconst 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 + bview(lensHa, obj) // =>
6over(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) // =>
50set(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 資料中的應用。以後我可能會繼續介紹。