JS函數語言程式設計 - 函式組合與柯里化
我們都知道單一職責原則,其實面向物件的SOLID中的S(SRP, Single responsibility principle)。在函式式當中每一個函式就是一個單元,同樣應該只做一件事。但是現實世界總是複雜的,當把現實世界對映到程式設計時,單一的函式就沒有太大的意義。這個時候就需要函式組合和柯里化了。
鏈式呼叫
如果用過jQuery的都曉得啥是鏈式呼叫,比如$('.post').eq(1).attr('data-test', 'test')
.javascript原生的一些字串和陣列的方法也能寫出鏈式呼叫的風格:
'Hello, world!'.split('').reverse().join('') // "!dlrow ,olleH"
首先鏈式呼叫是基於物件的,上面的一個一個方法split
, reverse
, join
如果脫離的前面的物件"Hello, world!"是玩不起來的。
而在函數語言程式設計中方法是獨立於資料的,我們可以把上面以函式式的方式在寫一遍:
const split = (tag, xs) => xs.split(tag) const reverse = xs => xs.reverse() const join = (tag, xs) => xs.join(tag) join('',reverse(split('','Hello, world!'))) // "!dlrow ,olleH"
你肯定會說,你是在逗我。這比鏈式呼叫好在哪兒了?這裡還是依賴於資料的啊,沒有傳遞`'Hello, world!',你這一串一串的函式組合也轉不起來啊。這裡唯一的好處也就是那幾個單獨的方法可以複用了。莫慌,後面還有那麼多內容我怎麼也會給你優化(忽悠)好的。再進行改造前,我們先介紹兩個概念,部分應用和柯里化。
部分應用
部分應用是一種處理函式引數的流程,他會接收部分引數,然後返回一個函式接收更少的引數。這個就是部分應用。我們用bind
來實現一把:
const addThreeArg = (x, y, z) => x + y + z; const addTwoArg = addThreeNumber.bind(null, 1) const addOneArg = addThreeNumber.bind(null, 1, 2) addTwoArg(2, 3) // 6 addOneArg(7) // 10
上面利用bind
生成了另外兩個函式,分別接受剩下的引數,這就是部分應用。當然你也可以通過其他方式實現。
部分應用存在的問題
部分應用主要的問題在於,它返回的函式型別無法直接推斷。正如前面所說,部分應用返回一個函式接收更少的引數,而沒有規定返回的引數具體是多少個。這也就是一些隱式的東西,你需要去檢視程式碼。才知道返回的函式接收多少個引數。
柯里化
柯里化定義:你可以調一個函式,但是不一次將所有引數傳給它。這個函式會返回一個函式去接收下一個引數。
const add = x => y => x + y
const plusOne = add(1)
plusOne(10) // 11
柯里化的函式返回一個只接收一個引數的函式,返回的函式型別可以預測。
當然在實際開發中,有很多的函式都不是柯里化的,我們可以使用一些工具函式來轉化:
const curry = (fn) => { // fn可以是任何引數的函式
const arity = fn.length;
return function $curry(...args) {
if (args.length < arity) {
return $curry.bind(null, ...args);
}
return fn.call(null, ...args);
};
};
也可以用開源庫Ramda裡提供的curry方法。
哦,柯里化。有什麼用呢?
舉個例子
const currySplit = curry((tag, xs) => xs.split(tag))
const split = (tag, xs) => xs.split(tag)
// 我現在需要一個函式去split ","
const splitComma = currySplit(',') //by curry
const splitComma = string => split(',', string)
可以看到柯里化的函式生成新函式時,和資料完全沒有關係。對比兩個生成新函式的過程,電影下載,沒有柯里化的相對而言就有一點囉嗦了。
函式組合
先給程式碼:
const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];
其實compose做的事情一共兩件:
- 接收一組函式,返回一個函式,不立即執行函式
- 組合函式,將傳遞給他的函式從左到右組合。
可能有同學對上面的reduceRight不是很熟悉,我給個2元和3元的例子:
const compose = (f, g) => (...args) => f(g(...args))
const compose3 = (f, g, z) => (...args) => f(g(z(...args)))
函式呼叫是從左到右,資料流也是一樣的從左到右。當然你可以定義從右到左的,不過從語義上來說就不那麼表意了。
好,現在讓我們來優化一下最開始的例子:
const split = curry((tag, xs) => xs.split(tag))
const reverse = xs => xs.reverse()
const join = curry((tag, xs) => xs.join(tag))
const reverseWords = compose(join(''), reverse, split(''))
reverseWords('Hello,world!');
是不是簡潔易於理解多了。這裡的reverseWords
也是我們之前講過的Pointfree的程式碼風格。網站速度測試,不依賴資料和外部狀態,就是組合在一起的一個函式。
Pointfree我在上一篇介紹過JS函數語言程式設計 - 概念,也闡述了其優缺點,有興趣的小夥伴可以看看。
函式組合的結合律
先回顧一下小學知識加法結合律:a+(b+c)=(a+b)+c
。我就不解釋了,你們應該能理解。
回過來看函式組合其實也存在結合律的:
compose(f, compose(g, h)) === compose(compose(f, g), h);
這個對於我們程式設計有一個好處,我們的函式組合可以隨意組合並且快取:
const split = curry((tag, xs) => xs.split(tag))
const reverse = xs => xs.reverse()
const join = curry((tag, xs) => xs.join(tag))
const getReverseArray = compose(reverse, split(''))
const reverseWords = compose(join(''), getReverseArray)
reverseWords('Hello,world!');
腦圖補充: