yo, what's up
今天要在 FP 裡很有用的概念 Lenses, 它可以減少在處理資料結構邏輯時的複雜度,並且可以寫出更容易閱讀以及更乾淨的程式。
const user = {
id: 0,
name: "JingMultipleFive",
username: "jing.tech",
email: "jing.tech.tw@gmail.com",
address: {
street: "Wall",
suite: "Abc 123",
city: ["Taipei", "Subic", "New York"],
zipcode: "242",
geo: {
lat: "-43.9509",
lng: "-34.4618"
}
}
}
假設目前有一個需求,其為對上列的資料結構進行
根據上面的問題,我們在實作前需要
將字串轉大寫的函式
const toUpper = str => str.toUpperCase()
讀者們可以用五分鐘想想實作方式!
在筆者還不認識 Lenses 這個概念前,馬上想到的方法就是 shallow copy,直接深入資料結構,瞄準目標,大鬧一番!!!!!!
首先我們先進行第一個需求 修改
// 將使用者居住城市中的第一筆資料改成大寫
const modified_user = {
...user,
address: {
...user.address,
city: [
toUpper(user.address.city.slice(0, 1)[0]),
...user.address.city.slice(1)
]
}
}
然後 讀取 該值
// 取出該值
modified_user.address.city[0] // TAIPEI
那我們來分析一下,上面的兩個行為 讀取 以及 修改,
modified_user.addresss.city[0]
// Uncaught TypeError: Cannot read properties of undefined (reading 'city')
有些讀者可能會想,那用前幾天提到的 function composition 呢?
R.compose(R.toUpper, R.path(['address', 'city', 0]))
這個作法只能將目標值取出,並修改,但並不會放回原本的資料結構內!
Lenses 是 FP 的工具之一,讓開發者可以在複雜的資料結構中,對特定的子結構 (subpart) 進行 讀取 / 寫入 / 修改,其他更進階的概念像是 Folds 跟 Traversals. 在之後的文章可能會提到。
Lenses 需傳入兩種 method, getter 以及 setter!
以下筆者將使用 Ramda 來 demo 這個概念
首先我們先使用 Ramda R.lens
,解決上面的問題
R.lens
R.lens(getter, setter)
getter: 取得目標資料。
setter: 寫入目標資料,注意 setter 不能 mutate 原有的資料結構。
const R = require('ramda');
const getFirstCity = data => data.address.city[0];
const setFirstCity = (value, data) => ({
...user,
address: {
...user.address,
city: [value, ...user.address.city.slice(1)]
}
})
const firstCityLens = R.lens(getFirstCity, setFirstCity);
在先前我們提到 Lenses 可以對資料結構中特定的子結構 (subpart) 進行 讀取/寫入/修改。
而Ramda 對應的操作函式為
R.view(lens, dataStructure)
R.set(lens, updateValue, dataStructure)
R.over(lens, updateFunction, dataStructure)
// 取出該值
R.view(firstCityLens, user)
// 用 R.set, 將使用者居住城市中的第一筆資料改成大寫
R.set(firstCityLens, R.toUpper(R.view(firstCityLens, user)), user)
// 用 R.over, 將使用者居住城市中的第一筆資料改成大寫
R.over(firstCityLens, R.toUpper, user)
好的,...想必現在各位讀者應該都心想 "So...What..., 怎麼這麼複雜,也沒多乾淨嘛!"。
修但幾咧,這只是 demo 最陽春的 lens 如何使用,竟然都使用 Ramda 了,其他函式當然也要給它用好用滿。
Ramda 也有提供相關的函式,去定位目標資料,如圖
資料結構 | getter | setter |
---|---|---|
單層 | R.prop | R.assoc |
多層 | R.path | R.assocPath |
而目前我們要定位的 city 是在資料結構的深層,所以我們需要使用 R.path
以及 R.assocPath
作為 lens 的 getter 與 setter!
const getFirstCity = R.path(['address', 'city', 0]);
const setFirstCity = R.assocPath(['address', 'city', 0])
const lensCity = R.lens(getFirstCity, setFirstCity);
// 取出該值
R.view(lensCity, user)
// 將使用者居住城市中的第一筆資料改成大寫
R.over(lensCity, R.toUpper, user)
R.lensPath
再改寫一次可以看到 R.path
跟 R.assocPath
所放入的參數都是 ['address', 'city', 0]
,Ramda 內有提供 R.lensPath
去簡化此寫法。
R.lensPath(<prop>)
只是R.lens(R.path(<prop>), R.assocPath(<prop>))
的簡寫。
const lensCity = R.lensPath(['address', 'city', 0]);
// 取出該值
R.view(lensCity, user)
// 將使用者居住城市中的第一筆資料改成大寫
R.over(lensCity, R.toUpper, user)
Isn't that neat!?
重點是 R.over
與 R.set
返回的值是 immutable 的,也就是不會去動到原有的資料結構!
Lenses 必定符合下列三個特性
1. set after getview(lens, set(lens, a, store)) ≡ a
寫入 一個值到資料結構中,然後立即 讀取 該目標值,則會得到剛 寫入 的值。
2. set after setset(lens, b, set(lens, a, store)) ≡ set(lens, b, store)
寫入 一個值到資料結構中,然後立即重複 寫入 值到該目標結構中,則會得到剛 寫入 的值,也就是 b。
3. get after setset(lens, view(lens, store), store) ≡ store
當你 讀取 資料結構中的目標值,然後立即 寫入 值到該目標結構中,資料結構不變。
Lenses 就是一般的函式,而想到函式代表我們可以進行 composition!
唯一不同的是它們執行順序是從左到右邊 (而非先前所學的右到左),明天介紹的 transduce 也是從左到右邊!
const address = lensProp('address');
const city = lensProp('city');
const head = lensIndex(0)
const lensCity = compose(address, city, head);
// 將使用者居住城市中的第一筆資料改成大寫
R.over(lensCity, R.toUpper, user)
// 取出該值
R.view(lensCity, user)
其實單就操作資料結構,Lenses 只是其中一個方法,還有其他原生的 Web API 像是 proxy 或是三方套件像是 immer,又或是可以期待未來的 JS 新 feature Record,它們使用起來或許對於非函數式編程者會更友善,但 Lenses 不只提供 Functional 的方法操作資料結構,還有 Fold 跟 Traversables 之後也會一併討論到。
Immer 範例
import produce from 'immer';
const modified_user = produce(user, draft => {
draft.address.city[0] = toUpper(draft.address.city[0])
})
// 取出該值
modified_user.address.city[0] // TAIPEI
感謝大家的閱讀!
NEXT: Tranduce I