Day 06 - Lenses (Basic)

yo, what's up

今天要在 FP 里很有用的概念 Lenses, 它可以减少在处理资料结构逻辑时的复杂度,并且可以写出更容易阅读以及更乾净的程序。

从两个简单的问题开始

const user = {
    id: 0,
    name: "JingMultipleFive",
    username: "jing.tech",
    email: "[email protected]",
    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 06] tinyML的重要推手Arm Cortex-M MCU

>>:  Day06 - 用 Next.js 做一个简易产品介绍页,使用 file-based routing

javasScript 进阶笔记二 (object.prototype.call)

Object.prototype.call(变数)可以更详细地找出变数的型态 console.log...

那些多人混战的开发经验谈

今年年初,刚好被大学学长推坑 COSCUP 开发组,这次的官网是以去年为基础去做修改的,所以并没有花...

Day 21 Arraylist

在Java程序设计中,有一个较为快速创造阵列的方法ArrayList,有别於固定大小的Array,A...

DAY 13- 《公钥密码》-RSA(1)

第一个要来看的公钥加密演算法是 RSA。 记得我们在 DAY6 的时候介绍到 RC4 时提到一个人吗...

30天学习笔记 -day 30 -感言

LAST Day 终於到了铁人赛的最後一天,过程中复习了不少的东西,对某些用法有了更加的认识,过程中...