最開始接觸函數語言程式設計的時候是在小米工作的時候,那個時候看老大以前寫的程式碼各種 compose,然後一些 ramda 的一些工具函式,看著很吃力,然後極力吐槽函數語言程式設計,現在回想起來,那個時候的自己真的是見識短淺,只想說,`真香`。
最近在研究函數語言程式設計,真的是在學習的過程中感覺自己的思維提升了很多,抽象能力大大的提高了,讓我深深的感受到了函數語言程式設計的魅力。所以我打算後面用 5 到 8 篇的篇幅,詳細的介紹一下函數語言程式設計的思想,基礎、如何設計、測試等。
今天這篇文章主要介紹函數語言程式設計的思想。
- 函數語言程式設計有用嗎?
- 什麼是函數語言程式設計?
- 函數語言程式設計的優點。
物件導向程式設計(OOP)通過封裝變化使得程式碼更易理解。
函數語言程式設計(FP)通過最小化變化使得程式碼更易理解。
— Michacel Feathers(Twitter)
總所周知 JavaScript 是一種擁有很多共享狀態的動態語言,慢慢的,程式碼就會積累足夠的複雜性,變得笨拙難以維護。物件導向設計能幫我們在一定程度上解決這個問題,但是還不夠。
由於有很多的狀態,所以處理資料流和變化的傳遞顯得尤為重要,不知道你們知道響應式程式設計與否,這種程式設計正規化有助於處理 JavaScript 的非同步或者事件響應。總之,當我們在設計應用程式的時候,我們應該考慮是否遵守了以下的設計原則。
- 可擴充套件性–我是否需要不斷地重構程式碼來支援額外的功能?
- 易模組化–如果我更改了一個檔案,另一個檔案是否會受到影響?
- 可重用性–是否有很多重複的程式碼?
- 可測性–給這些函式新增單元測試是否讓我糾結?
- 易推理性–我寫的程式碼是否非結構化嚴重並難以推理?
我這能這麼跟你說,一旦你學會了函數語言程式設計,這些問題迎刃而解,本來函數語言程式設計就是這個思想,一旦你掌握了函式式,然後你再學習響應式程式設計那就比較容易懂了,這是我親身體會的。我之前在學 Rxjs 的時候是真的痛苦,說實話,Rxjs 是我學過最難的庫了,沒有之一。在經歷過痛苦的一兩個月之後,有些東西還是不能融會貫通,知道我最近研究函數語言程式設計,才覺得是理所當然。毫無誇張,我也儘量在後面的文章中給大家介紹一下 Rxjs,這個話題我也在公司分享過。
什麼是函數語言程式設計?
簡單來說,函數語言程式設計是一種強調以函式使用為主的軟體開發風格。看到這句我想你還是一臉懵逼,不知道函數語言程式設計是啥,不要著急,看到最後我相信你會明白的。
還有一點你要記住,函數語言程式設計的目的是使用函式來抽象作用在資料之上的控制流和操作,從而在系統中消除副作用並減少對狀態的改變。
下面我們通過例子來簡單的演示一下函數語言程式設計的魅力。
現在的需求就是輸出在網頁上輸出 “Hello World”
。
可能初學者會這麼寫。
document.querySelector(`#msg`).innerHTML = `<h1>Hello World</h1>`
複製程式碼
這個程式很簡單,但是所有程式碼都是死的,不能重用,如果想改變訊息的格式、內容等就需要重寫整個表示式,所以可能有經驗的前端開發者會這麼寫。
function printMessage(elementId, format, message) {
document.querySelector(elementId).innerHTML = `<${format}>${message}</${format}>`
}
printMessage(`msg`, `h1`, `Hello World`)
複製程式碼
這樣確實有所改進,但是任然不是一段可重用的程式碼,如果是要將文字寫入檔案,不是非 HTML,或者我想重複的顯示 Hello World
。
那麼作為一個函式式開發者會怎麼寫這段程式碼呢?
const printMessage = compose(addToDom(`msg`), h1, echo)
printMessage(`Hello World`)
複製程式碼
解釋一下這段程式碼,其中的 h1
和 echo
都是函式,addToDom
很明顯也能看出它是函式,那麼我們為什麼要寫成這樣呢?看起來多了很多函式一樣。
其實我們是講程式分解為一些更可重用、更可靠且更易於理解的部分,然後再將他們組合起來,形成一個更易推理的程式整體,這是我們前面談到的基本原則。
compose 簡單解釋一下,他會讓函式從最後一個引數順序執行到第一個引數,compose 的每個引數都是函式,不明白的可以查一下,在 redux 的中介軟體部分這個函式式精華。
可以看到我們是將一個任務拆分成多個最小顆粒的函式,然後通過組合的方式來完成我們的任務,這跟我們元件化的思想很類似,將整個頁面拆分成若干個元件,然後拼裝起來完成我們的整個頁面。在函數語言程式設計裡面,組合是一個非常非常非常重要的思想。
好,我們現在再改變一下需求,現在我們需要將文字重複三遍,列印到控制檯。
var printMessaage = compose(console.log, repeat(3), echo)
printMessage(‘Hello World’)
複製程式碼
可以看到我們更改了需求並沒有去修改內部邏輯,只是重組了一下函式而已。
可以看到函數語言程式設計在開發中具有宣告模式。為了充分理解函數語言程式設計,我們先來看下幾個基本概念。
- 宣告式程式設計
- 純函式
- 引用透明
- 不可變性
宣告式程式設計
函數語言程式設計屬於宣告是程式設計正規化:這種正規化會描述一系列的操作,但並不會暴露它們是如何實現的或是資料流如何傳過它們。
我們所熟知的 SQL 語句就是一種很典型的宣告式程式設計,它由一個個描述查詢結果應該是什麼樣的斷言組成,對資料檢索的內部機制進行了抽象。
我們再來看一組程式碼再來對比一下指令式程式設計和宣告式程式設計。
// 命令式方式
var array = [0, 1, 2, 3]
for(let i = 0; i < array.length; i++) {
array[i] = Math.pow(array[i], 2)
}
array; // [0, 1, 4, 9]
// 宣告式方式
[0, 1, 2, 3].map(num => Math.pow(num, 2))
複製程式碼
可以看到命令式很具體的告訴計算機如何執行某個任務。
而宣告式是將程式的描述與求值分離開來。它關注如何用各種表示式來描述程式邏輯,而不一定要指明其控制流或狀態關係的變化。
為什麼我們要去掉程式碼迴圈呢?迴圈是一種重要的命令控制結構,但很難重用,並且很難插入其他操作中。而函數語言程式設計旨在儘可能的提高程式碼的無狀態性和不變性。要做到這一點,就要學會使用無副作用的函式–也稱純函式
純函式
純函式指沒有副作用的函式。相同的輸入有相同的輸出,就跟我們上學的函式一樣。
常常這些情況會產生副作用。
- 改變一個全域性的變數、屬性或資料結構
- 改變一個函式引數的原始值
- 處理使用者輸入
- 丟擲一個異常
- 螢幕列印或記錄日誌
- 查詢 HTML 文件,瀏覽器的 Cookie 或訪問資料庫
舉一個簡單的例子
var counter = 0
function increment() {
return ++counter;
}
複製程式碼
這個函式就是不純的,它讀取了外部的變數,可能會覺得這段程式碼沒有什麼問題,但是我們要知道這種依賴外部變數來進行的計算,計算結果很難預測,你也有可能在其他地方修改了 counter 的值,導致你 increment 出來的值不是你預期的。
對於純函式有以下性質:
- 僅取決於提供的輸入,而不依賴於任何在函式求值或呼叫間隔時可能變化的隱藏狀態和外部狀態。
- 不會造成超出作用域的變化,例如修改全域性變數或引用傳遞的引數。
但是在我們平時的開發中,有一些副作用是難以避免的,與外部的儲存系統或 DOM 互動等,但是我們可以通過將其從主邏輯中分離出來,使他們易於管理。
現在我們有一個小需求:通過 id 找到學生的記錄並渲染在瀏覽器(在寫程式的時候要想到可能也會寫到控制檯,資料庫或者檔案,所以要想如何讓自己的程式碼能重用)中。
// 命令式程式碼
function showStudent(id) {
// 這裡假如是同步查詢
var student = db.get(id)
if(student !== null) {
// 讀取外部的 elementId
document.querySelector(`${elementId}`).innerHTML = `${student.id},${student.name},${student.lastname}`
} else {
throw new Error(`not found`)
}
}
showStudent(`666`)
// 函式式程式碼
// 通過 find 函式找到學生
var find = curry(function(db, id) {
var obj = db.get(id)
if(obj === null) {
throw new Error(`not fount`)
}
return obj
})
// 將學生物件 format
var csv = (student) => `${student.id},${student.name},${student.lastname}`
// 在螢幕上顯示
var append = curry(function(elementId, info) {
document.querySelector(elementId).innerHTML = info
})
var showStudent = compose(append(`#student-info`), csv, find(db))
showStudent(`666`)
複製程式碼
如果看不懂 curry (柯里化)的先不著急,這是一個對於新手來說比較難理解的一個概念,在函數語言程式設計裡面起著至關重要的作用。
可以看到函式式程式碼通過較少這些函式的長度,將 showStudent 編寫為小函式的組合。這個程式還不夠完美,但是已經可以展現出相比於命令式的很多優勢了。
- 靈活。有三個可重用的元件
- 宣告式的風格,給高階步驟提供了一個清晰檢視,增強了程式碼的可讀性
- 另外是將純函式與不純的行為分離出來。
我們看到純函式的輸出結果是一致的,可預測的,相同的輸入會有相同的返回值,這個其實也被稱為引用透明。
引用透明
引用透明是定義一個純函式較為正確的方法。純度在這個意義上表面一個函式的引數和返回值之間對映的純的關係。如果一個函式對於相同的輸入始終產生相同的結果,那麼我們就說它是引用透明。
這個概念很容易理解,簡單的舉兩個例子就行了。
// 非引用透明
var counter = 0
function increment() {
return ++counter
}
// 引用透明
var increment = (counter) => counter + 1
複製程式碼
其實對於箭頭函式在函數語言程式設計裡面有一個高大上的名字,叫 lambda 表示式,對於這種匿名函式在學術上就是叫 lambda 表示式,現在在 Java 裡面也是支援的。
不可變資料
不可變資料是指那些建立後不能更改的資料。與許多其他語言一樣,JavaScript 裡有一些基本型別(String,Number 等)從本質上是不可變的,但是物件就是在任意的地方可變。
考慮一個簡單的陣列排序程式碼:
var sortDesc = function(arr) {
return arr.sort(function(a, b) {
return a - b
})
}
var arr = [1, 3, 2]
sortDesc(arr) // [1, 2, 3]
arr // [1, 2, 3]
複製程式碼
這段程式碼看似沒什麼問題,但是會導致在排序的過程中會產生副作用,修改了原始引用,可以看到原始的 arr 變成了 [1, 2, 3]
。這是一個語言缺陷,後面會介紹如何克服。
總結
- 使用純函式的程式碼絕不會更改或破壞全域性狀態,有助於提高程式碼的可測試性和可維護性
- 函數語言程式設計採用宣告式的風格,易於推理,提高程式碼的可讀性。
- 函數語言程式設計將函式視為積木,通過一等高階函式來提高程式碼的模組化和可重用性。
- 可以利用響應式程式設計組合各個函式來降低事件驅動程式的複雜性(這點後面可能會單獨拿一篇來進行講解)。
內容來至於《JavaScript函數語言程式設計指南》
歡迎關注個人公眾號【前端桃園】,公號更新頻率比掘金快。