JavaScript函數語言程式設計,真香之認識函數語言程式設計(一)
該系列文章不是針對前端新手,需要有一定的程式設計經驗,而且瞭解 JavaScript 裡面作用域,閉包等概念
組合函式
組合是一種為軟體的行為,進行清晰建模的一種簡單、優雅而富於表現力的方式。通過組合小的、確定性的函式,來建立更大的軟體元件和功能的過程,會生成更容易組織、理解、除錯、擴充套件、測試和維護的軟體。
對於組合,我覺得是函數語言程式設計裡面最精髓的地方之一,所以我迫不及待的把這個概念拿出來先介紹,因為在整個學習函數語言程式設計裡,所遇到的基本上都是以組合的方式來編寫程式碼,這也是改變你從一個物件導向,或者結構化程式設計思想的一個關鍵點。
我這裡也不去證明組合比繼承好,也不說組合的方式寫程式碼有多好,我希望你看了這篇文章能知道以組合的方式去抽象程式碼,這會擴充套件你的視野,在你想重構你的程式碼,或者想寫出更易於維護的程式碼的時候,提供一種思路。
組合的概念是非常直觀的,並不是函數語言程式設計獨有的,在我們生活中或者前端開發中處處可見。
比如我們現在流行的 SPA (單頁面應用),都會有元件的概念,為什麼要有元件的概念呢,因為它的目的就是想讓你把一些通用的功能或者元素組合抽象成可重用的元件,就算不通用,你在構建一個複雜頁面的時候也可以拆分成一個個具有簡單功能的元件,然後再組合成你滿足各種需求的頁面。
其實我們函數語言程式設計裡面的組合也是類似,函式組合就是一種將已被分解的簡單任務組織成複雜的整體過程。
現在我們有這樣一個需求:給你一個字串,將這個字串轉化成大寫,然後逆序。
你可能會這麼寫。
// 例 1.1
var str = 'function program'
// 一行程式碼搞定
function oneLine(str) {
var res = str.toUpperCase().split('').reverse().join('')
return res;
}
// 或者 按要求一步一步來,先轉成大寫,然後逆序
function multiLine(str) {
var upperStr = str.toUpperCase()
var res = upperStr.split('').reverse().join('')
return res;
}
console.log(oneLine(str)) // MARGORP NOITCNUF
console.log(multiLine(str)) // MARGORP NOITCNUF
複製程式碼
可能看到這裡你並沒有覺得有什麼不對的,但是現在產品又突發奇想,改了下需求,把字串大寫之後,把每個字元拆開之後組裝成一個陣列,比如 ’aaa‘ 最終會變成 [A, A, A]。
那麼這個時候我們就需要更改我們之前我們封裝的函式。這就修改了以前封裝的程式碼,其實在設計模式裡面就是破壞了開閉原則。
那麼我們如果把最開始的需求程式碼寫成這個樣子,以函數語言程式設計的方式來寫。
// 例 1.2
var str = 'function program'
function stringToUpper(str) {
return str.toUpperCase()
}
function stringReverse(str) {
return str.split('').reverse().join('')
}
var toUpperAndReverse = 組合(stringReverse, stringToUpper)
var res = toUpperAndReverse(str)
複製程式碼
那麼當我們需求變化的時候,我們根本不需要修改之前封裝過的東西。
// 例 2
var str = 'function program'
function stringToUpper(str) {
return str.toUpperCase()
}
function stringReverse(str) {
return str.split('').reverse().join('')
}
// var toUpperAndReverse = 組合(stringReverse, stringToUpper)
// var res = toUpperAndReverse(str)
function stringToArray(str) {
return str.split('')
}
var toUpperAndArray = 組合(stringToArray, stringToUpper)
toUpperAndArray(str)
複製程式碼
可以看到當變更需求的時候,我們沒有打破以前封裝的程式碼,只是新增了函式功能,然後把函式進行重新組合。
這裡可能會有人說,需求修改,肯定要更改程式碼呀,你這不是也刪除了以前的程式碼麼,也不是算破壞了開閉原則麼。我這裡宣告一下,開閉原則是指一個軟體實體如類、模組和函式應該對擴充套件開放,對修改關閉。是針對我們封裝,抽象出來的程式碼,而不是呼叫的邏輯程式碼。所以這樣寫並不算破壞開閉原則。
突然產品又靈光一閃,又想改一下需求,把字串大寫之後,再翻轉,再轉成陣列。
要是你按照以前的思考,沒有進行抽象,你肯定心理一萬隻草泥馬在奔騰,但是如果你抽象了,你完全可以不慌。
// 例 3
var str = 'function program'
function stringToUpper(str) {
return str.toUpperCase()
}
function stringReverse(str) {
return str.split('').reverse().join('')
}
function stringToArray(str) {
return str.split('')
}
var strUpperAndReverseAndArray = 組合(stringToArray, stringReverse, stringToUpper)
strUpperAndReverseAndArray(str)
複製程式碼
發現並沒有更換你之前封裝的程式碼,只是更換了函式的組合方式。可以看到,組合的方式是真的就是抽象單一功能的函式,然後再組成複雜功能。這種方式既鍛鍊了你的抽象能力,也給維護帶來巨大的方便。
但是上面的組合我只是用漢字來代替的,我們應該如何去實現這個組合呢。首先我們可以知道,這是一個函式,同時引數也是函式,返回值也是函式。
我們看到例 2, 怎麼將兩個函式進行組合呢,根據上面說的,引數和返回值都是函式,那麼我們可以確定函式的基本結構如下(順便把組合換成英文的 compose)。
function twoFuntionCompose(fn1, fn2) {
return function() {
// code
}
}
複製程式碼
我們再思考一下,如果我們不用 compose 這個函式,在例 2 中怎麼將兩個函式合成呢,我們是不是也可以這麼做來達到組合的目的。
var res = stringReverse(stringToUpper(str))
複製程式碼
那麼按照這個邏輯是不是我們就可以寫出 twoFuntonCompose
的實現了,就是
function twoFuntonCompose(fn1, fn2) {
return function(arg) {
return fn1(fn2(arg))
}
}
複製程式碼
同理我們也可以寫出三個函式的組合函式,四個函式的組合函式,無非就是一直巢狀多層嘛,變成:
function multiFuntionCompose(fn1, fn2, .., fnn) {
return function(arg) {
return fnn(...(fn1(fn2(arg))))
}
}
複製程式碼
這種噁心的方式很顯然不是我們程式設計師應該做的,然後我們也可以看到一些規律,無非就是把前一個函式的返回值作為後一個返回值的引數,當直接到最後一個函式的時候,就返回。
所以按照正常的思維就會這麼寫。
function aCompose(...args) {
let length = args.length
let count = length - 1
let result
return function f1 (...arg1) {
result = args[count].apply(this, arg1)
if (count <= 0) {
count = length - 1
return result
}
count--
return f1.call(null, result)
}
}
複製程式碼
這樣寫沒問題,underscore 也是這麼寫的,不過裡面還有很多健壯性的處理,核心大概就是這樣。
但是作為一個函式式愛好者,儘量還是以函式式的方式去思考,所以就用 reduceRight 寫出如下程式碼。
function compose(...args) {
return (result) => {
return args.reduceRight((result, fn) => {
return fn(result)
}, result)
}
}
複製程式碼
當然對於 compose 的實現還有很多種方式,在這篇實現 compose 的五種思路中還給出了另外腦洞大開的實現方式,在我看這篇文章之前,另外三種我是沒想到的,不過感覺也不是太有用,但是可以擴充套件我們的思路,有興趣的同學可以看一看。
注意:要傳給 compose 函式是有規範的,首先函式的執行是從最後一個引數開始執行,一直執行到第一個,而且對於傳給 compose 作為引數的函式也是有要求的,必須只有一個形參,而且函式的返回值是下一個函式的實參。
對於 compose 從最後一個函式開始求值的方式如果你不是很適應的話,你可以通過 pipe 函式來從左到右的方式。
function pipe(...args) {
return (result) => {
return args.reduce((result, fn) => {
return fn(result)
}, result)
}
}
複製程式碼
實現跟 compose 差不多,只是把引數的遍歷方式從右到左(reduceRight)改為從左到右(reduce)。
之前是不是看過很多文章寫過如何實現 compose,或者柯里化,部分應用等函式,但是你可能不知道是用來幹啥的,也沒用過,所以記了又忘,忘了又記,看了這篇文章之後我希望這些你都可以輕鬆實現。後面會繼續講到柯里化和部分應用的實現。
point-free
在函數語言程式設計的世界中,有這樣一種很流行的程式設計風格。這種風格被稱為 tacit programming,也被稱作為 point-free,point 表示的就是形參,意思大概就是沒有形參的程式設計風格。
// 這就是有參的,因為 word 這個形參
var snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_');
// 這是 pointfree,沒有任何形參
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
複製程式碼
有參的函式的目的是得到一個資料,而 pointfree 的函式的目的是得到另一個函式。
那這 pointfree 有什麼用? 它可以讓我們把注意力集中在函式上,引數命名的麻煩肯定是省了,程式碼也更簡潔優雅。 需要注意的是,一個 pointfree 的函式可能是由眾多非 pointfree 的函式組成的,也就是說底層的基礎函式大都是有參的,pointfree 體現在用基礎函式組合而成的高階函式上,這些高階函式往往可以作為我們的業務函式,通過組合不同的基礎函式構成我們的複製的業務邏輯。
可以說 pointfree 使我們的程式設計看起來更美,更具有宣告式,這種風格算是函數語言程式設計裡面的一種追求,一種標準,我們可以儘量的寫成 pointfree,但是不要過度的使用,任何模式的過度使用都是不對的。
另外可以看到通過 compose 組合而成的基礎函式都是隻有一個引數的,但是往往我們的基礎函式引數很可能不止一個,這個時候就會用到一個神奇的函式(柯里化函式)。
柯里化
在維基百科裡面是這麼定義柯里化的:
在電腦科學,柯里化(英語:Currying),又譯為卡瑞化或加里化,是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。
在定義中獲取兩個比較重要的資訊:
- 接受一個單一引數
- 返回結果是函式
這兩個要點不是 compose 函式引數的要求麼,而且可以將多個引數的函式轉換成接受單一引數的函式,豈不是可以解決我們再上面提到的基礎函式如果是多個引數不能用的問題,所以這就很清楚了柯里化函式的作用了。
柯里化函式可以使我們更好的去追求 pointfree,讓我們程式碼寫得更優美!
接下來我們具體看一個例子來理解柯里化吧:
比如你有一間士多店並且你想給你優惠的顧客給個 10% 的折扣(即打九折):
function discount(price, discount) {
return price * discount
}
複製程式碼
當一位優惠的顧客買了一間價值$500的物品,你給他打折:
const price = discount(500, 0.10); // $50
複製程式碼
你可以預見,從長遠來看,我們會發現自己每天都在計算 10% 的折扣:
const price = discount(1500,0.10); // $150
const price = discount(2000,0.10); // $200
// ... 等等很多
複製程式碼
我們可以將 discount 函式柯里化,這樣我們就不用總是每次增加這 0.10 的折扣。
// 這個就是一個柯里化函式,將本來兩個引數的 discount ,轉化為每次接收單個引數完成求職
function discountCurry(discount) {
return (price) => {
return price * discount;
}
}
const tenPercentDiscount = discountCurry(0.1);
複製程式碼
現在,我們可以只計算你的顧客買的物品都價格了:
tenPercentDiscount(500); // $50
複製程式碼
同樣地,有些優惠顧客比一些優惠顧客更重要-讓我們稱之為超級客戶。並且我們想給這些超級客戶提供 20% 的折扣。 可以使用我們的柯里化的discount函式:
const twentyPercentDiscount = discountCurry(0.2);
複製程式碼
我們通過這個柯里化的 discount 函式折扣調為 0.2(即20%),給我們的超級客戶配置了一個新的函式。 返回的函式 twentyPercentDiscount 將用於計算我們的超級客戶的折扣:
twentyPercentDiscount(500); // 100
複製程式碼
我相信通過上面的 **discountCurry **你已經對柯里化有點感覺了,這篇文章是談的柯里化在函數語言程式設計裡面的應用,所以我們再來看看在函式式裡面怎麼應用。
現在我們有這麼一個需求:給定的一個字串,先翻轉,然後轉大寫,找是否有TAOWENG
,如果有那麼就輸出 yes,否則就輸出 no。
function stringToUpper(str) {
return str.toUpperCase()
}
function stringReverse(str) {
return str.split('').reverse().join('')
}
function find(str, targetStr) {
return str.includes(targetStr)
}
function judge(is) {
console.log(is ? 'yes' : 'no')
}
複製程式碼
我們很容易就寫出了這四個函式,前面兩個是上面就已經寫過的,然後 find 函式也很簡單,現在我們想通過 compose 的方式來實現 pointfree,但是我們的 find 函式要接受兩個引數,不符合 compose 引數的規定,這個時候我們像前面一個例子一樣,把 find 函式柯里化一下,然後再進行組合:
// 柯里化 find 函式
function findCurry(targetStr) {
return str => str.includes(targetStr)
}
const findTaoweng = findCurry('TAOWENG')
const result = compose(judge, findTaoweng, stringReverse, stringToUpper)
複製程式碼
看到這裡是不是可以看到柯里化在達到 pointfree 是非常的有用,較少引數,一步一步的實現我們的組合。
但是通過上面那種方式柯里化需要去修改以前封裝好的函式,這也是破壞了開閉原則,而且對於一些基礎函式去把原始碼修改了,其他地方用了可能就會有問題,所以我們應該寫一個函式來手動柯里化。
根據定義之前對柯里化的定義,以及前面兩個柯里化函式,我們可以寫一個二元(引數個數為 2)的通用柯里化函式:
function twoCurry(fn) {
return function(firstArg) { // 第一次呼叫獲得第一個引數
return function(secondArg) { // 第二次呼叫獲得第二個引數
return fn(firstArg, secondArg) // 將兩個引數應用到函式 fn 上
}
}
}
複製程式碼
所以上面的 findCurry 就可以通過 twoCurry 來得到:
const findCurry = twoCurry(find)
複製程式碼
這樣我們就可以不更改封裝好的函式,也可以使用柯里化,然後進行函式組合。不過我們這裡只實現了二元函式的柯里化,要是三元,四元是不是我們又要要寫三元柯里化函式,四元柯里化函式呢,其實我們可以寫一個通用的 n 元柯里化。
function currying(fn, ...args) {
if (args.length >= fn.length) {
return fn(...args)
}
return function (...args2) {
return currying(fn, ...args, ...args2)
}
}
複製程式碼
我這裡採用的是遞迴的思路,當獲取的引數個數大於或者等於 fn 的引數個數的時候,就證明引數已經獲取完畢,所以直接執行 fn 了,如果沒有獲取完,就繼續遞迴獲取引數。
可以看到其實一個通用的柯里化函式核心思想是非常的簡單,程式碼也非常簡潔,而且還支援在一次呼叫的時候可以傳多個引數(但是這種傳遞多個引數跟柯里化的定義不是很合,所以可以作為一種柯里化的變種)。
我這裡重點不是講柯里化的實現,所以沒有寫得很健壯,更強大的柯里化函式可見羽訝的:JavaScript專題之函式柯里化。
部分應用
部分應用是一種通過將函式的不可變引數子集,初始化為固定值來建立更小元數函式的操作。簡單來說,如果存在一個具有五個引數的函式,給出三個引數後,就會得到一個、兩個引數的函式。
看到上面的定義可能你會覺得這跟柯里化很相似,都是用來縮短函式引數的長度,所以如果理解了柯里化,理解部分應用是非常的簡單:
function debug(type, firstArg, secondArg) {
if(type === 'log') {
console.log(firstArg, secondArg)
} else if(type === 'info') {
console.info(firstArg, secondArg)
} else if(type === 'warn') {
console.warn(firstArg, secondArg)
} else {
console.error(firstArg, secondArg)
}
}
const logDebug = 部分應用(debug, 'log')
const infoDebug = 部分應用(debug, 'info')
const warnDebug = 部分應用(debug, 'warn')
const errDebug = 部分應用(debug, 'error')
logDebug('log:', '測試部分應用')
infoDebug('info:', '測試部分應用')
warnDebug('warn:', '測試部分應用')
errDebug('error:', '測試部分應用')
複製程式碼
debug
方法封裝了我們平時用 console 物件除錯的時候各種方法,本來是要傳三個引數,我們通過部分應用的封裝之後,我們只需要根據需要呼叫不同的方法,傳必須的引數就可以了。
我這個例子可能你會覺得沒必要這麼封裝,根本沒有減少什麼工作量,但是如果我們在 debug 的時候不僅是要列印到控制檯,還要把除錯資訊儲存到資料庫,或者做點其他的,那是不是這個封裝就有用了。
因為部分應用也可以減少引數,所以他在我們進行編寫組合函式的時候也佔有一席之地,而且可以更快傳遞需要的引數,留下為了 compose 傳遞的引數,這裡是跟柯里化比較,因為柯里化按照定義的話,一次函式呼叫只能傳一個引數,如果有四五個引數就需要:
function add(a, b, c, d) {
return a + b + c +d
}
// 使用柯里化方式來使 add 轉化為一個一元函式
let addPreThreeCurry = currying(add)(1)(2)(3)
addPreThree(4) // 10
複製程式碼
這種連續呼叫(這裡所說的柯里化是按照定義的柯里化,而不是我們寫的柯里化變種),但是用部分應用就可以:
// 使用部分應用的方式使 add 轉化為一個一元函式
const addPreThreePartial = 部分應用(add, 1, 2, 3)
addPreThree(4) // 10
複製程式碼
既然我們現在已經明白了部分應用這個函式的作用了,那麼還是來實現一個吧,真的是非常的簡單:
// 通用的部分應用函式的核心實現
function partial(fn, ...args) {
return (..._arg) => {
return fn(...args, ..._arg);
}
}
複製程式碼
另外不知道你有沒有發現,這個部分應用跟 JavaScript 裡面的 bind 函式很相似,都是把第一次穿進去的引數通過閉包存在函式裡,等到再次呼叫的時候再把另外的引數傳給函式,只是部分應用不用指定 this,所以也可以用 bind 來實現一個部分應用函式。
// 通用的部分應用函式的核心實現
function partial(fn, ...args) {
return fn.bind(null, ...args)
}
複製程式碼
另外可以看到實際上柯里化和部分應用確實很相似,所以這兩種技術很容易被混淆。它們主要的區別在於引數傳遞的內部機制與控制:
- 柯里化在每次分佈呼叫時都會生成巢狀的一元函式。在底層 ,函式的最終結果是由這些一元函式逐步組合產生的。同時,curry 的變體允許同時傳遞一部分引數。因此,可以完全控制函式求值的時間與方式。
- 部分應用將函式的引數與一些預設值繫結(賦值),從而產生一個擁有更少引數的新函式。改函式的閉包中包含了這些已賦值的引數,在之後的呼叫中被完全求值。
總結
在這篇文章裡我重點想介紹的是函式以組合的方式來完成我們的需求,另外介紹了一種函數語言程式設計風格:pointfree,讓我們在函數語言程式設計裡面有了一個最佳實踐,儘量寫成 pointfree 形式(儘量,不是都要),然後介紹了通過柯里化或者部分應用來減少函式引數,符合 compose 或者 pipe 的引數要求。
所以這種文章的重點是理解我們如何去組合函式,如何去抽象複雜的函式為顆粒度更小,功能單一的函式。這將使我們的程式碼更容易維護,更具宣告式的特點。
對於這篇文章裡面提到的其他概念:閉包、作用域,然後柯里化的其他用途我希望是在番外篇裡面更深入的去理解,而這篇文章主要掌握函式組合就行了。
參考文章
- JavaScript函數語言程式設計之pointfree與宣告式程式設計
- Understanding Currying in JavaScript
- 《JavaScript 函數語言程式設計指南》
文章首發於自己的個人網站桃園,另外也可以在 github blog 上找到。
如果有興趣,也可以關注我的個人公眾號:「前端桃園」