iT邦幫忙

2021 iThome 鐵人賽

DAY 6
0
Software Development

Functional Programming For Everyone系列 第 6

Day 06 - Lenses (Basic)

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"
      }
    }
}

假設目前有一個需求,其為對上列的資料結構進行

  1. 修改: 將使用者居住城市中的第一筆資料改成大寫
  2. 讀取: 讀取該值

根據上面的問題,我們在實作前需要

將字串轉大寫的函式

const toUpper = str => str.toUpperCase()

讀者們可以用五分鐘想想實作方式!

在不認識 Lenses 以前

在筆者還不認識 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

那我們來分析一下,上面的兩個行為 讀取 以及 修改

  • 讀取 看似沒有什麼問題,但它不會有 Error Handle,想想今天不知道什麼原因,開發者因為胖手指在 address 後面多打了一個 s,則整個的程式就爆了
modified_user.addresss.city[0] 
// Uncaught TypeError: Cannot read properties of undefined (reading 'city')
  • 修改 修改就更彆扭了,如果今天是對資料結構進行更深層的修改,不斷的進行 shallow copy 只會讓程式在閱讀上更加困難。

那用前幾天學到的 compose 呢?

有些讀者可能會想,那用前幾天提到的 function composition 呢?

R.compose(R.toUpper, R.path(['address', 'city', 0])) 

這個作法只能將目標值取出,並修改,但並不會放回原本的資料結構內!

Lenses 是什麼?

Lenses 是 FP 的工具之一,讓開發者可以在複雜的資料結構中,對特定的子結構 (subpart) 進行 讀取 / 寫入 / 修改,其他更進階的概念像是 Folds 跟 Traversals. 在之後的文章可能會提到。

如何使用 Lenses

Lenses 需傳入兩種 method, getter 以及 setter!

以下筆者將使用 Ramda 來 demo 這個概念

首先我們先使用 Ramda R.lens,解決上面的問題

Step01. 用最陽春的 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 了,其他函式當然也要給它用好用滿。

Step02. 將 getter 以及 setter 改寫

Ramda 也有提供相關的函式,去定位目標資料,如圖

Imgur

資料結構 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)

Step03 用 R.lensPath 再改寫一次

可以看到 R.pathR.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.overR.set 返回的值是 immutable 的,也就是不會去動到原有的資料結構!

Lenses 的三個特性

Lenses 必定符合下列三個特性

1. set after get
view(lens, set(lens, a, store)) ≡ a
寫入 一個值到資料結構中,然後立即 讀取 該目標值,則會得到剛 寫入 的值。

2. set after set
set(lens, b, set(lens, a, store)) ≡ set(lens, b, store)
寫入 一個值到資料結構中,然後立即重複 寫入 值到該目標結構中,則會得到剛 寫入 的值,也就是 b。

3. get after set
set(lens, view(lens, store), store) ≡ store
當你 讀取 資料結構中的目標值,然後立即 寫入 值到該目標結構中,資料結構不變。

Composition

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

Reference


上一篇
Day 05 - Ramda
下一篇
Day 07 - Transduce I
系列文
Functional Programming For Everyone30

尚未有邦友留言

立即登入留言