1. 程式人生 > >函數語言程式設計之組合與管道

函數語言程式設計之組合與管道

7. 組合與管道

昨天我們學習了柯里化與偏函式,當然不能學完就完了,一些經典的函式什麼的還是需要記一下的,比如今天重寫新寫一下看看能不能寫出來,也能加深自己對這方面的理解。

今天我們將要學習的是函式式組合的含義及其實際應用。 Q 函式式組合在函數語言程式設計中被稱為組合,我們將通過了解組合的概念並學習大量例子,然後建立自己的 compose 函式。理解 compose 函式底層的執行機制是一項有趣的任務。

7.1 組合的概念

在瞭解函式式組合之前,我們先來理解一下組合的概念。先來介紹一種理念,它將使我們從組合中受益

Unix 的理念

Unix 的理念有部分內容如下:

每個程式只做好一件事情。為了完成一項新的任務,重新構建

要好於在複雜的舊程式中新增 “新屬性”

這也是我們在建立函式時秉承的理念。函數語言程式設計遵循了 Unix 的理念

該理念的第二部分是

每個程式的輸出都應該是另一個尚未可知的程式的輸入

這是什麼意思呢?我們來看一些 Unix 平臺上的命令

  • cat:用於在控制檯顯示文字檔案的內容(可以將它看做一個函式,接收一個引數,表示檔案的位置,並將輸出列印到控制檯)
  • grep:在給定的文字中搜索內容,返回包含內容的文字行(也可以看做函式,接收一個輸入並給出輸出)

假設我們想通過 cat 命令傳送資料,並將其作為 grep 命令的輸入以完成一次搜尋。我們知道 cat 命令會返回資料,而 grep 命令會接收資料並將其用於搜尋操作。因此,使用 Unix 的管道符號 |,我們就能完成該任務。

cat test.txt | grep 'world'

“|” 被稱為管道符號,它允許我們通過組合一些函式去建立一個能夠解決問題的新函式。大致來講,它將左側函式的輸出作為輸入傳送給右側的函式。從技術上來講,該處理過程稱為管道。

上面的例子可能很簡單,但是它傳達了每個程式的輸出都應該是另一個尚未可知的程式的輸入的理念。

隨著需求的加入,我們通過基礎函式建立了一個新函式,也就是組合成一個新函式。當然,管道在裡面扮演了橋樑的作用。

現在我們通過基礎函式的組合瞭解了組合函式的思想。組合函式真正的優勢在於:無須建立新的函式就可以通過基礎函式解決眼前的問題。

7.2 函式式組合

本節將討論一個有用的函式式組合的用例。

7.2.1 回顧 map 與 filter

還記得之前陣列的函數語言程式設計裡面的問題嗎?

我們又一個物件陣列,結構如下

let apressBooks = [
	{
		'id': 111,
		'title': 'c# 6.0',
		'author': 'Andrew Troelsen',
		'rating': [4.7],
		'reviews': [{good: 4, excellent: 12}]
	},
	{
		'id': 222,
		'title': 'Efficient Learning Machines',
		'author': 'Rahul Khanna',
		'rating': [4.5],
		'reviews': []
	},
	{
		'id': 333,
		'title': 'Pro AngularJS',
		'author': 'Adam Freeman',
		'rating': [4.0],
		'reviews': []
	},
    {
		'id': 444,
		'title': 'Pro ASP.NET',
		'author': 'Adam Freeman',
		'rating': [4.2],
		'reviews': [{good: 14, excellent: 12}]
	},
]

問題是從裡面獲取含有 title 和 author 欄位且評級高於 4.5 的物件。當時我們的解決方案如下

map(filter(apressBooks, book => book.rating[0]>4.5),book => {
    return {title: book.title, author: book.author}
})

是不是覺得很熟悉?這不就是上一節講的嗎?將 filter 的輸出作為輸入引數傳遞給 map 函式。那麼,在 js 中有和 “|” 類似的操作嗎?別說,還真可以

7.2.2 compose 函式

本節將建立一個 compose 函式。它需要接受一個函式的輸出,並將其輸入傳遞給另一個函式。現在把該過程封裝進一個函式

const compose = (a,b) => c => a(b(c))
// 即
const compose = function(a, b){
    return function(c){
        return a(b(c))
    }
}

compose 函式簡單實現了我們的需求。它接受兩個函式,a 和 b,並返回了一個接受引數 c 的函式。當用 c 呼叫返回函式時,它將用輸入 c 呼叫函式 b,b 的輸出將作為 a 的輸入。這就是 compose 函式的簡單定義。我們先用一個簡單的例子快速測試一下 compose 函式。

7.3 應用 compose 函式

假設我們想對一個給定的浮點數進行四捨五入求值。給定的數字為浮點型,因此必須將數字轉換為浮點型並呼叫 Math.round。如果不使用組合,我們將通過下面方式來做

let data = parseFloat('3.56')
let number = Math.round(data)

輸出將是我們期望的 4,但是這完全可以通過 compose 函式來解決啊

let number = compose(Math.round,parseInt)

上面的語句將返回一個新函式,它被儲存在一個變數 number 中,與下面的程式碼等價

number = c => Math.round(parseInt(c))

這個過程就是函式式組合!我們將兩個函式組合在一起以便能即時地構建出一個新函式。

假設我們有兩個函式:

let splitIntoSpaces = str => str.split(' ');
let count = array => array.length

如果想構建一個新函式以便計算一個字串中單詞的數量,可以很容易地實現:

const countWords = compose(count, splitIntoSpaces);

通過 compose 函式建立新的函式是一種優雅而簡單的方式

7.3.1 引入 curry 與 partial

我們知道,僅當函式接受一個引數時,我們才能將兩個函式組合。但多引數函式呢?

還記得我們昨天學的嗎?是的,我們可以通過 partial 和 curry 來實現。

我們將把 map 和 filter 函式組合起來,它們都接受兩個引數,第一個是陣列,第二個是運算元組的函式。我們可以通過 partial 函式來組合

我們先把之前的物件陣列貼過來

let apressBooks = [
	{
		'id': 111,
		'title': 'c# 6.0',
		'author': 'Andrew Troelsen',
		'rating': [4.7],
		'reviews': [{good: 4, excellent: 12}]
	},
	{
		'id': 222,
		'title': 'Efficient Learning Machines',
		'author': 'Rahul Khanna',
		'rating': [4.5],
		'reviews': []
	},
	{
		'id': 333,
		'title': 'Pro AngularJS',
		'author': 'Adam Freeman',
		'rating': [4.0],
		'reviews': []
	},
    {
		'id': 444,
		'title': 'Pro ASP.NET',
		'author': 'Adam Freeman',
		'rating': [4.2],
		'reviews': [{good: 14, excellent: 12}]
	},
]

假設我們根據不同評級在程式碼庫中定義了很多小函式用於過濾圖書,如下所示

let filterOutStandingBooks = book => book.rating[0] === 5;
let filterGoodBooks = book => book.rating[0] > 4.5;
let filterBadBooks = book => book.rating[0] < 3.5;

再定義一些投影函式

let projectTitleAndAuthor = book => {title: book.title, author: book.author}
let projectAuthor = book => {author: book.author}
let projectTitle = book => {title: book.title}

為什麼要定義這麼多小函式呢?因為組合的思想就是把小函式組合成一個大函式,簡單的函式更容易閱讀,測試和維護。

現在該解決問題了——獲取評級高於 4.5 的圖書的標題和作者,我們可以通過 compose 和 partial 來實現

let queryGoodBooks = partial(filter,undefined,filterGoodBooks);
let mapTitleAndAuthor = partial(map,undefined,projectTitleAndAuthor);
let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks);

下面來解釋一下

首先,compose 函式只能組合接受一個引數的函式,但是 filter 和 map 接受兩個引數,因此,我們不能直接將它們組合。這就是我們先使用 partial 函式部分地應用 map 和 filter 的第二個引數的原因

partial(filter,undefined,filterGoodBooks);
partial(map,undefined,projectTitleAndAuthor);

此處我們出入了 filterGoodBooks 函式來查詢評級高於 4.5 的圖書,傳入 projectTitleAndAuthor 函式來獲取 apressBooks 物件的 title 和 author 屬性。現在的偏應用函式都只接受一個數組引數了!有了這兩個偏函式,我們就可以通過 compose 函式將它們組合起來了。

let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks);

現在 titleAndAuthorForGoodBooks 只接受一個引數,下面把 apressBooks 物件陣列傳給它:

titleAndAuthorForGoodBooks(apressBooks)
/*
    [
        {
            title: 'c# 6.0',
            author: 'ANDREW TRELSEN'
        }
    ]
*/

同樣,我們只想獲取評級高於 4.5 的圖書的標題,該怎麼辦?很簡單

let mapTitle = partial(map,undefined,projectTitle);
let titleForGoodBooks = compose(mapTitle,queryGoodBooks);

// 呼叫
titleForGoodBooks(apressBooks)
/*
    [
        {
            title: 'c# 6.0',
        }
    ]
*/

那如果要只獲取評級等於 5 的圖書的作者呢?這個問題留給你自己去想吧

本節使用了 partial 函式來填充函式的引數。其實你也可以使用 curry 函式做同樣的事情。只是選擇的問題,但是你能使用 curry 給出上面例子的解決方案嗎?可以自己想一下(提示:顛倒 map 和 filter 的引數順序)

7.3.2 組合多個函式

當前 compose 函式只能組合兩個給定的函式。如何組合三個、四個或更多個函式呢?現在的函式肯定解決不了。下面重寫 compose 函式,讓它能夠即時地組合多個函式。

記住,我們需要把每個函式的輸出作為輸入傳送給另一個函式(通過遞迴地儲存上一次執行的函式的輸出)。可以使用 reduce 函式,之前我們也是用過它逐次歸約多個函式呼叫。

const compose = (...fns) => {
    return value => reduce(fns.reverse(),(acc,fn) => fn(acc), value)
}
// 用真正的 reduce 函式改寫一下
var compose = function(...fns){
    return function(value){
        // 這樣更容易看出思想,即將 value 作為初始值,然後將其傳入最後一個函式,將返回值一直向前傳遞
        fns.push(value);
        return fns.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}

其中最重要的是這一句

reduce(fns.reverse(),(acc,fn) => fn(acc), value)

回顧一下我們之前的 reduce 函式,第一引數是傳入的陣列,第二個引數對陣列的操作,第三個引數是初始值。

首先,我們將傳入的陣列反轉,並傳入函式(acc,fn) => fn(acc),它會以傳入的 acc 作為其引數依次呼叫每一個函式。累加器的初始值是 value 變數,它將作為函式的第一個輸入。

有了新的 compose 函式,下面用一箇舊的例子來測試一下它。上一節,我們組合了一個函式用於計算給定字串的單詞數

let splitIntoSpaces = str => str.split(' ');
let count = array => array.length;
const countWords = compose(count, splitIntoSpaces);

// 計算
countWords("hello your reading about composition")
// 5

假設我們想知道給定字串的單詞數是奇數還是偶數。而我們已經有了一個這樣的函式

let oddOrEven = ip => ip % 2 == 0 ? 'even': 'odd'

通過 compose 函式,我們就可以組合這三個函式組合起來以得到想要的結果

const oddOrEvenWords = compose(oddOrEven,count,splitIntoSpaces);

oddOrEvenWords("hello your reading about composition")
// ['odd']

但是這個函式及我改寫的函式其實都存在一個問題,即它們都只能執行一次,可以自己試下。我自己測出來的,因為 compose 返回的是函式,然後第一次呼叫的時候執行了 fns.reverse(),reverse 會改變原陣列,第二次呼叫的時候又改變了原陣列,一來一回陣列變回原來的順序了,所以會出錯。

那麼有什麼辦法改變這個書中存在的 bug 呢?很簡單,我們建立一個額外的副本就可以了,如下

const compose = (...fns) => {
    return value => {
        var fnsCopy = fns.reverse();
        reduce(fnsCopy.reverse(),(acc,fn) => fn(acc), value)
    }
}
// 真正的 reduce 函式
var compose = function(...fns){
    return function(value){
        let fnsCopy = fns.concat();
        fnsCopy.push(value);
        return fnsCopy.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}

這裡有一個可能不經常用到的點,陣列的 concat 可以快速拷貝一個數組,但是記住這種拷貝是淺拷貝哦。這樣我們就可以進行任意次數的操作啦,所以說看書還是得自己動手啊,畢竟絕知此事要躬行。

7.4 管道/序列

上一節我們瞭解了 compose 函式資料流的執行機制:compose 函式的資料流是從右往左的,因為最右側的函式最先執行,將資料傳遞給下一個函式,從我改寫的函式就可以看出來

var compose = function(...fns){
    return function(value){
        let fnsCopy = fns.concat();
        fnsCopy.push(value);
        // 此時數組裡面是[f1,f2,f3,value]
        // 然後反轉陣列,陣列變為 [value,f3,f2,f1]
        // 然後執行 reduce,先是 f3(value) -> f2(f3(value)) -> f1(f2(f3(value)))
        // 夠清楚了吧
        return fnsCopy.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}

這一節我們將介紹另一種資料流——最左側的函式最先執行,最右側的函式最後執行。還記得之前 Unix 裡面的 “|” 操作符嗎,它就是從左往右的。這一節我們將實現一個 pipe 的函式,它與 compose 函式所做的事情相同,只不過交換了資料流的方向!

從左往右處理資料流的過程稱為管道(pipeline)或序列(sequence)

程式碼實現如下

const pipe = (...fns) => {
    return (value) => reduce(fns,(acc, fn) => fn(acc), value);
}
// 同樣用真正的 reduce 改寫一下
const pipe = function(...fns){
    return function(value){
        // 這裡定義拷貝陣列是因為 fns 是陣列,如果每次 unshift 的話,陣列長度就一直變化,當然也可以操作完以後再做一個 shift 操作,但是直接重新定義的話更方便一些
        let fnsCopy = fns.concat();
        fnsCopy.unshift(value);
        return fnsCopy.reduce((acc, fn) => {
            return fn(acc);
        })
    }
}

同樣來試驗一下

// 請注意,我們改變了函式傳入的順序
const oddOrEvenWords = pipe(splitIntoSpaces,count,oddOrEven);

oddOrEvenWords("hello your reading about composition")
// ['odd']

pipe 和 compose 其實實現的是相同的功能,只是資料流方向的區別。在團隊開發中最好確定一種方向,否則容易混亂。

7.5 組合的優勢

這一節我們將討論組合最大的優勢——組合滿足結合律。然後討論組合多個函式時如何除錯

7.5.1 組合滿足結合律

函式總是滿足結合律

先來複習一下結合律吧

( a + b ) + c = a + ( b +c )

表現在函式中就是

compose(f,compose(g, h)) == compose(compose(f, g),h)

還是拿上一節的函式舉例子

// compose(compose(f, g),h)
const oddOrEvenWords = compose(compose(oddOrEven,count),splitIntoSpaces);
oddOrEvenWords("hello your reading about composition")
// ['odd']

// compose(f,compose(g, h))
const oddOrEvenWords = compose(oddOrEven,compose(count,splitIntoSpaces));
oddOrEvenWords("hello your reading about composition")
// ['odd']

從上面的例子可以看出,兩種情況的執行結果是相同的。這就證明了函式式組合滿足結合律。那麼這有什麼用呢?

最大的用處是允許我們把函式組合到各自所需的 compose 函式中,比如

let countWords = compose(count,splitIntoSpaces);
const oddOrEvenWords = compose(oddOrEven,countWords);

// 或者
let countOddOrEven = compose(oddOrEven,count);
const oddOrEvenWords = compose(countOddOrEven,splitIntoSpaces);

由於結合律的存在,我們可以建立各種各樣的小函式,最後組成大函式,不用擔心結果會有變化,這也是為什麼之前我們建立那麼多小函式的原因。

7.5.2 使用 tap 函式除錯

tap 函式式 underscore.js 中的一個函式,其主要目的是在一個鏈式呼叫中對中間結果執行某些操作。我們即將要建立的 identity 函式有類似功能,即列印 compose 函式的中間結果,用於 compose 函式的除錯。

const identity = it => {
    console.log(it);
    return it;
}

我們只是簡單的添加了一行 console.log 來列印輸出值,為什麼就能除錯了呢?沒錯,就是因為函式組合的結合律,我們可以將它放在任何位置而不會影響結果,只是列印了一下結果而已。

讓我們測試一下

const oddOrEvenWords = compose(oddOrEven,count,splitIntoSpaces);
oddOrEvenWords("Test string")

假設我們在執行程式碼時,count 函式丟擲錯誤了怎麼辦,如何得知 count 接收的引數?這就是 identity 函式發揮作用的地方了。我們將 identity 放在可能發生錯誤的地方

// compose 資料流從右往左,所以要放在 count 後面
compose(oddOrEven,count,identify,splitIntoSpaces)('Test string');

這樣就會打印出 count 函式接收到的輸入引數了,這對於除錯函式接收到的資料非常有幫助。

7.6 小結

今天我們從 Unix 的理念談起,瞭解了 cat、grep 這些命令式如何按需組合的。然後建立了自己的 compose 和 pipe 函式。順帶發現了書裡的一個 bug。還了解了偏函式與柯里化在函式式組合中發揮的作用。

最後我們介紹了函式式組合的一個重要特性——組合滿足結合律!並且利用這個特性提供了一個名為 identity 的小函式。我們可以用它來除錯組合過程中出現的錯誤。

我們需要記住,compose 函式是通過組合一些簡單,並且定義良好的小函式來實現複雜函式的。當然最重要的是自己動手來實現,否則你永遠也記不住。至少我是這樣。

明天我們要學習的是一個簡單而強大的東西——函子,那麼明天見。