JavaScript函數式編程,真香之組合函數(二)

JavaScript函數式編程,真香之認識函數式編程(一)javascript

該系列文章不是針對前端新手,須要有必定的編程經驗,並且瞭解 JavaScript 裏面做用域,閉包等概念php

組合函數

組合是一種爲軟件的行爲,進行清晰建模的一種簡單、優雅而富於表現力的方式。經過組合小的、肯定性的函數,來建立更大的軟件組件和功能的過程,會生成更容易組織、理解、調試、擴展、測試和維護的軟件。前端

對於組合,我以爲是函數式編程裏面最精髓的地方之一,因此我火燒眉毛的把這個概念拿出來先介紹,由於在整個學習函數式編程裏,所遇到的基本上都是以組合的方式來編寫代碼,這也是改變你從一個面向對象,或者結構化編程思想的一個關鍵點。java

我這裏也不去證實組合比繼承好,也不說組合的方式寫代碼有多好,我但願你看了這篇文章能知道以組合的方式去抽象代碼,這會擴展你的視野,在你想重構你的代碼,或者想寫出更易於維護的代碼的時候,提供一種思路。git

組合的概念是很是直觀的,並非函數式編程獨有的,在咱們生活中或者前端開發中到處可見。程序員

好比咱們如今流行的 SPA (單頁面應用),都會有組件的概念,爲何要有組件的概念呢,由於它的目的就是想讓你把一些通用的功能或者元素組合抽象成可重用的組件,就算不通用,你在構建一個複雜頁面的時候也能夠拆分紅一個個具備簡單功能的組件,而後再組合成你知足各類需求的頁面。github

其實咱們函數式編程裏面的組合也是相似,函數組合就是一種將已被分解的簡單任務組織成複雜的總體過程數據庫

如今咱們有這樣一個需求:給你一個字符串,將這個字符串轉化成大寫,而後逆序。編程

你可能會這麼寫。segmentfault

// 例 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 的參數要求。

因此這種文章的重點是理解咱們如何去組合函數,如何去抽象複雜的函數爲顆粒度更小,功能單一的函數。這將使咱們的代碼更容易維護,更具聲明式的特色。

對於這篇文章裏面提到的其餘概念:閉包、做用域,而後柯里化的其餘用途我但願是在番外篇裏面更深刻的去理解,而這篇文章主要掌握函數組合就好了。

參考文章

文章首發於本身的我的網站桃園,另外也能夠在 github blog 上找到。

若是有興趣,也能夠關注個人我的公衆號:「前端桃園」

相關文章
相關標籤/搜索