摘要:組合的概念是非常直觀的,并不是函數式編程獨有的,在我們生活中或者前端開發中處處可見。其實我們函數式編程里面的組合也是類似,函數組合就是一種將已被分解的簡單任務組織成復雜的整體過程。在函數式編程的世界中,有這樣一種很流行的編程風格。
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 = 組合(stringReverse, 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.01 的折扣。
// 這個就是一個柯里化函數,將本來兩個參數的 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 上找到。
如果有興趣,也可以關注我的個人公眾號:「前端桃園」
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/101953.html
摘要:簡單來說高階組件就是一個函數,它接受一個組件作為參數然后返回一個新組件。主要用于組件之間邏輯復用。使用由于數據請求是異步的,為了不讓用戶看到一片空白,當數據請求還沒有返回時,展示組件。組合函數,提升代碼可閱讀性。 簡單來說高階組件(HOC)就是一個函數,它接受一個組件作為參數然后返回一個新組件。HOC 主要用于組件之間邏輯復用。比如你寫了幾個組件,他們之間的邏輯幾乎相同,就可以用 HOC 對...
摘要:今天這篇文章主要介紹函數式編程的思想。函數式編程通過最小化變化使得代碼更易理解。在函數式編程里面,組合是一個非常非常非常重要的思想。可以看到函數式編程在開發中具有聲明模式。而函數式編程旨在盡可能的提高代碼的無狀態性和不變性。 最開始接觸函數式編程的時候是在小米工作的時候,那個時候看老大以前寫的代碼各種 compose,然后一些 ramda 的一些工具函數,看著很吃力,然后極力吐槽函數式...
摘要:函數式編程逐漸被邊緣化,被拋棄到學術界和非主流的場外。組合式編程的重新崛起年左右,有個巨大的變化爆發了。人們開始逐漸在私下里談論函數式編程。箭頭函數對于函數式編程的爆發起到了推動劑的作用?,F在很少看到那種不用函數式編程的大型應用了。 showImg(https://segmentfault.com/img/remote/1460000009036867?w=800&h=364); 本...
摘要:函數式編程,一看這個詞,簡直就是學院派的典范。所以這期周刊,我們就重點引入的函數式編程,淺入淺出,一窺函數式編程的思想,可能讓你對編程語言的理解更加融會貫通一些。但從根本上來說,函數式編程就是關于如使用通用的可復用函數進行組合編程。 showImg(https://segmentfault.com/img/bVGQuc); 函數式編程(Functional Programming),一...
閱讀 1012·2021-11-23 10:11
閱讀 3854·2021-11-16 11:50
閱讀 921·2021-10-14 09:43
閱讀 2713·2021-10-14 09:42
閱讀 2710·2021-09-22 16:02
閱讀 1056·2019-08-29 10:57
閱讀 3378·2019-08-29 10:57
閱讀 2268·2019-08-26 13:52