從JavaScript純函數解析最深刻的函子 Monad實例
序言
轉眼間,來到專欄第 3 篇,前兩篇分別是:
?從柯里化講起,一網打盡 JavaScript 重要的高階函數
建議按順序“食用”。飲水知其源,由 lambda 演算演化而來的閉包思想是 JavaScript 寫在基因里的東西,閉包的“孿生子”柯里化,是封裝高階函數的利器。
當我們頻繁使用高階函數、甚至自己不斷在封裝高階函數的時候,其實就已經把“函數是一等公民”這個最核心的函數式編程思想根植在心里面了。
函數可以作為參數、可以作為返回值、可以賦值給變量......
本篇帶來 JavaScript 函數式編程思想中最重要的概念之一 —— 純函數,它定義了:寫出怎樣的函數才是優(yōu)雅的! 由純函數概念衍生,我們將進一步探討:
- 函數的輸入和輸出
- 函數的副作用
- 組合函數
- 無形參風格編程
- 以及最后將一窺較難理解的函子 Monad 概念
話不多說,趕緊沖了~
純函數
什么樣的函數才算“純”?
緊扣定義,滿足以下兩個條件的函數可以稱作純函數:
- 如果函數的調用參數相同,則永遠返回相同的結果。它不依賴于程序執(zhí)行期間函數外部任何狀態(tài)或數據的變化,必須只依賴于其輸入參數。
- 該函數不會產生任何可觀察的副作用,例如網絡請求,輸入和輸出設備或數據突變(mutation)
輸入 & 輸出
在純函數中,約定:相同的輸入總能得到相同的輸出。而在日常 JavaScript 編程中,我們并沒有刻意保持這一點,這會導致很多“意外”。
?? 比如:分不清 slice 和 splice 的區(qū)別
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 查詢
- 發(fā)送一個 http 請求
- Math.random()
- 獲取的當前時間
- 訪問系統(tǒng)狀態(tài)
- 更改文件系統(tǒng)
- 往數據庫插入記錄
?? 舉一些常見的有副作用的函數例子:
// 修改函數外部數據
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 }
我們不喜歡副作用,它充滿了不確定性,我們的函數不是一個穩(wěn)定的黑盒,假設 function handleA()
函數,我們只期望它的功能是 A 操作,不希望它意外的又操作了 B 或 C。
所以,我們在純函數內幾乎不去引用、修改函數外部的任何變量,僅僅通過最初的形參輸入,經過一系列計算后再 return
返回給外部。
但副作用真的太常見了,有時候難以避免使用帶副作用的非純函數。在 JavaScript 函數式編程中,我們并不是倡導嚴格控制函數不帶一點副作用,而是要盡量把這個“危險的玩意”控制在可控的范圍內。后面會講到如何控制非純函數的副作用。
“純”的好處
說了這么多關于“純函數”概念,肯定有人會問:寫純函數有什么好處?我為什么要寫純函數?
自文檔化
函數越純,它的功能越明確,不需要你閱讀它的時候還翻前找后,代碼本身就是文檔,甚至讀一下方法名就能放心的使用它,而不用擔心它還會不會有其它的影響。這就是代碼的自文檔化。
??舉個例子:
實現一個登錄功能:
// 非純函數
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, ...); ... };
// 純函數
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) { ... };
在純函數表達中,每個函數需要用到的參數更明確、調用關系更明確,為我們提供了更多的基礎信息,代碼信息自成文檔。
組合函數
本瓜常提的“組合函數”就是純函數衍生出來的一種函數。把一個純函數的結果作為另一個純函數的輸入,最終得到一個新的函數,就是組合函數。
const componse = (...fns) => fns.reduceRight((pFn, cFn) => (...args) => cFn(pFn(...args)))
function hello(name) { return `HELLO ${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
多個純函數組合起來的函數也一定是純函數。
引用透明性
引用透明性是指一個函數調用可以被它的輸出值所代替,并且整個程序的行為不會改變。
我們可以利用這個特性對純函數進行“加和乘”的運算,這是重構代碼的絕妙手段之一~
??比如:
優(yōu)化以下代碼:
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);
因為 decrementHP
和 isSameTeam
都是純函數,我們可以用等式推導、手動執(zhí)行、值的替換來簡化代碼:
因為數據不可變,所以 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);
純函數的引用透明性讓純函數能做簡單運算及替換,在重構中能大大減少代碼量。
其它
- 純函數不需要訪問共享的內存,這也是它的決定性好處之一。這樣一來,它無需處于競爭態(tài),使得 JS 在服務端的并行能力極大提高。
- 純函數還能讓測試更加容易。我們不需要模擬一個真實的場景,只需要簡單模擬函數的輸入、然后斷言輸出即可。
- 純函數與運行環(huán)境無關,只要愿意嗎,可以在任何地方移植它、運行它,其本身已經撇除了函數所攜帶的的各種隱式環(huán)境,這是命令式編程的弊病之一。
言而總之,函數盡量寫“純”一點,好處真的有很多~ 寫著寫著就知道了
無形參風格
純函數的引用透明性可以等式推導演算,在函數式編程中,有一種流行的代碼風格和它很相似,如出一轍。
這種風格就是無形參風格,其目的是通過移除不必要的形參-實參映射來減少視覺上的干擾。
??舉例說明:
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]
無形參可以提高代碼的可讀性和可理解性。
其實我們也能看出只有純函數的組合才能更利于寫出無形參風格的代碼,看起來更優(yōu)雅~
Monad
前面一直強調:純函數!無副作用!
談何容易?HTTP 請求、修改函數外的數據、輸出數據到屏幕或控制臺、DOM查詢/操作、Math.random()、獲取當前時間等等這些操作都是我們經常需要做的,根本不可能擯棄它們,不然連最基礎功能都實現不了。。。
解決上述矛盾,這里要拋出一個哲學問題:
你是否能知道一間黑色的房間里面有沒有一只黑色的貓?
明顯是不能的,直到開燈那一刻之前,把一只貓藏在一間黑色的屋子里,和一間干凈的黑屋子都是等效的。
所以,對了!我們可以把不純的函數用一間間黑色屋子裝起來,最后一刻再亮燈,這樣能保證在亮燈前一刻,一直都是“純”的。
這些屋子就是單子 —— “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); // 執(zhí)行到這里,整個操作都是純的,因為副作用函數一直被包裹在 Monad 里,并沒有執(zhí)行 monad.value(); // 執(zhí)行副作用函數
readFile、print、tail
函數最開始并非是純函數,都有副作用操作,比如讀文件、打印日志、修改數據,然而經過用 Monad 封裝之后,它們可以等效為一個個純函數,然后通過鏈式綁定,最后調用執(zhí)行,也就是開燈。
在執(zhí)行 monad.value()
這句之前,整段函數都是“純”的,都沒有對外部環(huán)境做任何影響,也就意味著我們最大程度的保證了“純”這一特性。
王垠在《對函數式語言的誤解》中準確了描述了 Monad 本質:
Monad 本質是使用類型系統(tǒng)的“重載”(overloading),把這些多出來的參數和返回值,掩蓋在類型里面。這就像把亂七八糟的電線塞進了接線盒似的,雖然表面上看起來清爽了一些,底下的復雜性卻是不可能消除的。
上述的 Monad 只是最通俗的理解,實際上 Monad 還有很多分類,比如:Maybe 單子、List 單子、IO 單子、Writer 單子等,后面再討論~
結語
本篇從純函數出發(fā),JavaScript 函數要寫的優(yōu)雅,一定要“純”!寫純函數、組合純函數、簡化運算純函數、無形參風格、純函數的鏈式調用、Monad 封裝不存的函數讓它看起來“純”~
更多關于JavaScript純函數Monad的資料請關注腳本之家其它相關文章!
相關文章
深入理解JavaScript系列(14) 作用域鏈介紹(Scope Chain)
在第12章關于變量對象的描述中,我們已經知道一個執(zhí)行上下文 的數據(變量、函數聲明和函數的形參)作為屬性存儲在變量對象中2012-04-04小程序自定義tabbar導航欄及動態(tài)控制tabbar功能實現方法(uniapp)
在項目中遇到一個需求,根據不同的賬號,生成不同的tabBar,下面這篇文章主要給大家介紹了關于小程序自定義tabbar導航欄及動態(tài)控制tabbar功能實現方法(uniapp)的相關資料,需要的朋友可以參考下2022-12-12