實戰 - JavaScript 函數式編程

来源:https://www.cnblogs.com/wisewrong/archive/2020/05/11/12531629.html
-Advertisement-
Play Games

最近和做技術的朋友聊天的時候,發現自己居然不能將函數式編程思想講清楚,於是做一次複習 一、函數是“一等公民” 常常都能聽到這麼一句話:在 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);

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

 

六、小結

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

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

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

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

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

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

 

參考資料:

《函數式編程指北》 

《函數式編程入門教程》


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 學習 babel 時,遇到的問題,使用舊版本 babel 命名規則安裝後運行報錯,初步查找到原因是因為 babel 各個preset和plugin新舊不同版本之間存在相容問題,提示使用 npx babel-upgrade 可以自動升級,但是我升級失敗了,提示解析錯誤,後來看到了這篇文章,問題得以解決 ...
  • 一、逗號表達式 1.程式中使用逗號表達式,通產是要分別求出逗號表示式內各表達式的值,並不一定要求整個表達式的值。 2.並不是所有出現逗號的地方都組成逗號表達式,例如在變數說明中,函數參數表中逗號,只是用作各個變數之間的間隔符。 var a,b; b = (a=3,--a,a*5); console. ...
  • 介紹 集合是由一組無序且唯一(即不能重覆)的項組成的。比如由一個大於等於0的整數組成的集合:N={0,1,2,3,4,5,6,...}。 還有一個概念叫空集。用'{}'表示。 創建集合 我們使用對象來表示集合。 1 function Set() { 2 let items = {}; 3 } 常見方 ...
  • html post請求之a標簽的兩種用法舉例 1、使用ajax來發起POST請求HTML代碼如下: <a href="https://www.cnblogs.com/" class="a_post">發起POST請求</a> JQuery代碼如下: $(".a_post").on("click",f ...
  • 《Vue.js 2.x實踐指南》其實早在一年前就已經完稿,只是由於疫情的緣故耽擱了許久才下廠印刷。 本書旨在讓初學者能夠快速上手vue技術棧,並能夠利用所學知識獨立動手進行項目開發。我的寫作風格一向都是喜歡採用理論和實踐相結合的方式,這樣學習起來不會那麼枯燥,而且極具成效。時間是很寶貴的東西,所以盡 ...
  • 可視化編(rxeditor)輯告一段落,在知乎上發了一個問題,詢問前景,雖然看好的不多,但是關註度還是有的,目前為止積累了21w流量,因為這個事,開心了好長一段時間。這一個月的時間,主要在設計製作Vular,基於Vuetify跟larval實現的,可拼插式應用框架。並且把RXEditor可視化編輯也 ...
  • spring boot 使用 Thymeleaf +layui 使用到的功能實例 ...
  • 內容概述 本系列“vue項目中使用bpmn-xxxx”分為七篇,均為自己使用過程中用到的實例,手工原創,目前陸續更新中。主要包括vue項目中bpmn使用實例、應用技巧、基本知識點總結和需要註意事項,具有一定的參考價值,需要的朋友可以參考一下。如果轉載或通過爬蟲直接爬的,格式特別醜,請來原創看:我是作 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...