✨從純函式講起,一窺最深刻的函子 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 日更文 ✍ 關注我,安東尼陪你一起度過漫長程式設計歲月 🌏