最近和做技術的朋友聊天的時候,發現自己居然不能將函式式編程思想講清楚,于是做一次復習
一、函式是“一等公民”
常常都能聽到這么一句話:在 JavaScript 中,函式是“一等公民”,這句話到底意味著什么?
在編程語言中,一等公民可以作為函式引數,可以作為函式回傳值,也可以賦值給變數 —— Christopher Strachey
其實在很多傳統語言中( 比如 C,JAVA 8 以前 )函式只可以宣告和呼叫,無法像字串一樣作為引數使用
而 JavaScript 中的函式與其他資料型別處于平等地位,這是函式式編程的前提
二、純函式 (pure functions)
現在正式接觸函式式編程,首先看一個簡單的需求:
有這樣的一堆用戶資訊
const arr = [
{name: '趙信', gender: 1, age: 25, high: 176, weight: 62},
{name: '艾希', gender: 2, age: 23, high: 161, weight: 46},
{name: '阿貍', gender: 2, age: 27, high: 182, weight: 53},
{name: '蓋倫', gender: 1, age: 27, high: 175, weight: 78},
{name: '沃里克', gender: 1, age: 42, high: 169, weight: 70},
{name: '安妮', gender: 2, age: 16, high: 153, weight: 43},
{name: '卡爾瑪', gender: 2, age: 40, high: 168, weight: 48},
{name: '菲茲', gender: 0, age: 52, high: 163, weight: 50},
{name: '亞索', gender: 1, age: 35, high: 177, weight: 65},
{name: '銳雯', gender: 2, age: 33, high: 172, weight: 52},
]
撰寫一個過濾用戶資訊的函式,統計18歲以上男性有多少人,且記錄他們的身高和姓名
也許你會這么寫:
const male = {
count: 0,
list: [],
};
const MIN_AGE = 18;
const Count = (arr) => {
for (const item of arr) {
if (
!item
|| +item.age < +MIN_AGE
|| `${item.gender}` !== '1'
) { continue }
male.count++;
male.list.push({
name: item.name,
high: item.high,
});
}
}
似乎沒什么問題的亞子,我們作業中也會寫這樣的函式
但上面的 MIN_AGE、male 都是外部變數(或者說全域變數)
我們在寫業務的時候,這樣的寫法挑不出什么毛病,但他們都不是純函式
純函式具備兩個特點:
1. 不依賴外部狀態,相同的輸入永遠得到相同的輸出;
2. 沒有副作用,不會修改入參或者全域變數, // splice 說的就是你!
就上面的例子來說,如果連續執行幾次 Count(arr) 就會出問題:

如果按照純函式的標準,可以改成這樣:
const Count = (arr, min) => {
// 創建一個區域變數
const res = {
count: 0,
list: [],
};
for (const item of arr) {
if (
!item
|| +item.age < +min // 使用入參而不是全域變數
|| `${item.gender}` !== '1'
) { continue }
res.count++;
res.list.push({
name: item.name,
high: item.high,
});
}
// 回傳結果
return res;
}
這樣調整之后,函式就實作了完全的自給自足,我們也能很清楚的知道這個函式所依賴的引數是什么
但僅僅是這樣的調整似乎沒有什么特別之處,假如我們篩選條件改為體重小于 50kg 的女性,這個函式就需要做許多調整
別急,我們才剛開始,接下來就打造一個易維護、可讀性高的業務函式
三、柯里化 (curry)
上面的例子其實采用的是命令式編程的思想,關注的是如何一步一步實作當前的需求
而函式式編程更像是用一個一個的加工站組合起來的工廠流水線,他也能實作需求,但更關注的是如何使用加工站
這個加工站就是柯里化,柯里化的概念很簡單:將一個多引數函式,轉換成一個依次呼叫的單引數函式
fun(a, b, c) -> fun(a)(b)(c)
需要注意柯里化和區域呼叫的區別
區域呼叫是指:只傳遞給函式一部分引數,并回傳一個函式去處理剩下的引數
fun(a, b, c) -> fun(a)(b, c) / fun(a, b)(c)
不過在實際作業中,由于都是使用工具庫(比如 Lodash,Ramda)提供的 curry 函式,而這些 curry 函式通常既滿足柯里化,也滿足區域呼叫,所以這兩個概念對實際作業沒什么影響
先從一個簡單的例子來認識柯里化,首先宣告一個求和函式
const sum = (x, y, z) => x + y + z;
然后實作一個簡單的 curry 函式(通常我們不會自己去寫 curry 函式,而是直接使用各種工具庫提供的 curry 函式)
const curry = (fn) => {
return function recursive(...args) {
// 如果args.length >= fn.length則表明傳入了足夠的引數,此時呼叫fn并回傳
if (args.length >= fn.length) {
return fn(...args);
}
// 否則表明沒有傳入足夠的引數,此時回傳一個函式,用這個函式接受后面傳遞的新引數
return (...newArgs) => {
// 遞回呼叫recursive函式,并回傳
return recursive(...args.concat(newArgs));
};
};
};
將 sum 函式柯里化
const Sum = curry(sum); // -> [Function]
Sum(10)(11)(12); // -> 33
const Sum10 = Sum(10); // -> [Function]
const Sum10_11 = Sum10(11); // -> [Function]
Sum10_11(12); // -> 33
我們可以直接使用柯里化之后的 Sum 來得到最終結果,也可以基于 Sum 創建出兩個特定的單入參函式 Sum10 和 Sum10_11,大大的增強了原本的 sum 函式的靈活性
而這些單入參函式是函陣列合的基礎,
四、函陣列合 (compose)
如果一個值要經過多個函式才能變成另外一個值,就可以把所有中間步驟合并成一個函式,這就是函陣列合
const compose = (f, g) => x => f(g(x))
以這個極簡版的 compose 函式舉個例子:
const f = x => x + 1;
const g = x => x * 2;
const fg = compose(f, g);
fg(1) // ----> ?
別用控制臺除錯,能看出 fg(1) 的結果是 3 還是 4 么?
如果有經過思考,就會發現一個細節:函陣列合中的函式是倒序執行的,我們的入參是 (f, g),但實際執行的順序是 g -> f
現在假設我們有四個工具函式:
filter18(arr); // 從陣列中回傳年齡大于18歲的資料
filterMale(arr); // 從陣列中篩選出男性資料并回傳新陣列
pickNameHeight(arr); // 獲取陣列中的姓名和身高欄位并回傳新陣列
log(arr); // 列印引數
按照命令式編程的思路,如果要通過這四個函式實作最初的那個篩選用戶資訊的需求,就需要這么寫:
log(pickNameHeight(filterMale(filter18(arr))));
看得眼花是不是?使用 compose 試試:
const fun = compose(log, pickNameHeight, filterMale, filter18);
fun(arr);
現在就清晰多了,通過入參我們能一眼看出這條流水線做了什么
而且將不同的函式用不同的方式組合,還能得到更多更靈活的函式,這恰恰是函式式編程的魅力所在
和 curry 函式一樣,我們通常都是直接使用各種工具庫提供的 compose 函式
而這些工具庫通常還會提供一個 pipe 函式,這個函式的作用 compose 類似,但 pipe 的執行順序和 compose 相反,會將入參函式從前往后組合
現在我們掌握了函式式編程的兩大利器: curry 和 compose,再回頭想想最開始的那個需求吧
五、實戰
再來過一遍需求:撰寫一個過濾用戶資訊的函式,統計18歲以上男性有多少人,且記錄他們的身高和姓名
其實我們只需要做三件事,首先過濾出18歲以上的資料,然后過濾出男性,最后獲取其身高和姓名
1. 過濾出18歲以上的資料,首先需要實作一個用于比較大小的工具函式
// 校驗物件中的某個 key 是否大于臨界值 val
function porpGt(key, val, item) {
return item[key] > val
}
將這個函式柯里化,就能得到過濾 18 歲的工具函式
const cPropGt = curry(porpGt); // porpGt(a, b, c) -> cPropGt(a)(b)(c)
const filter18 = cPropGt('age')(18); // cPropGt('age')(18)(item) -> filter18(item)
arr.filter(filter18); // 回傳 age 大于 18 的資料
2. 過濾出男性,這需要一個判斷等值的工具函式
// 判斷物件中的某個 key 是否等于臨界值 val
function porpEq(key, val, item) {
return `${item[key]}` === `${val}`
}
同樣的執行柯里化,然后得到過濾男性的工具函式
const cPropEq = curry(porpEq); // porpEq(a, b, c) -> cPropEq(a)(b)(c)
const filterMale = cPropEq('gender')(1); // cPropEq('gender')(1)(item) -> filterMale(item)
arr.filter(filterMale); // 回傳 gender 等于 1 的資料
3. 記錄身高和姓名,需要一個從物件中提取值的工具函式
// 從物件中提取多個值并回傳新的物件
function pickAll(keys, item) {
const res = {};
keys.map(key => res[key] = item[key]);
return res;
}
柯里化,并保留 name 和 high 兩個欄位
const cPickAll = curry(pickAll);
const pickProps = cPickAll(['name', 'high']);
arr.map(pickProps); // 只保留 name 和 high
完成這三步之后,如果采用面向物件的寫法,可以直接鏈式呼叫:
arr.filter(filter18)
.filter(filterMale)
.map(pickProps)
而如果使用了工具庫,通常會帶有 filter()、map() 這樣的工具函式,其功能和資料的 filter、map 一樣,只是呼叫的方式有些區別
所以使用工具庫的話,就可以很方便的使用函陣列合:
const Count = compose(
map(pickProps),
filter(filterMale),
filter(filter18),
);
Count(arr);
如果需要調整過濾條件,就只需要稍微修改一下工具函式的入參,生成新的工具函式之后再組合即可
六、小結
函式式編程會讓代碼顯得更清晰,更易維護
但從上面的例子也可以看出,命令式的寫法只進行了一次遍歷,而函式式編程的寫法卻遍歷了三次
所以我想提醒看到這里的小伙伴,函式式編程并不是放之四海皆準的萬能藥, 甚至在某些性能要求很嚴格的場合,函式式編程并不是太合適的選擇
我認為命令式編程、面向物件編程、函式式編程之間的關系就像是汽車、輪船、飛機之間的關系一樣
他們之間并不存在絕對的優劣好壞,也許在大部分的場合,飛機的速度會比汽車更快,但在崇山峻嶺之間,飛機也沒法安然著陸
多學習一種編程思想,只是多掌握了一門技能,僅此而已,
參考資料:
《函式式編程指北》
《函式式編程入門教程》
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/92009.html
標籤:JavaScript
上一篇:Ajax獲取網頁添加到div中
