最近和做技術的朋友聊天的時候,發現自己居然不能將函數式編程思想講清楚,於是做一次複習 一、函數是“一等公民” 常常都能聽到這麼一句話:在 JavaScript 中,函數是“一等公民”,這句話到底意味著什麼? 在編程語言中,一等公民可以作為函數參數,可以作為函數返回值,也可以賦值給變數 —— Chri ...
最近和做技術的朋友聊天的時候,發現自己居然不能將函數式編程思想講清楚,於是做一次複習
一、函數是“一等公民”
常常都能聽到這麼一句話:在 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);
如果需要調整過濾條件,就只需要稍微修改一下工具函數的入參,生成新的工具函數之後再組合即可
六、小結
函數式編程會讓代碼顯得更清晰,更易維護
但從上面的例子也可以看出,命令式的寫法只進行了一次遍歷,而函數式編程的寫法卻遍歷了三次
所以我想提醒看到這裡的小伙伴,函數式編程並不是放之四海皆準的萬能藥, 甚至在某些性能要求很嚴格的場合,函數式編程並不是太合適的選擇
我認為命令式編程、面向對象編程、函數式編程之間的關係就像是汽車、輪船、飛機之間的關係一樣
他們之間並不存在絕對的優劣好壞,也許在大部分的場合,飛機的速度會比汽車更快,但在崇山峻嶺之間,飛機也沒法安然著陸
多學習一種編程思想,只是多掌握了一門技能,僅此而已。
參考資料: