JavaScript 中的函數式編程:函數,組合和柯里化
面向對象編程和函數式編程是兩種非常不同的編程范式,它們有自己的規則和優缺點。
但是,JavaScript 并沒有一直遵循一個規則,而是正好處于這兩個規則的中間,它提供了普通OOP語言的一些方面,比如類、對象、繼承等等。但與此同時,它還為你提供了函數編程的一些概念,比如高階函數以及組合它們的能力。
高階函數
我們行人人三個概念中最重要的一個開始:高階函數。
高階函數意味著函數不僅僅是一個可以從代碼中定義和調用,實際上,你可以將它們用作可分配的實體。如果你使用過一些JavaScript,那么這并不奇怪。將匿名函數分配給常量,這樣的事情非常常見。
- const adder = (a, b) => {
- return a + b
- }
上述邏輯在許多其他語言中是無效的,能夠像分配整數一樣分配函數是一個非常有用的工具,實際上,本文涵蓋的大多數主題都是該函數的副產品。
高階函數的好處:封裝行為
有了高階函數,我們不僅可以像上面那樣分配函數,還可以在函數調用時將它們作為參數傳遞。這為創建一常動態的代碼基打開了大門,在這個代碼基礎上,可以直接將復雜行為作為參數傳遞來重用它。
想象一下,在純面向對象的環境中工作,你想擴展類的功能,以完成任務。在這種情況下,你可能會使用繼承,方法是將該實現邏輯封裝在一個抽象類中,然后將其擴展為一組實現類。這是一種完美的 OOP 行為,并且行之有效,我們:
- 創建了一個抽象結構來封裝我們的可重用邏輯
- 創建了二級構造
- 我們重用的原有的類,并擴展了它
現在,我們想要的是重用邏輯,我們可以簡單地將可重用邏輯提取到函數中,然后將該函數作為參數傳遞給任何其他函數,這種方法,可以少省去一些創建“樣板”過程,因為,我們只是在創建函數。
下面的代碼顯示了如何在 OOP 中重用程序邏輯。
- //Encapsulated behavior封裝行為stract class LogFormatter {
- format(msg) {
- return Date.now() + "::" + msg
- }
- }
- //重用行為
- class ConsoleLogger extends LogFormatter {
- log(msg) {
- console.log(this.format(msg))
- }
- }
- class FileLogger extends LogFormatter {
- log(msg) {
- writeToFileSync(this.logFile, this.format(msg))
- }
- }
第二個示是將邏輯提取到函數中,我們可以混合匹配輕松創建所需的內容。你可以繼續添加更多格式和編寫功能,然后只需將它們與一行代碼混合在一起即可:
- // 泛型行為抽象
- function format(msg) {
- return Date.now() + "::" + msg
- }
- function consoleWriter(msg) {
- console.log(msg)
- }
- function fileWriter(msg) {
- let logFile = "logfile.log"
- writeToFileSync(logFile, msg)
- }
- function logger(output, format) {
- return msg => {
- output(format(msg))
- }
- }
- // 通過組合函數來使用它
- const consoleLogger = logger(consoleWriter, format)
- const fileLogger = logger(fileWriter, format)
這兩種方法都有優點,而且都非常有效,沒有誰最優。這里只是展示這種方法的靈活性,我們有能力通過 行為(即函數)作為參數,就好像它們是基本類型(如整數或字符串)一樣。
高階函數的好處:簡潔代碼
對于這個好處,一個很好的例子就是Array方法,例如forEach,map,reduce等等。在非函數式編程語言(例如C)中,對數組元素進行迭代并對其進行轉換需要使用for循環或某些其他循環結構。這就要求我們以指定方式編寫代碼,就是需求描述循環發生的過程。
- let myArray = [1,2,3,4]
- let transformedArray = []
- for(let i = 0; i < myArray.length; i++) {
- transformedArray.push(myArray[i] * 2)
- }
上面的代碼主要做了:
- 聲明一個新變量i,該變量將用作myArray的索引,其值的范圍為0到myArray的長度
- 對于i的每個值,將myArray的值在i的位置相乘,并將其添加到transformedArray數組中。
這種方法很有效,而且相對容易理解,然而,這種邏輯的復雜性會隨著項目的復雜程度上升而上升,認知負荷也會隨之增加。但是,像下面這種方式就更容易閱讀:
- const double = x => x * 2;
- let myArray = [1,2,3,4];
- let transformedArray = myArray.map(double);
與第一種方式相比,這種方式更容易閱讀,而且由于邏輯隱藏在兩個函數(map和double)中,因此你不必擔心了解它們的工作原理。你也可以在第一個示例中將乘法邏輯隱藏在函數內部,但是遍歷邏輯必須存在,這就增加了一些不必要的閱讀阻礙。
柯里化
函數柯里化是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,并且返回接受余下的參數而且返回結果的新函數的技術。我們來看個例子:
- function adder(a, b) {
- return a + b
- }
- // 變成
- const add10 = x => adder(a, 10)
現在,如果你要做的就是將10添加到一系列值中,則可以調用add10而不是每次都使用相同的第二個參數調用adder。這個事例看起來比較蠢,但它是體現了 柯里化 的理想。
你可以將柯里化視為函數式編程的繼承,然后按照這種思路再回到logger的示例,可以得到以下內容:
- function log(msg, msgPrefix, output) {
- output(msgPrefix + msg)
- }
- function consoleOutput(msg) {
- console.log(msg)
- }
- function fileOutput(msg) {
- let filename = "mylogs.log"
- writeFileSync(msg, filename)
- }
- const logger = msg => log(msg, ">>", consoleOutput);
- const fileLogger = msg => log(msg, "::", fileOutput);
log的函數需要三個參數,而我們將其引入僅需要一個參數的專用版本中,因為其他兩個參數已由我們選擇。
注意,這里將log函數視為抽象類,只是因為在我的示例中,不想直接使用它,但是這樣做是沒有限制的,因為這只是一個普通的函數。如果我們使用的是類,則將無法直接實例化它。
組合函數
函數組合就是組合兩到多個函數來生成一個新函數的過程。將函數組合在一起,就像將一連串管道扣合在一起,讓數據流過一樣。
在計算機科學中,函數組合是將簡單函數組合成更復雜函數的一種行為或機制。就像數學中通常的函數組成一樣,每個函數的結果作為下一個函數的參數傳遞,而最后一個函數的結果是整個函數的結果。
這是來自維基百科的函數組合的定義,粗體部分是比較關鍵的部分。使用柯里化時,就沒有該限制,我們可以輕松使用預設的函數參數。
代碼重用聽起來很棒,但是實現起來很難。如果代碼業務性過于具體,就很難重用它。如時代碼太過通用簡單,又很少人使用。所以我們需要平衡兩者,一種制作更小的、可重用的部件的方法,我們可以將其作為構建塊來構建更復雜的功能。
在函數式編程中,函數是我們的構建塊。每個函數都有各自的功能,然后我們把需要的功能(函數)組合起來完成我們的需求,這種方式有點像樂高的積木,在編程中我們稱為 組合函數。
看下以下兩個函數:
- var add10 = function(value) {
- return value + 10;
- };
- var mult5 = function(value) {
- return value * 5;
- };
上面寫法有點冗長了,我們用箭頭函數改寫一下:
- var add10 = value => value + 10;
- var mult5 = value => value * 5;
現在我們需要有個函數將傳入的參數先加上 10 ,然后在乘以 5, 如下:
現在我們需要有個函數將傳入的參數先加上 10 ,然后在乘以 5, 如下:
- var mult5AfterAdd10 = value => 5 * (value + 10)
盡管這是一個非常簡單的例子,但仍然不想從頭編寫這個函數。首先,這里可能會犯一個錯誤,比如忘記括號。第二,我們已經有了一個加 10 的函數 add10 和一個乘以 5 的函數 mult5 ,所以這里我們就在寫已經重復的代碼了。
使用函數 add10,mult5 來重構 mult5AfterAdd10 :
- var mult5AfterAdd10 = value => mult5(add10(value));
我們只是使用現有的函數來創建 mult5AfterAdd10,但是還有更好的方法。
在數學中, f ° g 是函數組合,叫作“f 由 g 組合”,或者更常見的是 “f after g”。因此 (f ° g)(x) 等效于f(g(x)) 表示調用 g 之后調用 f。
在我們的例子中,我們有 mult5 ° add10 或 “add10 after mult5”,因此我們的函數的名稱叫做 mult5AfterAdd10。由于Javascript本身不做函數組合,看看 Elm 是怎么寫的:
- add10 value =
- value + 10
- mult5 value =
- value * 5
- mult5AfterAdd10 value =
- (mult5 << add10) value
在 Elm 中 << 表示使用組合函數,在上例中 value 傳給函數 *** add10 *** 然后將其結果傳遞給 mult5。還可以這樣組合任意多個函數:
- f x =
- (g << h << s << r << t) x
這里 x 傳遞給函數 t,函數 t 的結果傳遞給 r,函數 t 的結果傳遞給 s,以此類推。在Javascript中做類似的事情,它看起來會像 ***g(h(s(r(t(x)))))***,一個括號噩夢。
常見的函數式函數(Functional Function)
函數式語言中3個常見的函數:Map,Filter,Reduce。
如下JavaScript代碼:
- for (var i = 0; i < something.length; ++i) {
- // do stuff
- }
這段代碼存在一個很大的問題,但不是bug。問題在于它有很多重復代碼(boilerplate code)。如果你用命令式語言來編程,比如Java,C#,JavaScript,PHP,Python等等,你會發現這樣的代碼你寫地最多。這就是問題所在。
現在讓我們一步一步的解決問題,最后封裝成一個看不見 for 語法函數:
先用名為 things 的數組來修改上述代碼:
- var things = [1, 2, 3, 4];
- for (var i = 0; i < things.length; ++i) {
- things[i] = things[i] * 10; // 警告:值被改變!
- }
- console.log(things); // [10, 20, 30, 40]
這樣做法很不對,數值被改變了!
在重新修改一次:
- var things = [1, 2, 3, 4];
- var newThings = [];
- for (var i = 0; i < things.length; ++i) {
- newThings[i] = things[i] * 10;
- }
- console.log(newThings); // [10, 20, 30, 40]
這里沒有修改***things***數值,但卻卻修改了***newThings***。暫時先不管這個,畢竟我們現在用的是 JavaScript。一旦使用函數式語言,任何東西都是不可變的。
現在將代碼封裝成一個函數,我們將其命名為 map,因為這個函數的功能就是將一個數組的每個值映射(map)到新數組的一個新值。
- var map = (f, array) => {
- var newArray = [];
- for (var i = 0; i < array.length; ++i) {
- newArray[i] = f(array[i]);
- }
- return newArray;
- };
函數 f 作為參數傳入,那么函數 map 可以對 array 數組的每項進行任意的操作。
現在使用 map 重寫之前的代碼:
- var things = [1, 2, 3, 4];
- var newThings = map(v => v * 10, things);
這里沒有 for 循環!而且代碼更具可讀性,也更易分析。
現在讓我們寫另一個常見的函數來過濾數組中的元素:
- var filter = (pred, array) => {
- var newArray = [];
- for (var i = 0; i < array.length; ++i) {
- if (pred(array[i]))
- newArray[newArray.length] = array[i];
- }
- return newArray;
- };
當某些項需要被保留的時候,斷言函數 pred 返回TRUE,否則返回FALSE。
使用過濾器過濾奇數:
- var isOdd = x => x % 2 !== 0;
- var numbers = [1, 2, 3, 4, 5];
- var oddNumbers = filter(isOdd, numbers);
- console.log(oddNumbers); // [1, 3, 5]
比起用 for 循環的手動編程,filter 函數簡單多了。最后一個常見函數叫reduce。通常這個函數用來將一個數列歸約(reduce)成一個數值,但事實上它能做很多事情。
在函數式語言中,這個函數稱為 fold。
- var reduce = (f, start, array) => {
- var acc = start;
- for (var i = 0; i < array.length; ++i)
- acc = f(array[i], acc); // f() 有2個參數
- return acc;
- });
reduce函數接受一個歸約函數 f,一個初始值 start,以及一個數組 array。
這三個函數,map,filter,reduce能讓我們繞過for循環這種重復的方式,對數組做一些常見的操作。但在函數式語言中只有遞歸沒有循環,這三個函數就更有用了。附帶提一句,在函數式語言中,遞歸函數不僅非常有用,還必不可少。
作者:Fernando Doglio 譯者:前端小智 來源:medium
原文:https://blog.bitsrc.io/functional-programming-in-functions-composition-and-currying-3c765a50152e
本文轉載自微信公眾號「 大遷世界」,可以通過以下二維碼關注。轉載本文請聯系 大遷世界公眾號。