函數語言程式設計——入門筆記與React實踐
最近在看近來很火的函數語言程式設計教程《Mostly Adequate Guide》 (中文版:《JS函數語言程式設計指南》),收穫很大。對於函數語言程式設計的初學者,這本書不僅深入淺出,更讓人感受到函數語言程式設計的優勢和美感,強烈推薦給想要學習函數語言程式設計的朋友。
這篇文章是我個人的一個學習筆記,在總結知識的同時,也嘗試以React元件的輸入事件響應為例,用函數語言程式設計去應對實際專案中的場景。
下文涉及React的程式碼出於閱讀考慮有一定刪減,完整程式碼在我的Github。
lodash與ramda部分程式碼由於比較簡單,想看執行結果的話可以直接到
純函式
純函式引用原書的描述:
純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
相同的輸入,永遠會得到相同的輸出
,通常意味著對外部狀態解耦。
所謂外部狀態,最常見的例子就是this,如果你的函式是:
function(){
return 'hello, ' + this.name;
}
那它不可能是純函式——你永遠不知道this.name會被誰改寫,測試用例也不可能覆蓋所有情況。如果正巧有一個外部函式,它每隔一個月將this.name改寫成'shit'
提倡函數語言程式設計的人認為,這種共享狀態導致的混亂是絕大多數bug的萬惡之源
其實某種程度上這早已成為共識:不提倡全域性變數其實就是這個道理。也許深刻意識到純函式的優勢還需要一點時間,也許你覺得純函式不錯,但對於如何在專案中使用它完全沒有頭緒,不用著急,現在我們暫時先記住:
做一個純粹的函式,一個脫離了低階趣味的函式
Curry與Compose
curry和compose可以說是函數語言程式設計的眾妙之門
Curry
curry的本質是函式的部分應用
。聽起來有點遙遠,事實上類似的需求我們經常會遇到:
class Form extends React.Component {
setField(key){
return (e)=>{
this.setState({
[key]: e.target.value
})
}
}
render(){
const {name, address} = this.state;
return (
<form>
<input
value={name}
onChange={this.setField('name')}
/>
<input
value={address}
onChange={this.setField('address')}
/>
</form>
)
}
}
藉助高階函式式的function return function,對不同的key我們能夠複用響應事件並setState的邏輯,上例可以認為就是脫掉了馬甲的部分應用
。
換個寫法試試:
const setFieldOnContext = _.curry(function(context, key, e){
context.setState({
[key]: e.target.value
})
});
class Form extends React.Component{
render(){
const {name, address} = this.state;
const setField = setFieldOnContext(this);
return (
<form>
<input
value={name}
onChange={setField('name')}
/>
<input
value={address}
onChange={setField('address')}
/>
</form>
)
}
}
部分應用的特性使得我們可以把關注點分散到每一個引數,在render函式中
const setField = setFieldOnContext(this);
設定了當前上下文,因為你肯定不會設定其它component的state,而具體到每一個onChange則關注不同的目標key。
也許你會想curry是讓程式碼變得好看了一點,但也僅此而已,它只是用新的姿勢解決問題,並沒有解決新的問題或產生新的價值。
當然不是,curry真正產生的價值和魅力的地方,是它對組合的友好。
Compose
對邏輯進行組合,這樣的需求其實很常見,當我想要:
將一個數組去重,然後篩選,最後排序
很多時候會寫成這樣:
import _ from 'lodash'
function filterFn(v){
return typeof v === 'number';
}
function sortFn(v){
return Math.abs(v);
}
_.sortBy(_.filter(_.uniq([1, 1, 3, 4, 2, 'a', -10]), filterFn), sortFn);
// -> [1, 2, 3, 4, -10]
巢狀的程式碼難以閱讀,就像回撥地獄一樣。自然的邏輯應該是順序而非巢狀的,因此很多人會更喜歡”鏈式“寫法:
_([1, 1, 3, 4, 2, 'a', -10]).
uniq().
filter(filterFn).
sortBy(sortFn).
value();
// -> [1, 2, 3, 4, -10]
看起來順眼多了,用瓶子把東西封起來操作的思路很棒(functor
就是這麼幹的,下次我們會細說)。然而問題在於,_(x)
的原型鏈上可供我們鏈式呼叫的函式是有限的,這限制了我們的邏輯表現力。
一個典型的場景是程式碼除錯:我們想知道每一步的返回值,以便定位問題。然而無論是單步除錯還是log列印,在面對鏈式程式碼時都顯得有些束手無策(chrome devtool可以選中部分程式碼並執行,但對編譯生成的程式碼不管用),如果你不想每次debug都把要列印的值扔給臨時變數搞得一地雞毛的話,或許可以這樣:
//_ is lodash
_.prototype.log = function log(label){
var value = this.value();
console.log(label, value);
return _(value);
};
_([1, 1, 3, 4, 2, 'a', -10]).
uniq().
log('does uniq() works right? ').
filter(filterFn).
sortBy(sortFn).
value();
可惜lodash原生並沒有提供這樣的log函式。這不難理解,原型鏈有盡而需求場景無窮,擴充原型來滿足業務場景是註定被動的。
即使你打算打破教條
不是你的物件不要動
——隔壁老王法則
決定像上面程式碼一樣擴充第三方物件的原型,這個log函式仍然有太多怪異的地方,解包var value = this.value()
和封包return _(value)
的過程讓人感到多餘——有種脫掉褲子,放了個屁,然後穿回去的即視感。更重要的是這當中還伴隨著對this關鍵字的依賴,或許你現在覺得沒什麼大不了的,但我希望你在看完這篇文章後能對this有更審慎的想法。
如果你還有其它更好的debug方法和經驗,請一定分享出來。不過現在,讓我們以Ramda為例,看看在函式式的世界裡,問題是如何被解決的:
import R from 'ramda'
var log = R.curry(function (label, value){
console.log(label, value);
return value
});
R.compose(
R.reverse,
log('why we need a reverse ?'),
R.sort(sortFn),
R.filter(filterFn),
R.uniq
)([1, 1, 3, 4, 2, 'a', -10])
// -> [1, 4, 3, 2, -10]
R.compose
接收一組函式並返回了一個新的函式,而資料就像經過一條邏輯流水線一樣,從最後一個函式,一步步地向前接受處理。
R.sort(fn, data)
和R.filter(fn, data)
都是curry函式,你應該已經注意到它們和lodash的同名函式有所不同——引數順序是相反的。這就是curry與compose協同工作的奧祕:compose通常只能針對一元函式,而curry則使得多元函式可以一元化。
函式curry化,並把可變性高複用性低的引數後置,是函式式庫的特徵之一,也是寫自定義函式時需要注意的地方。我們的log函式就遵循了這一點。
組合相比鏈式最大的優勢,是函式可以自由而專注:不再受原型鏈的約束,也不再看this的臉色。對比之前的log函式,現在的版本沒有了多餘的解包與封包,也不再依賴this——現在它是一個純函式。
很多人會用_
而不是R
作為ramda的變數名,我們接下來也會這樣
關於組合更多的內容,還是強烈建議移步《Mostly Adequate Guide》的 第 5 章: 程式碼組合
應用實踐
在大致瞭解了函式組合後,讓我們繼續前面事件響應的例子,先回顧一下,之前我們用curry改寫了setField
函式,得到setFieldOnContext
:
const setFieldOnContext = _.curry(function(context, key, e){
context.setState({
[key]: e.target.value
})
});
class Form extends React.Component{
render(){
const {name, address} = this.state;
const setField = setFieldOnContext(this);
return (
<form>
<input
value={name}
onChange={setField('name')}
/>
<input
value={address}
onChange={setField('address')}
/>
</form>
)
}
}
setFieldOnContext
函式已經能幫我們節省一些重複程式碼,就像它的前輩setField
一樣,然而它的職責還分離得不夠乾淨:對e.target.value
的依賴使得它只能處理原生事件物件。假設我們有一些第三方元件(比如接下來會遇到的X元件),它們的onChange
丟擲了並不標準的事件物件,甚至可能直接把value扔了出來。看起來setFieldOnContext
有些不從心,難道我們只能回到複製--貼上--修改
的懷抱嗎?是時候借用組合的力量了:
import _ from 'ramda'
const getValueFromEvent = function(e){
return e.target.value;
};
const getValueFromX = function(x){
return x.value
}
const setFieldOnContext = _.curry(function(context, key, value){
context.setState({
[key]: value
})
});
class Form extends React.Component{
render(){
const {name, x} = this.state;
const setField = setFieldOnContext(this);
return (
<form>
<input
value={name}
onChange={_.compose(setField('name'), getValueFromEvent)}
/>
<X
value={address}
onChange={_.compose(setField('address'), getValueFromX)}
/>
</form>
)
}
}
藉助compose,我們的函式職責更加分離,setField只關心設值,對值的轉換則由其它函式負責,雖然目前實現的版本用起來還有一些囉嗦,但我們得到了三個關注點(職責)高度分離的、可複用的函式。
在接著討論前,讓我們先統一一下用詞,下面我會把getValueFromEvent
和getValueFromX
這樣的值轉換函式稱作valueAdapter,正如它們的角色(介面卡模式中的介面卡)一樣。
剛剛的程式碼之所以囉嗦,問題出在引數順序和複用度不一致。
_.compose(..., valueAdapter)
其本質是對一類事件進行適配,而我們把它放在引數最後,這導致適配的工作落在了每一次事件宣告上。隨著專案的發展,情況會是這樣:
<form>
<input
value={name}
onChange={_.compose(setField('foo'), getValueFromEvent)}
/>
<input
value={name}
onChange={_.compose(setField('bar'), getValueFromEvent)}
/>
<input
value={name}
onChange={_.compose(setField('baz'), getValueFromEvent)}
/>
<input
value={name}
onChange={_.compose(setField('baa'), getValueFromEvent)}
/>
<input
value={name}
onChange={_.compose(setField('zzz'), getValueFromEvent)}
/>
</form>
滿眼的getValueFromEvent
,完全背離了我們抽象出valueAdapter
的初衷!
這重申了curry的要點:通常我們會按照複用程度從高到低地排列引數,比如在同一個元件中,context的複用度最高,而key則次之,event沒有複用度——每個事件源都是單獨的。至於valueAdapter
們,它們的複用範圍是一類元件。因此,在上例中我們更希望得到一個引數順序類似於fn(valueAdapter, context, key, event)
的函式。
下面是封裝一層函式做引數順序轉換然後curry化的簡單實現:
import _ from 'ramda'
const getValueFromEvent = function(e){
return e.target.value;
};
const getValueFromX = function(x){
return x.value
}
const setFieldOnContext = _.curry(function(context, key, value){
context.setState({
[key]: value
})
});
const getFieldSetter= _.curry(function(valueAdapter, context, name){
//返回真正的event handler
return _.compose(setFieldOnContext(context, name), valueAdapter);
});
const setFieldForEvent = getFieldSetter(getValueFromEvent);
const setFieldForX = getFieldSetter(getValueFromX);
React.createClass({
render(){
const {name, x} = this.state;
return (
<form>
<input
value={name}
onChange={setFieldForEvent(this, 'name')}
/>
<X
value={x}
onChange={setFieldForX(this, 'x')}
/>
</form>
)
}
})
數一數,我們一下子有了六個函式!或許你會為此感到不安:是不是弄錯了什麼?
不必擔心,仔細看看,這六個函式都有各自的複用價值,隨著專案的發展和膨脹,響應事件值的需求隨處可見,而重複的程式碼和邏輯會慢慢蠶食可維護性。把高度解耦的函式們(比如valueAdapter們)組合起來,會讓我們更輕鬆的應對挑戰。
還有一點,上面六個函式中有五個都是純函式!除了setFieldOnContext
,每個函式的輸入輸出都是唯一對映的(雖然有的輸出是函式),沒有依賴外部狀態,也沒有任何的副作用。
追求純函式有時候會比較困難,但它是值得的,如果你的函式依賴了this,或者其它外部狀態,那最好重新審視你的程式碼——至少把不安全的依賴剝離到最小範圍。setFieldOnContext
就是個例子,藉助context
變數而不是this
,我們可以不用在意compose出來的event handler的this是誰,如何傳遞。自由組合函式的前提,就是它們不管在哪兒都始終如一。儘管React的setState返回undefined
導致setFieldOnContext
不能成為純函式,我們也盡力讓它更加接近這一目標
當然這一版本的實現仍不完美:setFieldOnContext
把值直接設到了state的屬性上,有時候這並不是我們想要的結果。在此我先不給出實現,留給看官思考和動手。
下一篇,我會藉助Promise這個老面孔來介紹Functor和Monad——這兩個你甚至沒有見過,卻無處不在的概念。