由 converge() 重構到 chain()

實務上從 API 獲得的資料,可能只有 id,並沒有實際的 value,我們必須自行 map() 將 id 轉換成 value,這個常見的應用,若使用 Ramda 該怎麼寫呢 ?

Version


VS Code 1.30.2
Quokka 1.0.136
Ramda 0.26.1

Imperative


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const countryLut = {
1: 'USA',
2: 'China',
3: 'Japan',
};

const data = [
{ id: 1, language: 'English' , countryId: 1},
{ id: 2, language: 'Chinese', countryId: 2 },
{ id: 3, language: 'Japanese', countryId: 3},
];

const getCountries = data => {
let result = [];
for(let x of data) {
result.push({
...x,
country: countryLut[x.countryId],
});
}
return result;
};

console.dir(getCountries(data));

data 只有 idlanguagecountryId,但需求是能根據 countryLut 對照表,新增 country property。

Imperative 會使用 for loop,先建立好要回傳的新 array:result,將新的 object 一一 push()result,原本的 property 全數使用 ... spread operator 加以保留。

至於查詢 countryLut,可使用 ECMAScript 的語言特性,直接使用 [] 加以查詢。

這是大家熟悉的寫法,但有幾個問題:

  1. result array 並不是必須的,算是為了結果所產生的 中繼變數
  2. 必須使用 push() 這種 side effect 寫法對 array 新增資料
  3. 必須跟著 for loop 內的程式碼一行一行看下去,才知道到底想做什麼,並不能很直覺的看出這段程式碼的意圖,也就是 Imperative 寫法會嘗試將演算法直接寫在 function 內

ref000

Array.prototype


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const countryLut = {
1: 'USA',
2: 'China',
3: 'Japan',
};

const data = [
{ id: 1, language: 'English' , countryId: 1},
{ id: 2, language: 'Chinese', countryId: 2 },
{ id: 3, language: 'Japanese', countryId: 3},
];

const getCountries = data =>
data.map(x => ({
...x,
country: countryLut[x.countryId]
}));

console.dir(getCountries(data));

ECMAScript 提供了 Functional 的 map() Higer Order Function,讓我們一眼就可以看到他的意圖:就是為了產生一個筆數相同的 array,只是內容略有調整。

map() 的參數為 callback function,其中 x 為 array 中的 object,相當於 for(let x of data)x

為了保留 object 原本的 property,一樣使用 ... spread operator 將 property 展開。

Callback 主要目的就是回傳新 object,因此可使用 {} 直接組新 object,但 ES2015 規定,若直接回傳 object,必須再加上 (),否則會誤判。

我們可以發現 Functional 寫法有幾個特色:

  1. 中繼變數 result 不見了
  2. 不必使用 push() 這種 side effect 寫法
  3. map() 意圖明顯,一看到 map() 就知道他要產生新格式 array

但若嚴格來說,還有改善空間:

  1. getCountries() 的參數 data 是否真的需要 ? 能否再簡化 ?
  2. map() 的 callback 參數 x 參數是否真的需要 ? 能否再簡化 ?

ref001

Ramda


Map()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { map } from 'ramda';

const countryLut = {
1: 'USA',
2: 'China',
3: 'Japan',
};

const data = [
{ id: 1, language: 'English' , countryId: 1},
{ id: 2, language: 'Chinese', countryId: 2 },
{ id: 3, language: 'Japanese', countryId: 3},
];

const getCountries = map(x => ({
...x,
country: countryLut[x.countryId]
}));

console.dir(getCountries(data));

我們希望將 getCountries() 的參數 data 加以簡化,可使用 Ramdamap() 取代 Array.prototype.map(),callback 則完全保留。

map() callback 的參數 x 依然存在,所以還有重構空間。

ref003

Converge()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { map, assoc, converge, identity, compose, prop, __ } from 'ramda';

const countryLut = {
1: 'USA',
2: 'China',
3: 'Japan',
};

const data = [
{ id: 1, language: 'English' , countryId: 1},
{ id: 2, language: 'Chinese', countryId: 2 },
{ id: 3, language: 'Japanese', countryId: 3},
];

const lookupLut = (propName, lut) => compose(
prop(__, lut),
prop(propName),
);

const getCountries = map(converge(
assoc('country'),
[
lookupLut('countryId', countryLut),
identity,
]
));

console.dir(getCountries(data));

回想 map() 的 callback 部分:

1
2
3
4
x => ({
...x,
country: countryLut[x.countryId]
})

說穿了我們的目的就是:從 object 取得 countryId 值,並對 countryLut 查表,最後將其值新增至 country property。

若以鐵道圖表示如下:

ref006

15 行

1
2
3
4
const lookupLut = (propName, lut) => compose(
prop(__, lut),
prop(propName),
);

其中前兩個流程 從 object 取得 countryId 值對 countryLut 查表,這兩個流程在實務上經常遇到,可以抽出來寫成 function:傳入 property 名稱與 LUT object

透過 Ramda 的 compose(),可將兩個 prop() 加以組合成新 function。

lookupLut() 這類 general function,可以重構到 helper module 內,將來其他地方要用只要 import lookupLut() 即可使用,FP 就是因為這些小 function 存在,所以重複使用的程度非常高。

20 行

1
2
3
4
5
6
7
8
9
const getCountries = map(converge(
assoc('country'),
[
lookupLut('countryId', countryLut),
identity,
]
));

console.dir(getCountries(data));

前兩個流程都解決了,剩下最後一個流程:將其值新增至 country,Ramda 特提供了 assoc() 負責對 object 新增 property。

assoc()
String -> a -> {k: v} -> {k: v}
複製原本 object 的 property 外,還可新增 property

assoc() 的第一個參數是新的 property 名稱,所以我們傳入 country

第二個參數要傳入的是其 value,我們已經建立了 lookupLut(),這也沒問題。

第三個參數要傳入為原 object,也就是 map() callback 的 x

但卻面臨了一個問題:

assoc() 該如何與 map() 串起來 ? map() 的 callback 只有一個參數 x object,但 assoc() 要的卻是兩個參數:value 與 object。

這是 Ramda 初學者一定會卡關的地方。

Ramda 另外提供了 converge(),負責將原本一個參數變成兩個參數,再交給 assoc() 執行。

ref007

對於 lookupLut('countryId', countryLut) 而言,因為 lookupLut() 的兩個參數已經填滿,其實 x object 傳進去會忽略,不過並不會影響結果。

assoc() 第三個參數要的就是 x object,所以使用了 Ramda 的 identity,傳回自己本身的 object,在 FP 稱為 Identity Function。

identity()
a -> a
產生回傳與原本輸入值相同的 function

透過 converge()map()assoc() 就接起來了。

我們可以發現 map() callback 的 x 被 Point-free 掉了,且還產生了 lookupLut() Higher Order Function 可供日後重複使用。

ref004

Chain()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { map, assoc, chain, compose, prop, __ } from "ramda";

const countryLut = {
1: "USA",
2: "China",
3: "Japan"
};

const data = [
{ id: 1, language: "English", countryId: 1 },
{ id: 2, language: "Chinese", countryId: 2 },
{ id: 3, language: "Japanese", countryId: 3 }
];

const lookupLut = (propName, lut) => compose(
prop(__, lut),
prop(propName)
);

const getCountries = map(chain(
assoc("country"),
lookupLut("countryId", countryLut))
);

console.dir(getCountries(data));

使用 converge() 已經接近完美,配合 identity() 可讀性也高,但 identity() 似乎是一個可有可無的 function,能否也 Point-free 掉呢 ?

20 行

1
2
3
4
const getCountries = map(chain(
assoc("country"),
lookupLut("countryId", countryLut))
);

使用 Ramda 的 chain() 可將 identity() 省略。

chain()
Chain m => (a -> m b) -> m a -> m b
chain(f, g)(x) === f(g(x), x)

ref008

chain() 會自動將參數 x object 直接傳給 assoc() ,我們就不必再使用 identity() 了。

ref005

Conclusion


  • 由於 Ramda 的參數都是 function,會無形中要求自己重構出很多小 function,而這些小 function 就是日後組合的本錢,會大幅增加開發效率
  • converge() 為實務上常用的 operator,尤其當 operator 間的參數個數不相同時,會需要 converge() 作為中間人加以處理
  • 若一開始覺得 chain() 太玄,無法一步到位想到 chain(),可先重構成 converge(),看到 identity 之後,自然會想到 chain()

Reference


Ramda, map()
Ramda, converge()
Ramda, chain()
Ramda, assoc()
Ramda, identity()
Ramda, prop()
Ramda, compose()