1. 程式人生 > >函數語言程式設計——入門筆記與React實踐

函數語言程式設計——入門筆記與React實踐

函數語言程式設計——入門筆記與React實踐

最近在看近來很火的函數語言程式設計教程《Mostly Adequate Guide》 (中文版:《JS函數語言程式設計指南》),收穫很大。對於函數語言程式設計的初學者,這本書不僅深入淺出,更讓人感受到函數語言程式設計的優勢和美感,強烈推薦給想要學習函數語言程式設計的朋友。

這篇文章是我個人的一個學習筆記,在總結知識的同時,也嘗試以React元件的輸入事件響應為例,用函數語言程式設計去應對實際專案中的場景。

下文涉及React的程式碼出於閱讀考慮有一定刪減,完整程式碼在我的Github

lodash與ramda部分程式碼由於比較簡單,想看執行結果的話可以直接到

lodashramda官網開啟console執行。

純函式

純函式引用原書的描述:

純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。

相同的輸入,永遠會得到相同的輸出,通常意味著對外部狀態解耦。

所謂外部狀態,最常見的例子就是this,如果你的函式是:

function(){
    return 'hello, ' + this.name;
}

那它不可能是純函式——你永遠不知道this.name會被誰改寫,測試用例也不可能覆蓋所有情況。如果正巧有一個外部函式,它每隔一個月將this.name改寫成'shit'

,你和測試人員熬了幾個通宵沒有發現一點問題,你也信心滿滿——用函式給客人打招呼實在太簡單。專案上線後,你買好機票正準備出門度假,卻接到老闆的電話讓你滾回公司改bug,而你對事情的狀況沒有一點頭緒……

提倡函數語言程式設計的人認為,這種共享狀態導致的混亂是絕大多數bug的萬惡之源

其實某種程度上這早已成為共識:不提倡全域性變數其實就是這個道理。也許深刻意識到純函式的優勢還需要一點時間,也許你覺得純函式不錯,但對於如何在專案中使用它完全沒有頭緒,不用著急,現在我們暫時先記住:

做一個純粹的函式,一個脫離了低階趣味的函式

Curry與Compose

curry和compose可以說是函數語言程式設計的眾妙之門

,而且必須相輔相成才見威力。就我個人而言,見過一些講函式式的教程,講了curry,我也知道了什麼是curry,但是curry怎麼用?能帶來什麼好處呢?還是沒講清楚,然後馬不停蹄地往前講functor講monad,作為資質不那麼高的函式式菜鳥,很快就雲裡霧中,不明覺厲了。

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>
        )
    }
}

完整程式碼 step_1

藉助高階函式式的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>
        )
    }
}

完整程式碼 step_2

部分應用的特性使得我們可以把關注點分散到每一個引數,在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>
        )
    }
}

完整程式碼 step_3

藉助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>
        )
    }
})

完整程式碼 step_4

數一數,我們一下子有了六個函式!或許你會為此感到不安:是不是弄錯了什麼?

不必擔心,仔細看看,這六個函式都有各自的複用價值,隨著專案的發展和膨脹,響應事件值的需求隨處可見,而重複的程式碼和邏輯會慢慢蠶食可維護性。把高度解耦的函式們(比如valueAdapter們)組合起來,會讓我們更輕鬆的應對挑戰。

還有一點,上面六個函式中有五個都是純函式!除了setFieldOnContext,每個函式的輸入輸出都是唯一對映的(雖然有的輸出是函式),沒有依賴外部狀態,也沒有任何的副作用。

追求純函式有時候會比較困難,但它是值得的,如果你的函式依賴了this,或者其它外部狀態,那最好重新審視你的程式碼——至少把不安全的依賴剝離到最小範圍。setFieldOnContext就是個例子,藉助context變數而不是this,我們可以不用在意compose出來的event handler的this是誰,如何傳遞。自由組合函式的前提,就是它們不管在哪兒都始終如一。儘管React的setState返回undefined導致setFieldOnContext不能成為純函式,我們也盡力讓它更加接近這一目標

當然這一版本的實現仍不完美:setFieldOnContext把值直接設到了state的屬性上,有時候這並不是我們想要的結果。在此我先不給出實現,留給看官思考和動手。

補充:我的實現見 程式碼step_5 , 感謝 Young 的建議與啟發

下一篇,我會藉助Promise這個老面孔來介紹Functor和Monad——這兩個你甚至沒有見過,卻無處不在的概念。