✨從純函數講起,一窺最深刻的函子 Monad

語言: CN / TW / HK

theme: smartblue

本文為稀土掘金技術社區首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

專欄簡介

作為一名 5 年經驗的 JavaScript 技能擁有者,筆者時常在想,它的核心是什麼?後來我確信答案是:閉包和異步。而函數式編程能完美串聯了這兩大核心,從高階函數到函數組合;從無副作用到延遲處理;從函數響應式到事件流,從命令式風格到代碼重用。所以,本專欄將從函數式編程角度來再看 JavaScript 精要,歡迎關注!傳送門

序言

轉眼間,來到專欄第 3 篇,前兩篇分別是:

✨從歷史講起,JavaScript 基因裏寫着函數式編程

✨從柯里化講起,一網打盡 JavaScript 重要的高階函數

建議按順序“食用”。飲水知其源,由 lambda 演算演化而來的閉包思想是 JavaScript 寫在基因裏的東西,閉包的“孿生子”柯里化,是封裝高階函數的利器。

當我們頻繁使用高階函數、甚至自己不斷在封裝高階函數的時候,其實就已經把“函數是一等公民”這個最核心的函數式編程思想根植在心裏面了。

函數可以作為參數、可以作為返回值、可以賦值給變量......

本篇帶來 JavaScript 函數式編程思想中最重要的概念之一 —— 純函數,它定義了:寫出怎樣的函數才是優雅的! 由純函數概念衍生,我們將進一步探討:

  • 函數的輸入和輸出
  • 函數的副作用
  • 組合函數
  • 無形參風格編程
  • 以及最後將一窺較難理解的函子 Monad 概念

話不多説,趕緊衝了~

點贊 + 收藏 + 關注 === 學會

純函數

什麼樣的函數才算“純”?

緊扣定義,滿足以下兩個條件的函數可以稱作純函數:

  1. 如果函數的調用參數相同,則永遠返回相同的結果。它不依賴於程序執行期間函數外部任何狀態或數據的變化,必須只依賴於其輸入參數。
  2. 該函數不會產生任何可觀察的副作用,例如網絡請求,輸入和輸出設備或數據突變(mutation)

輸入 & 輸出

在純函數中,約定:相同的輸入總能得到相同的輸出。而在日常 JavaScript 編程中,我們並沒有刻意保持這一點,這會導致很多“意外”。

🌰 比如:分不清 slice 和 splice 的區別

``` var arr = [1,2,3,4,5];

arr.slice(0,3); // [1,2,3]

arr.slice(0,3); // [1,2,3]

arr.slice(0,3); // [1,2,3] ```

``` var arr = [1,2,3,4,5];

arr.splice(0,3); // [1,2,3]

arr.splice(0,3); // [4,5]

arr.splice(0,3); // [] ```

使用 slice 無論多少次,相同的輸入參數,都會有相同的結果;而 splice 則不會,splice 會修改原數組,導致即使參數完全相同,結果竟然完全不同。

在數組中,類似的、會對原數組修改的方法還有不少:pop()、push()、shift()、unshift()、reverse()、sort()、splice() 等,閲讀代碼時,想要得到原數組最終的值,必須追蹤到每一次修改,這會大幅降低代碼的可讀性。

🌰 比如: random 函數的不確定

Math.random() // 0.9706010566439833 Math.random() // 0.26820889412263416 Math.random() // 0.6144693062318409

Math.random() 每次運行,都會產生一個介於 0 和 1 之間的新隨機數,你無法預測它,相同的輸入、不通的輸出,意外 + 1;

相似的還有 new Date() 函數,每次相同的調用,結果不一致;

``` new Date().toLocaleTimeString() // '11:43:44'

new Date().toLocaleTimeString() // '11:44:16' ```

🌰 比如:有隱式輸出的函數

``` var tax = 20;

function calculateTax(productPrice) { tax = tax/100 return (productPrice * tax) + productPrice; }

calculateTax(100) // 120

calculateTax(100) // 100.2 ```

上面 calculateTax 函數是一個比較隱蔽的非純函數,輸入相同的參數,得到不同的結果。

究其原因是因為函數輸出依賴外部變量 tax,並在無意中修改了外部變量。

所以,綜上,純函數必須要是:有相同的輸入就必須有相同輸出的這樣的函數,運行一次是這樣,運行一萬次也應該是這樣。

副作用

除了保障相同的輸入得到相同的輸出這一點外,純函數還要求:不會產生任何可觀察的副作用

副作用指當調用函數時,除了返回可能的函數值之外,還對主調用函數產生附加的影響。

副作用主要包含:

  • 可變數據
  • 打印/log
  • 獲取用户輸入
  • DOM 查詢
  • 發送一個 http 請求
  • Math.random()
  • 獲取的當前時間
  • 訪問系統狀態
  • 更改文件系統
  • 往數據庫插入記錄

🌰 舉一些常見的有副作用的函數例子:

// 修改函數外部數據 let num = 0 function sum(x,y){ num = x + y return num } // 調用 I/O function sum(x,y){ console.log(x,y) return x+y } // 引用函數外檢索值 function of(){ return this._value } // 調用磁盤方法 function getRadom(){ return Math.random() } // 拋出異常 function sum(x,y){ throw new Error() return x + y }

我們不喜歡副作用,它充滿了不確定性,我們的函數不是一個穩定的黑盒,假設 function handleA() 函數,我們只期望它的功能是 A 操作,不希望它意外的又操作了 B 或 C。

所以,我們在純函數內幾乎不去引用、修改函數外部的任何變量,僅僅通過最初的形參輸入,經過一系列計算後再 return 返回給外部。

但副作用真的太常見了,有時候難以避免使用帶副作用的非純函數。在 JavaScript 函數式編程中,我們並不是倡導嚴格控制函數不帶一點副作用,而是要儘量把這個“危險的玩意”控制在可控的範圍內。後面會講到如何控制非純函數的副作用。

“純”的好處

説了這麼多關於“純函數”概念,肯定有人會問:寫純函數有什麼好處?我為什麼要寫純函數?

自文檔化

函數越純,它的功能越明確,不需要你閲讀它的時候還翻前找後,代碼本身就是文檔,甚至讀一下方法名就能放心的使用它,而不用擔心它還會不會有其它的影響。這就是代碼的自文檔化。

🌰舉個例子:

實現一個登錄功能:

// 非純函數 ```js var signUp = function(attrs) { var user = saveUser(attrs); welcomeUser(user); };

var saveUser = function(attrs) { var user = Db.save(attrs); ... };

var welcomeUser = function(user) { Email(user, ...); ... }; ```

// 純函數 ```js var signUp = function(Db, Email, attrs) { return function() { let user = saveUser(Db, attrs); welcomeUser(Email, user); }; };

var saveUser = function(Db, attrs) { ... };

var welcomeUser = function(Email, user) { ... }; ```

在純函數表達中,每個函數需要用到的參數更明確、調用關係更明確,為我們提供了更多的基礎信息,代碼信息自成文檔。

組合函數

本瓜常提的“組合函數”就是純函數衍生出來的一種函數。把一個純函數的結果作為另一個純函數的輸入,最終得到一個新的函數,就是組合函數。

js const componse = (...fns) => fns.reduceRight((pFn, cFn) => (...args) => cFn(pFn(...args))) ``js function hello(name) { returnHELLO ${name}` }

function connect(firstName, lastName) {   return firstName + lastName; }

function toUpperCase(name) {   return name.toUpperCase() } const sayHello = componse(hello, toUpperCase, connect)

console.log(sayHello('juejin', 'anthony')) // HELLO JUEJINANTHONY ```

多個純函數組合起來的函數也一定是純函數。

引用透明性

引用透明性是指一個函數調用可以被它的輸出值所代替,並且整個程序的行為不會改變。

我們可以利用這個特性對純函數進行“加和乘”的運算,這是重構代碼的絕妙手段之一~

🌰比如:

優化以下代碼: ```js var Immutable = require('immutable');

var decrementHP = function(player) { return player.set("hp", player.hp-1); };

var isSameTeam = function(player1, player2) { return player1.team === player2.team; };

var punch = function(player, target) { if(isSameTeam(player, target)) { return target; } else { return decrementHP(target); } };

var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"}); var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});

punch(jobe, michael); ```

因為 decrementHPisSameTeam 都是純函數,我們可以用等式推導、手動執行、值的替換來簡化代碼:

因為數據不可變,所以 isSameTeam(player, target) 替換成 "red" === "green",在 puch 函數內,if(false){...} 則直接刪掉,然後將 decrementHP 函數內聯,最終簡化為:

``` var punch = function(player, target) { return target.set("hp", target.hp-1); };

var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"}); var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});

punch(jobe, michael); ``` 純函數的引用透明性讓純函數能做簡單運算及替換,在重構中能大大減少代碼量。

其它

  • 純函數不需要訪問共享的內存,這也是它的決定性好處之一。這樣一來,它無需處於競爭態,使得 JS 在服務端的並行能力極大提高。

  • 純函數還能讓測試更加容易。我們不需要模擬一個真實的場景,只需要簡單模擬函數的輸入、然後斷言輸出即可。

  • 純函數與運行環境無關,只要願意嗎,可以在任何地方移植它、運行它,其本身已經撇除了函數所攜帶的的各種隱式環境,這是命令式編程的弊病之一。

言而總之,函數儘量寫“純”一點,好處真的有很多~ 寫着寫着就知道了

20220602144939_45223.gif

無形參風格

純函數的引用透明性可以等式推導演算,在函數式編程中,有一種流行的代碼風格和它很相似,如出一轍。

這種風格就是無形參風格,其目的是通過移除不必要的形參-實參映射來減少視覺上的干擾。

🌰舉例説明:

``` function double(x) { return x * 2; }

[1,2,3,4,5].map( function mapper(v){ return double( v ); } ); ```

double 函數和 mapper 函數有着相同的形參,mapper 的參數 v 可以直接映射到 double 函數裏的實參裏,所以 mapper(..) 函數包裝是非必需的。我們可以將其簡化為無形參風格:

``` function double(x) { return x * 2; }

[1,2,3,4,5].map( double ); // [2,4,6,8,10] ```

無形參可以提高代碼的可讀性和可理解性。

其實我們也能看出只有純函數的組合才能更利於寫出無形參風格的代碼,看起來更優雅~

Monad

前面一直強調:純函數!無副作用!

談何容易?HTTP 請求、修改函數外的數據、輸出數據到屏幕或控制枱、DOM查詢/操作、Math.random()、獲取當前時間等等這些操作都是我們經常需要做的,根本不可能擯棄它們,不然連最基礎功能都實現不了。。。

解決上述矛盾,這裏要拋出一個哲學問題:

你是否能知道一間黑色的房間裏面有沒有一隻黑色的貓?

image.png

明顯是不能的,直到開燈那一刻之前,把一隻貓藏在一間黑色的屋子裏,和一間乾淨的黑屋子都是等效的。

所以,對了!我們可以把不純的函數用一間間黑色屋子裝起來,最後一刻再亮燈,這樣能保證在亮燈前一刻,一直都是“純”的。

這些屋子就是單子 —— “Monad”!

🌰舉個例子,用 JavaScript 模擬這個過程:

``` var fs = require("fs");

// 純函數,傳入 filename,返回 Monad 對象 var readFile = function (filename) { // 副作用函數:讀取文件 const readFileFn = () => { return fs.readFileSync(filename, "utf-8"); }; return new Monad(readFileFn); };

// 純函數,傳入 x,返回 Monad 對象 var print = function (x) { // 副作用函數:打印日誌 const logFn = () => { console.log(x); return x; }; return new Monad(logFn); };

// 純函數,傳入 x,返回 Monad 對象 var tail = function (x) { // 副作用函數:返回最後一行的數據 const tailFn = () => { return x[x.length - 1]; }; return new Monad(tailFn); };

// 鏈式操作文件 const monad = readFile("./xxx.txt").bind(tail).bind(print); // 執行到這裏,整個操作都是純的,因為副作用函數一直被包裹在 Monad 裏,並沒有執行 monad.value(); // 執行副作用函數 ```

readFile、print、tail 函數最開始並非是純函數,都有副作用操作,比如讀文件、打印日誌、修改數據,然而經過用 Monad 封裝之後,它們可以等效為一個個純函數,然後通過鏈式綁定,最後調用執行,也就是開燈。

在執行 monad.value() 這句之前,整段函數都是“純”的,都沒有對外部環境做任何影響,也就意味着我們最大程度的保證了“純”這一特性。

王垠在《對函數式語言的誤解》中準確了描述了 Monad 本質:

Monad 本質是使用類型系統的“重載”(overloading),把這些多出來的參數和返回值,掩蓋在類型裏面。這就像把亂七八糟的電線塞進了接線盒似的,雖然表面上看起來清爽了一些,底下的複雜性卻是不可能消除的。

上述的 Monad 只是最通俗的理解,實際上 Monad 還有很多分類,比如:Maybe 單子、List 單子、IO 單子、Writer 單子等,後面再討論~

結語

本篇從純函數出發,JavaScript 函數要寫的優雅,一定要“純”!寫純函數、組合純函數、簡化運算純函數、無形參風格、純函數的鏈式調用、Monad 封裝不存的函數讓它看起來“純”~

純,就是這個味兒!

2016011611024214841.gif

OK,以上便是本篇分享,專欄第 3 篇,希望各位工友喜歡~ 歡迎點贊、收藏、評論 🤟

後文會重點講 延遲處理的思想、JavaScript 迭代器、函數式編程中的異步等,敬請期待~

關注專欄 # JavaScript 函數式編程精要 —— 簽約作者安東尼

我是掘金安東尼 🤠 100 萬人氣前端技術博主 💥 INFP 寫作人格堅持 1000 日更文 ✍ 關注我,安東尼陪你一起度過漫長編程歲月 🌏