實戰 - JavaScript 函數式編程


最近和做技術的朋友聊天的時候,發現自己居然不能將函數式編程思想講清楚,於是做一次復習

 

一、函數是“一等公民”

常常都能聽到這么一句話:在 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);

如果需要調整過濾條件,就只需要稍微修改一下工具函數的入參,生成新的工具函數之后再組合即可

 

六、小結

函數式編程會讓代碼顯得更清晰,更易維護

但從上面的例子也可以看出,命令式的寫法只進行了一次遍歷,而函數式編程的寫法卻遍歷了三次

所以我想提醒看到這里的小伙伴,函數式編程並不是放之四海皆准的萬能葯, 甚至在某些性能要求很嚴格的場合,函數式編程並不是太合適的選擇

我認為命令式編程、面向對象編程、函數式編程之間的關系就像是汽車、輪船、飛機之間的關系一樣

他們之間並不存在絕對的優劣好壞,也許在大部分的場合,飛機的速度會比汽車更快,但在崇山峻嶺之間,飛機也沒法安然着陸

多學習一種編程思想,只是多掌握了一門技能,僅此而已。

 

參考資料:

《函數式編程指北》 

《函數式編程入門教程》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM