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

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

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

對於這篇文章裏面提到的其他概念:閉包、作用域,然後柯里化的其他用途我希望是在番外篇裏面更深入的去理解,而這篇文章主要掌握函數組合就行了。

參考文章

文章首發於自己的個人網站桃園,另外也可以在 github blog 上找到。

如果有興趣,也可以關注我的個人公衆號:「前端桃園」

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章