翻譯連載 | JavaScript 輕量級函數式編程-第3章:管理函數的輸入 |《你不知道的JS》姊妹篇

關於譯者:這是一個流淌着滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裏最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。通過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,但願能夠幫助你們在學習函數式編程的道路上走的更順暢。比心。前端

譯者團隊(排名不分前後):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry蘿蔔vavd317vivaxy萌萌zhouyaogit

第 3 章:管理函數的輸入(Inputs)

在第 2 章的 「函數輸入」 小節中,咱們聊到了函數形參(parameters)和實參(arguments)的基本知識,實際上還了解到一些能簡化其使用方式的語法技巧,好比 ... 操做符和解構(destructuring)。github

在那個討論中,我建議儘量設計單一形參的函數。但實際上你不能每次都作到,並且也不能每次都掌控你的函數簽名(譯者注:JS 中,函數簽名通常包含函數名和形參等函數關鍵信息,例如 foo(a, b = 1, c))。ajax

如今,咱們把注意力放在更復雜、強大的模式上,以便討論處在這些場景下的函數輸入。正則表達式

當即傳參和稍後傳參

若是一個函數接收多個實參,你可能會想先指定部分實參,餘下的稍後再指定。編程

來看這個函數:api

function ajax(url,data,callback) {
    // ..
}複製代碼

想象一個場景,你要發起多個已知 URL 的 API 請求,但這些請求的數據和處理響應信息的回調函數要稍後才能知道。數組

固然,你能夠等到這些東西都肯定後再發起 ajax(..) 請求,而且到那時再引用全局 URL 常量。但咱們還有另外一種選擇,就是建立一個已經預設 url 實參的函數引用。bash

咱們將建立一個新函數,其內部仍然發起 ajax(..) 請求,此外在等待接收另外兩個實參的同時,咱們手動將 ajax(..) 第一個實參設置成你關心的 API 地址。閉包

function getPerson(data,cb) {
    ajax( "http://some.api/person", data, cb );
}

function getOrder(data,cb) {
    ajax( "http://some.api/order", data, cb );
}複製代碼

手動指定這些外層函數固然是徹底有可能的,但這可能會變得冗長乏味,特別是不一樣的預設實參還會變化的時候,譬如:

function getCurrentUser(cb) {
    getPerson( { user: CURRENT_USER_ID }, cb );
}複製代碼

函數式編程者習慣於在重複作同一種事情的地方找到模式,並試着將這些行爲轉換爲邏輯可重用的實用函數。實際上,該行爲確定已經是大多數讀者的本能反應了,因此這並不是函數式編程獨有。可是,對函數式編程而言,這個行爲的重要性是毋庸置疑的。

爲了構思這個用於實參預設的實用函數,咱們不只要着眼於以前提到的手動實現方式,還要在概念上審視一下到底發生了什麼。

用一句話來講明發生的事情:getOrder(data,cb)ajax(url,data,cb) 函數的偏函數(partially-applied functions)。該術語表明的概念是:在函數調用現場(function call-site),將實參應用(apply) 於形參。如你所見,咱們一開始僅應用了部分實參 —— 具體是將實參應用到 url 形參 —— 剩下的實參稍後再應用。

關於該模式更正式的說法是:偏函數嚴格來說是一個減小函數參數個數(arity)的過程;這裏的參數個數指的是但願傳入的形參的數量。咱們經過 getOrder(..) 把原函數 ajax(..) 的參數個數從 3 個減小到了 2 個。

讓咱們定義一個 partial(..) 實用函數:

function partial(fn,...presetArgs) {
    return function partiallyApplied(...laterArgs){
        return fn( ...presetArgs, ...laterArgs );
    };
}複製代碼

建議: 只是蜻蜓點水是不行的。請花些時間研究一下該實用函數中發生的事情。請確保你真的理解了。因爲在接下來的文章裏,咱們將會一次又一次地提到該模式,因此你最好如今就適應它。

partial(..) 函數接收 fn 參數,來表示被咱們偏應用實參(partially apply)的函數。接着,fn 形參以後,presetArgs 數組收集了後面傳入的實參,保存起來稍後使用。

咱們建立並 return 了一個新的內部函數(爲了清晰明瞭,咱們把它命名爲partiallyApplied(..)),該函數中,laterArgs 數組收集了所有實參。

你注意到在內部函數中的 fnpresetArgs 引用了嗎?他們是怎麼如何工做的?在函數 partial(..) 結束運行後,內部函數爲什麼還能訪問 fnpresetArgs 引用?你答對了,就是由於閉包!內部函數 partiallyApplied(..) 封閉(closes over)了 fnpresetArgs 變量,因此不管該函數在哪裏運行,在 partial(..) 函數運行後咱們仍然能夠訪問這些變量。因此理解閉包是多麼的重要!

partiallyApplied(..) 函數稍後在某處執行時,該函數使用被閉包做用(closed over)的 fn 引用來執行原函數,首先傳入(被閉包做用的)presetArgs 數組中全部的偏應用(partial application)實參,而後再進一步傳入 laterArgs 數組中的實參。

若是你對以上感到任何疑惑,請停下來再看一遍。相信我,隨着咱們進一步深刻本文,你會欣然接受這個建議。

提一句,對於這類代碼,函數式編程者每每喜歡使用更簡短的 => 箭頭函數語法(請看第 2 章的 「語法」 小節),像這樣:

var partial =
    (fn, ...presetArgs) =>
        (...laterArgs) =>
            fn( ...presetArgs, ...laterArgs );複製代碼

毫無疑問這更加簡潔,甚至代碼稀少。但我我的以爲,不管咱們從數學符號的對稱性上得到什麼好處,都會因函數變成了匿名函數而在總體的可讀性上失去更多益處。此外,因爲做用域邊界變得模糊,咱們會更加難以辯認閉包。

無論你喜歡哪一種語法實現方式,如今咱們用 partial(..) 實用函數來製造這些以前說起的偏函數:

var getPerson = partial( ajax, "http://some.api/person" );

var getOrder = partial( ajax, "http://some.api/order" );複製代碼

請暫停並思考一下 getPerson(..) 函數的外形和內在。它至關於下面這樣:

var getPerson = function partiallyApplied(...laterArgs) {
    return ajax( "http://some.api/person", ...laterArgs );
};複製代碼

建立 getOrder(..) 函數能夠依葫蘆畫瓢。可是 getCurrentUser(..) 函數又如何呢?

// 版本 1
var getCurrentUser = partial(
    ajax,
    "http://some.api/person",
    { user: CURRENT_USER_ID }
);

// 版本 2
var getCurrentUser = partial( getPerson, { user: CURRENT_USER_ID } );複製代碼

咱們能夠(版本 1)直接經過指定 urldata 兩個實參來定義 getCurrentUser(..) 函數,也能夠(版本 2)將 getCurrentUser(..) 函數定義成 getPerson(..) 的偏應用,該偏應用僅指定一個附加的 data 實參。

由於版本 2 重用了已經定義好的函數,因此它在表達上更清晰一些。所以我認爲它更加貼合函數式編程精神。

版本 1 和 2 分別至關於下面的代碼,咱們僅用這些代碼來確認一下對兩個函數版本內部運行機制的理解。

// 版本 1
var getCurrentUser = function partiallyApplied(...laterArgs) {
    return ajax(
        "http://some.api/person",
        { user: CURRENT_USER_ID },
        ...laterArgs
    );
};

// 版本 2
var getCurrentUser = function outerPartiallyApplied(...outerLaterArgs) {
    var getPerson = function innerPartiallyApplied(...innerLaterArgs){
        return ajax( "http://some.api/person", ...innerLaterArgs );
    };

    return getPerson( { user: CURRENT_USER_ID }, ...outerLaterArgs );
}複製代碼

再強調一下,爲了確保你理解這些代碼段發生了什麼,請暫停並從新閱讀一下它們。

注意: 第二個版本的函數包含了一個額外的函數包裝層。這看起來有些奇怪並且多餘,但對於你真正要適應的函數式編程來講,這僅僅是它的冰山一角。隨着本文的繼續深刻,咱們將會把許多函數互相包裝起來。記住,這就是函數式編程!

咱們接着看另一個偏應用的實用示例。設想一個 add(..) 函數,它接收兩個實參,並取兩者之和:

function add(x,y) {
    return x + y;
}複製代碼

如今,想象咱們要拿到一個數字列表,而且給其中每一個數字加一個肯定的數值。咱們將使用 JS 數組對象內置的 map(..) 實用函數。

[1,2,3,4,5].map( function adder(val){
    return add( 3, val );
} );
// [4,5,6,7,8]複製代碼

注意: 若是你沒見過 map(..) ,別擔憂,咱們會在本書後面的部分詳細介紹它。目前你只須要知道它用來循環遍歷(loop over)一個數組,在遍歷過程當中調用函數產出新值並存到新的數組中。

由於 add(..) 函數簽名不是 map(..) 函數所預期的,因此咱們不直接把它傳入 map(..) 函數裏。這樣一來,偏應用就有了用武之地:咱們能夠調整 add(..) 函數簽名,以符合 map(..) 函數的預期。

[1,2,3,4,5].map( partial( add, 3 ) );
// [4,5,6,7,8]複製代碼

bind(..)

JavaScript 有一個內建的 bind(..) 實用函數,任何函數均可以使用它。該函數有兩個功能:預設 this 關鍵字的上下文,以及偏應用實參。

我認爲將這兩個功能混合進一個實用函數是極其糟糕的決定。有時你不想關心 this 的綁定,而只是要偏應用實參。我本人基本上從不會同時須要這兩個功能。

對於下面的方案,你一般要傳 null 給用來綁定 this 的實參(第一個實參),而它是一個能夠忽略的佔位符。所以,這個方案很是糟糕。

請看:

var getPerson = ajax.bind( null, "http://some.api/person" );複製代碼

那個 null 只會給我帶來無盡的煩惱。

將實參順序顛倒

回想咱們以前調用 Ajax 函數的方式:ajax( url, data, cb )。若是要偏應用 cb 而稍後再指定 dataurl 參數,咱們應該怎麼作呢?咱們能夠建立一個能夠顛倒實參順序的實用函數,用來包裝原函數。

function reverseArgs(fn) {
    return function argsReversed(...args){
        return fn( ...args.reverse() );
    };
}

// ES6 箭頭函數形式
var reverseArgs =
    fn =>
        (...args) =>
            fn( ...args.reverse() );複製代碼

如今能夠顛倒 ajax(..) 實參的順序了,接下來,咱們再也不從左邊開始,而是從右側開始偏應用實參。爲了恢復指望的實參順序,接着咱們又將偏應用實參後的函數顛倒一下實參順序:

var cache = {};

var cacheResult = reverseArgs(
    partial( reverseArgs( ajax ), function onResult(obj){
        cache[obj.id] = obj;
    } )
);

// 處理後:
cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );複製代碼

好,咱們來定義一個從右邊開始偏應用實參(譯者注:如下簡稱右偏應用實參)的 partialRight(..) 實用函數。咱們將運用和上面相同的技巧於該函數中:

function partialRight( fn, ...presetArgs ) {
    return reverseArgs(
        partial( reverseArgs( fn ), ...presetArgs.reverse() )
    );
}

var cacheResult = partialRight( ajax, function onResult(obj){
    cache[obj.id] = obj;
});

// 處理後:
cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );複製代碼

這個 partialRight(..) 函數的實現方案不能保證讓一個特定的形參接收特定的被偏應用的值;它只能確保將被這些值(一個或幾個)看成原函數最右邊的實參(一個或幾個)傳入。

舉個例子:

function foo(x,y,z) {
    var rest = [].slice.call( arguments, 3 );
    console.log( x, y, z, rest );
}

var f = partialRight( foo, "z:last" );

f( 1, 2 );            // 1 2 "z:last" []

f( 1 );                // 1 "z:last" undefined []

f( 1, 2, 3 );        // 1 2 3 ["z:last"]

f( 1, 2, 3, 4 );    // 1 2 3 [4,"z:last"]複製代碼

只有在傳兩個實參(匹配到 xy 形參)調用 f(..) 函數時,"z:last" 這個值才能被賦給函數的形參 z。在其餘的例子裏,無論左邊有多少個實參,"z:last" 都被傳給最右的實參。

一次傳一個

咱們來看一個跟偏應用相似的技術,該技術將一個指望接收多個實參的函數拆解成連續的鏈式函數(chained functions),每一個鏈式函數接收單一實參(實參個數:1)並返回另外一個接收下一個實參的函數。

這就是柯里化(currying)技術。

首先,想象咱們已建立了一個 ajax(..) 的柯里化版本。咱們這樣使用它:

curriedAjax( "http://some.api/person" )
    ( { user: CURRENT_USER_ID } )
        ( function foundUser(user){ /* .. */ } );複製代碼

咱們將三次調用分別拆解開來,這也許有助於咱們理解整個過程:

var personFetcher = curriedAjax( "http://some.api/person" );

var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } );

getCurrentUser( function foundUser(user){ /* .. */ } );複製代碼

curriedAjax(..) 函數在每次調用中,一次只接收一個實參,而不是一次性接收全部實參(像 ajax(..) 那樣),也不是先傳部分實參再傳剩餘部分實參(藉助 partial(..) 函數)。

柯里化和偏應用類似,每一個相似偏應用的連續柯里化調用都把另外一個實參應用到原函數,一直到全部實參傳遞完畢。

不一樣之處在於,curriedAjax(..) 函數會明確地返回一個指望只接收下一個實參 data 的函數(咱們把它叫作 curriedGetPerson(..)),而不是那個能接收全部剩餘實參的函數(像此前的 getPerson(..) 函數) 。

若是一個原函數指望接收 5 個實參,這個函數的柯里化形式只會接收第一個實參,而且返回一個用來接收第二個參數的函數。而這個被返回的函數又只接收第二個參數,而且返回一個接收第三個參數的函數。依此類推。

由此而知,柯里化將一個多參數(higher-arity)函數拆解爲一系列的單元鏈式函數。

如何定義一個用來柯里化的實用函數呢?咱們將要用到第 2 章中的一些技巧。

function curry(fn,arity = fn.length) {
    return (function nextCurried(prevArgs){
        return function curried(nextArg){
            var args = prevArgs.concat( [nextArg] );

            if (args.length >= arity) {
                return fn( ...args );
            }
            else {
                return nextCurried( args );
            }
        };
    })( [] );
}複製代碼

ES6 箭頭函數版本:

var curry =
    (fn, arity = fn.length, nextCurried) =>
        (nextCurried = prevArgs =>
            nextArg => {
                var args = prevArgs.concat( [nextArg] );

                if (args.length >= arity) {
                    return fn( ...args );
                }
                else {
                    return nextCurried( args );
                }
            }
        )( [] );複製代碼

此處的實現方式是把空數組 [] 看成 prevArgs 的初始實參集合,而且將每次接收到的 nextArgprevArgs 鏈接成 args 數組。當 args.length 小於 arity(原函數 fn(..) 被定義和指望的形參數量)時,返回另外一個 curried(..) 函數(譯者注:這裏指代 nextCurried(..) 返回的函數)用來接收下一個 nextArg 實參,與此同時將 args 實參集合做爲惟一的 prevArgs 參數傳入 nextCurried(..) 函數。一旦咱們收集了足夠長度的 args 數組,就用這些實參觸發原函數 fn(..)

默認地,咱們的實現方案基於下面的條件:在拿到原函數指望的所有實參以前,咱們可以經過檢查將要被柯里化的函數的 length 屬性來得知柯里化須要迭代多少次。

假如你將該版本的 curry(..) 函數用在一個 length 屬性不明確的函數上 —— 函數的形參聲明包含默認形參值、形參解構,或者它是可變參數函數,用 ...args 當形參;參考第 2 章 —— 你將要傳入 arity 參數(做爲 curry(..) 的第二個形參)來確保 curry(..) 函數的正常運行。

咱們用 curry(..) 函數來實現此前的 ajax(..) 例子:

var curriedAjax = curry( ajax );

var personFetcher = curriedAjax( "http://some.api/person" );

var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } );

getCurrentUser( function foundUser(user){ /* .. */ } );複製代碼

如上,咱們每次函數調用都會新增一個實參,最終給原函數 ajax(..) 使用,直到收齊三個實參並執行 ajax(..) 函數爲止。

還記得前面講到爲數值列表的每一個值加 3 的那個例子嗎?回顧一下,因爲柯里化是和偏應用類似的,因此咱們能夠用幾乎相同的方式以柯里化來完成那個例子。

[1,2,3,4,5].map( curry( add )( 3 ) );
// [4,5,6,7,8]複製代碼

partial(add,3)curry(add)(3) 二者有什麼不一樣呢?爲何你會選 curry(..) 而不是偏函數呢?當你先得知 add(..) 是將要被調整的函數,但若是這個時候並不能肯定 3 這個值,柯里化可能會起做用:

var adder = curry( add );

// later
[1,2,3,4,5].map( adder( 3 ) );
// [4,5,6,7,8]複製代碼

讓咱們來看看另外一個有關數字的例子,此次咱們拿一個列表的數字作加法:

function sum(...args) {
    var sum = 0;
    for (let i = 0; i < args.length; i++) {
        sum += args[i];
    }
    return sum;
}

sum( 1, 2, 3, 4, 5 );                        // 15

// 好,咱們看看用柯里化怎麼作:
// (5 用來指定須要鏈式調用的次數)
var curriedSum = curry( sum, 5 );

curriedSum( 1 )( 2 )( 3 )( 4 )( 5 );        // 15複製代碼

這裏柯里化的好處是,每次函數調用傳入一個實參,並生成另外一個特定性更強的函數,以後咱們能夠在程序中獲取並使用那個新函數。而偏應用則是預先指定全部將被偏應用的實參,產出一個等待接收剩下全部實參的函數。

若是想用偏應用來每次指定一個形參,你得在每一個函數中逐次調用 partialApply(..) 函數。而被柯里化的函數能夠自動完成這個工做,這讓一次單獨傳遞一個參數變得更加符合人機工程學。

在 JavaScript 中,柯里化和偏應用都使用閉包來保存實參,直到收齊全部實參後咱們再執行原函數。

柯里化和偏應用有什麼用?

不管是柯里化風格(sum(1)(2)(3))仍是偏應用風格(partial(sum,1,2)(3)),它們的簽名比普通函數簽名奇怪得多。那麼,在適應函數式編程的時候,咱們爲何要這麼作呢?答案有幾個方面。

首先是顯而易見的理由,使用柯里化和偏應用能夠將指定分離實參的時機和地方獨立開來(遍佈代碼的每一處),而傳統函數調用則須要預先肯定全部實參。若是你在代碼某一處只獲取了部分實參,而後在另外一處肯定另外一部分實參,這個時候柯里化和偏應用就能派上用場。

另外一個最能體現柯里化應用的的是,當函數只有一個形參時,咱們可以比較容易地組合它們。所以,若是一個函數最終須要三個實參,那麼它被柯里化之後會變成須要三次調用,每次調用須要一個實參的函數。當咱們組合函數時,這種單元函數的形式會讓咱們處理起來更簡單。咱們將在後面繼續探討這個話題。

如何柯里化多個實參?

到目前爲止,我相信我給出的是咱們能在 JavaScript 中能獲得的,最精髓的柯里化定義和實現方式。

具體來講,若是簡單看下柯里化在 Haskell 語言中的應用,咱們會發現一個函數老是在一次柯里化調用中接收多個實參 —— 而不是接收一個包含多個值的元組(tuple,相似咱們的數組)實參。

在 Haskell 中的示例:

foo 1 2 3複製代碼

該示例調用了 foo 函數,而且根據傳入的三個值 123 獲得告終果。可是在 Haskell 中,函數會自動被柯里化,這意味着咱們傳入函數的值都分別傳入了單獨的柯里化調用。在 JS 中看起來則會是這樣:foo(1)(2)(3)。這和我此前講過的 curry(..) 風格一模一樣。

注意: 在 Haskell 中,foo (1,2,3) 不是把三個值看成單獨的實參一次性傳入函數,而是把它們包含在一個元組(相似 JS 數組)中做爲單獨實參傳入函數。爲了正常運行,咱們須要改變 foo 函數來處理做爲實參的元組。據我所知,在 Haskell 中咱們沒有辦法在一次函數調用中將所有三個實參獨立地傳入,而須要柯里化調用每一個函數。誠然,屢次調用對於 Haskell 開發者來講是透明的,但對 JS 開發者來講,這在語法上更加一目瞭然。

基於以上緣由,我認爲此前展現的 curry(..) 函數是一個對 Haskell 柯里化的可靠改編,我把它叫作 「嚴格柯里化」。

然而,咱們須要注意,大多數流行的 JavaScript 函數式編程庫都使用了一種並不嚴格的柯里化(loose currying)定義。

具體來講,每每 JS 柯里化實用函數會容許你在每次柯里化調用中指定多個實參。回顧一下以前提到的 sum(..) 示例,鬆散柯里化應用會是下面這樣:

var curriedSum = looseCurry( sum, 5 );

curriedSum( 1 )( 2, 3 )( 4, 5 );            // 15複製代碼

能夠看到,語法上咱們節省了()的使用,而且把五次函數調用減小成三次,間接提升了性能。除此以外,使用 looseCurry(..) 函數的結果也和以前更加狹義的 curry(..) 函數同樣。我猜便利性和性能因素是衆框架容許多實參柯里化的緣由。這看起來更像是品味問題。

注意: 鬆散柯里化容許你傳入超過形參數量(arity,原函數確認或指定的形參數量)的實參。若是你將函數的參數設計成可配的或變化的,那麼鬆散柯里化將會有利於你。例如,若是你將要柯里化的函數接收 5 個實參,鬆散柯里化依然容許傳入超過 5 個的實參(curriedSum(1)(2,3,4)(5,6)),而嚴格柯里化就不支持 curriedSum(1)(2)(3)(4)(5)(6)

咱們能夠將以前的柯里化實現方式調整一下,使其適應這種常見的更鬆散的定義:

function looseCurry(fn,arity = fn.length) {
    return (function nextCurried(prevArgs){
        return function curried(...nextArgs){
            var args = prevArgs.concat( nextArgs );

            if (args.length >= arity) {
                return fn( ...args );
            }
            else {
                return nextCurried( args );
            }
        };
    })( [] );
}複製代碼

如今每一個柯里化調用能夠接收一個或多個實參了(收集在 nextArgs 數組中)。至於這個實用函數的 ES6 箭頭函數版本,咱們就留做一個小練習,有興趣的讀者能夠模仿以前 curry(..) 函數的來完成。

反柯里化

你也會遇到這種狀況:拿到一個柯里化後的函數,卻想要它柯里化以前的版本 —— 這本質上就是想將相似 f(1)(2)(3) 的函數變回相似 g(1,2,3) 的函數。

不出意料的話,處理這個需求的標準實用函數一般被叫做 uncurry(..)。下面是簡陋的實現方式:

function uncurry(fn) {
    return function uncurried(...args){
        var ret = fn;

        for (let i = 0; i < args.length; i++) {
            ret = ret( args[i] );
        }

        return ret;
    };
}

// ES6 箭頭函數形式
var uncurry =
    fn =>
        (...args) => {
            var ret = fn;

            for (let i = 0; i < args.length; i++) {
                ret = ret( args[i] );
            }

            return ret;
        };複製代碼

警告: 請不要覺得 uncurry(curry(f))f 函數的行爲徹底同樣。雖然在某些庫中,反柯里化使函數變成和原函數(譯者注:這裏的原函數指柯里化以前的函數)相似的函數,可是凡事皆有例外,咱們這裏就有一個例外。若是你傳入原函數指望數量的實參,那麼在反柯里化後,函數的行爲(大多數狀況下)和原函數相同。然而,若是你少傳了實參,就會獲得一個仍然在等待傳入更多實參的部分柯里化函數。咱們在下面的代碼中說明這個怪異行爲。

function sum(...args) {
    var sum = 0;
    for (let i = 0; i < args.length; i++) {
        sum += args[i];
    }
    return sum;
}

var curriedSum = curry( sum, 5 );
var uncurriedSum = uncurry( curriedSum );

curriedSum( 1 )( 2 )( 3 )( 4 )( 5 );        // 15

uncurriedSum( 1, 2, 3, 4, 5 );                // 15
uncurriedSum( 1, 2, 3 )( 4 )( 5 );            // 15複製代碼

uncurry() 函數最爲常見的做用對象極可能並非人爲生成的柯里化函數(例如上文所示),而是某些操做所產生的已經被柯里化了的結果函數。咱們將在本章後面關於 「無形參風格」 的討論中闡述這種應用場景。

只要一個實參

設想你向一個實用函數傳入一個函數,而這個實用函數會把多個實參傳入函數,但可能你只但願你的函數接收單一實參。若是你有個相似咱們前面提到被鬆散柯里化的函數,它能接收多個實參,但你卻想讓它接收單一實參。那麼這就是我想說的狀況。

咱們能夠設計一個簡單的實用函數,它包裝一個函數調用,確保被包裝的函數只接收一個實參。既然實際上咱們是強制把一個函數處理成單參數函數(unary),那咱們索性就這樣命名實用函數:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

// ES6 箭頭函數形式
var unary =
    fn =>
        arg =>
            fn( arg );複製代碼

咱們此前已經和 map(..) 函數打過照面了。它調用傳入其中的 mapping 函數時會傳入三個實參:valueindexlist。若是你但願你傳入 map(..) 的 mapping 函數只接收一個參數,好比 value,你可使用 unary(..) 函數來操做:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

var adder = looseCurry( sum, 2 );

// 出問題了:
[1,2,3,4,5].map( adder( 3 ) );
// ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ...

// 用 `unary(..)` 修復後:
[1,2,3,4,5].map( unary( adder( 3 ) ) );
// [4,5,6,7,8]複製代碼

另外一種經常使用的 unary(..) 函數調用示例:

["1","2","3"].map( parseFloat );
// [1,2,3]

["1","2","3"].map( parseInt );
// [1,NaN,NaN]

["1","2","3"].map( unary( parseInt ) );
// [1,2,3]複製代碼

對於 parseInt(str,radix) 這個函數調用,若是 map(..) 函數調用它時在它的第二個實參位置傳入 index,那麼毫無疑問 parseInt(..) 會將 index 理解爲 radix 參數,這是咱們不但願發生的。而 unary(..) 函數建立了一個只接收第一個傳入實參,忽略其餘實參的新函數,這就意味着傳入 index 再也不會被誤解爲 radix 參數。

傳一個返回一個

說到只傳一個實參的函數,在函數式編程工具庫中有另外一種通用的基礎函數:該函數接收一個實參,而後什麼都不作,原封不動地返回實參值。

function identity(v) {
    return v;
}

// ES6 箭頭函數形式
var identity =
    v =>
        v;複製代碼

看起來這個實用函數簡單到了無處可用的地步。但即便是簡單的函數在函數式編程的世界裏也能發揮做用。就像演藝圈有句諺語:沒有小角色,只有小演員。

舉個例子,想象一下你要用正則表達式拆分(split up)一個字符串,但輸出的數組中可能包含一些空值。咱們可使用 filter(..) 數組方法(下文會詳細說到這個方法)來篩除空值,而咱們將 identity(..) 函數做爲 filter(..) 的斷言:

var words = " Now is the time for all... ".split( /\s|\b/ );
words;
// ["","Now","is","the","time","for","all","...",""]

words.filter( identity );
// ["Now","is","the","time","for","all","..."]複製代碼

既然 identity(..) 會簡單地返回傳入的值,而 JS 會將每一個值強制轉換爲 truefalse,這樣咱們就能在最終的數組裏對每一個值進行保存或排除。

小貼士: 像這個例子同樣,另一個能被用做斷言的單實參函數是 JS 自有的 Boolean(..) 方法,該方法會強制把傳入值轉爲 truefalse

另外一個使用 identity(..) 的示例就是將其做爲替代一個轉換函數(譯者注:transformation,這裏指的是對傳入值進行修改或調整,返回新值的函數)的默認函數:

function output(msg,formatFn = identity) {
    msg = formatFn( msg );
    console.log( msg );
}

function upper(txt) {
    return txt.toUpperCase();
}

output( "Hello World", upper );        // HELLO WORLD
output( "Hello World" );            // Hello World複製代碼

若是不給 output(..) 函數的 formatFn 參數設置默認值,咱們能夠叫出老朋友 partialRight(..) 函數:

var specialOutput = partialRight( output, upper );
var simpleOutput = partialRight( output, identity );

specialOutput( "Hello World" );        // HELLO WORLD
simpleOutput( "Hello World" );        // Hello World複製代碼

你也可能會看到 identity(..) 被看成 map(..) 函數調用的默認轉換函數,或者做爲某個函數數組的 reduce(..) 函數的初始值。咱們將會在第 8 章中提到這兩個實用函數。

恆定參數

Certain API 禁止直接給方法傳值,而要求咱們傳入一個函數,就算這個函數只是返回一個值。JS Promise 中的 then(..) 方法就是一個 Certain API。不少人聲稱 ES6 箭頭函數能夠看成這個問題的 「解決方案」。但我這有一個函數式編程實用函數能夠完美勝任該任務:

function constant(v) {
    return function value(){
        return v;
    };
}

// or the ES6 => form
var constant =
    v =>
        () =>
            v;複製代碼

這個微小而簡潔的實用函數能夠解決咱們關於 then(..) 的煩惱:

p1.then( foo ).then( () => p2 ).then( bar );

// 對比:

p1.then( foo ).then( constant( p2 ) ).then( bar );複製代碼

警告: 儘管使用 () => p2 箭頭函數的版本比使用 constant(p2) 的版本更簡短,但我建議你忍住別用前者。該箭頭函數返回了一個來自外做用域的值,這和 函數式編程的理念有些矛盾。咱們將會在後面第 5 章的 「減小反作用」 小節中提到這種行爲帶來的陷阱。

擴展在參數中的妙用

在第 2 章中,咱們簡要地講到了形參數組解構。回顧一下該示例:

function foo( [x,y,...args] ) {
    // ..
}

foo( [1,2,3] );複製代碼

foo(..) 函數的形參列表中,咱們指望接收單一數組實參,咱們要把這個數組拆解 —— 或者更貼切地說,擴展(spread out)—— 成獨立的實參 xy。除了頭兩個位置之外的參數值咱們都會經過 ... 操做將它們收集在 args 數組中。

當函數必須接收一個數組,而你卻想把數組內容當成單獨形參來處理的時候,這個技巧十分有用。

然而,有的時候,你沒法改變原函數的定義,但想使用形參數組解構。舉個例子,請思考下面的函數:

function foo(x,y) {
    console.log( x + y );
}

function bar(fn) {
    fn( [ 3, 9 ] );
}

bar( foo );            // 失敗複製代碼

你注意到爲何 bar(foo) 函數失敗了嗎?

咱們將 [3,9] 數組做爲單一值傳入 fn(..) 函數,但 foo(..) 指望接收單獨的 xy 形參。若是咱們能夠把 foo(..) 的函數聲明改變成 function foo([x,y]) { .. 那就好辦了。或者,咱們能夠改變 bar(..) 函數的行爲,把調用改爲 fn(...[3,9]),這樣就能將 39 分別傳入 foo(..) 函數了。

假設有兩個在此方法上互不兼容的函數,並且因爲各類緣由你沒法改變它們的聲明和定義。那麼你該如何一併使用它們呢?

爲了調整一個函數,讓它能把接收的單一數組擴展成各自獨立的實參,咱們能夠定義一個輔助函數:

function spreadArgs(fn) {
    return function spreadFn(argsArr) {
        return fn( ...argsArr );
    };
}

// ES6 箭頭函數的形式:
var spreadArgs =
    fn =>
        argsArr =>
            fn( ...argsArr );複製代碼

注意: 我把這個輔助函數叫作 spreadArgs(..),但一些庫,好比 Ramda,常常把它叫作 apply(..)

如今咱們可使用 spreadArgs(..) 來調整 foo(..) 函數,使其做爲一個合適的輸入參數並正常地工做:

bar( spreadArgs( foo ) );            // 12複製代碼

相信我,雖然我不能講清楚這些問題出現的緣由,但它們必定會出現的。本質上,spreadArgs(..) 函數使咱們可以定義一個藉助數組 return 多個值的函數,不過,它讓這些值仍然能分別做爲其餘函數的輸入參數來處理。

一個函數的輸出做爲另一個函數的輸入被稱做組合(composition),咱們將在第四章詳細討論這個話題。

儘管咱們在談論 spreadArgs(..) 實用函數,但咱們也能夠定義一下實現相反功能的實用函數:

function gatherArgs(fn) {
    return function gatheredFn(...argsArr) {
        return fn( argsArr );
    };
}

// ES6 箭頭函數形式
var gatherArgs =
    fn =>
        (...argsArr) =>
            fn( argsArr );複製代碼

注意: 在 Ramda 中,該實用函數被稱做 unapply(..),是與 apply(..) 功能相反的函數。我認爲術語 「擴展(spread)」 和 「彙集(gather)」 能夠把這兩個函數發生的事情解釋得更好一些。

由於有時咱們可能要調整一個函數,解構其數組形參,使其成爲另外一個分別接收單獨實參的函數,因此咱們能夠經過使用 gatherArgs(..) 實用函數來將單獨的實參彙集到一個數組中。咱們將在第 8 章中細說 reduce(..) 函數,這裏咱們簡要說一下:它重複調用傳入的 reducer 函數,其中 reducer 函數有兩個形參,如今咱們能夠將這兩個形參彙集起來:

function combineFirstTwo([ v1, v2 ]) {
    return v1 + v2;
}

[1,2,3,4,5].reduce( gatherArgs( combineFirstTwo ) );
// 15複製代碼

參數順序的那些事兒

對於多形參函數的柯里化和偏應用,咱們不得不經過許多使人懊惱的技巧來修正這些形參的順序。有時咱們把一個函數的形參順序定義成柯里化需求的形參順序,但這種順序沒有兼容性,咱們不得不絞盡腦汁來從新調整它。

讓人沮喪的可不只是咱們須要使用實用函數來委曲求全,在此以外,這種作法還會致使咱們的代碼被無關代碼混淆。這種東西就像碎紙片,這一片那一片的,而不是一整個突出問題,但這些問題的細碎絲絕不會減小它們帶來的苦惱。

難道就沒有能讓咱們從修正參數順序這件事裏解脫出來的方法嗎!?

在第 2 章裏,咱們講到了命名實參(named-argument)解構模式。回顧一下:

function foo( {x,y} = {} ) {
    console.log( x, y );
}

foo( {
    y: 3
} );                    // undefined 3複製代碼

咱們將 foo(..) 函數的第一個形參 —— 它被指望是一個對象 —— 解構成單獨的形參 xy。接着在調用時傳入一個對象實參,而且提供函數指望的屬性,這樣就能夠把 「命名實參」 映射到相應形參上。

命名實參主要的好處就是不用再糾結實參傳入的順序,所以提升了可讀性。咱們能夠發掘一下看看是否能設計一個等效的實用函數來處理對象屬性,以此提升柯里化和偏應用的可讀性:

function partialProps(fn,presetArgsObj) {
    return function partiallyApplied(laterArgsObj){
        return fn( Object.assign( {}, presetArgsObj, laterArgsObj ) );
    };
}

function curryProps(fn,arity = 1) {
    return (function nextCurried(prevArgsObj){
        return function curried(nextArgObj = {}){
            var [key] = Object.keys( nextArgObj );
            var allArgsObj = Object.assign( {}, prevArgsObj, { [key]: nextArgObj[key] } );

            if (Object.keys( allArgsObj ).length >= arity) {
                return fn( allArgsObj );
            }
            else {
                return nextCurried( allArgsObj );
            }
        };
    })( {} );
}複製代碼

咱們甚至不須要設計一個 partialPropsRight(..) 函數了,由於咱們根本不須要考慮屬性的映射順序,經過命名來映射形參徹底解決了咱們有關於順序的煩惱!

咱們這樣使用這些使用函數:

function foo({ x, y, z } = {}) {
    console.log( `x:${x} y:${y} z:${z}` );
}

var f1 = curryProps( foo, 3 );
var f2 = partialProps( foo, { y: 2 } );

f1( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3

f2( { z: 3, x: 1 } );
// x:1 y:2 z:3複製代碼

咱們不用再爲參數順序而煩惱了!如今,咱們能夠指定咱們想傳入的實參,而不用管它們的順序如何。不再須要相似 reverseArgs(..) 的函數或其它妥協了。贊!

屬性擴展

不幸的是,只有在咱們能夠掌控 foo(..) 的函數簽名,而且能夠定義該函數的行爲,使其解構第一個參數的時候,以上技術才能起做用。若是一個函數,其形參是各自獨立的(沒有通過形參解構),並且不能改變它的函數簽名,那咱們應該如何運用這個技術呢?

function bar(x,y,z) {
    console.log( `x:${x} y:${y} z:${z}` );
}複製代碼

就像以前的 spreadArgs(..) 實用函數同樣,咱們也能夠定義一個 spreadArgProps(..) 輔助函數,它接收對象實參的 key: value 鍵值對,並將其 「擴展」 成獨立實參。

不過,咱們須要注意某些異常的地方。咱們使用 spreadArgs(..) 函數處理數組實參時,參數的順序是明確的。然而,對象屬性的順序是不太明確且不可靠的。取決於不一樣對象的建立方式和屬性設置方式,咱們沒法徹底確認對象會產生什麼順序的屬性枚舉。

針對這個問題,咱們定義的實用函數須要讓你可以指定函數指望的實參順序(好比屬性枚舉的順序)。咱們能夠傳入一個相似 ["x","y","z"] 的數組,通知實用函數基於該數組的順序來獲取對象實參的屬性值。

這着實不錯,但仍是有點瑕疵,就算是最簡單的函數,咱們也免不了爲其增添一個由屬性名構成的數組。難道咱們就沒有一種能夠探知函數形參順序的技巧嗎?哪怕給一個普通而簡單的例子?還真有!

JavaScript 的函數對象上有一個 .toString() 方法,它返回函數代碼的字符串形式,其中包括函數聲明的簽名。先忽略其正則表達式分析技巧,咱們能夠經過解析函數字符串來獲取每一個單獨的命名形參。雖然這段代碼看起來有些粗暴,但它足以知足咱們的需求:

function spreadArgProps( fn, propOrder = fn.toString() .replace( /^(?:(?:function.*\(([^]*?)\))|(?:([^\(\)]+?)\s*=>)|(?:\(([^]*?)\)\s*=>))[^]+$/, "$1$2$3" ) .split( /\s*,\s*/ ) .map( v => v.replace( /[=\s].*$/, "" ) ) ) {
    return function spreadFn(argsObj) {
        return fn( ...propOrder.map( k => argsObj[k] ) );
    };
}複製代碼

注意: 該實用函數的參數解析邏輯並不是無懈可擊,使用正則來解析代碼這個前提就已經很不靠譜了!但處理通常狀況是咱們的惟一目標,從這點來看這個實用函數仍是恰到好處的。咱們須要的只是對簡單形參(包括帶默認值的形參)函數的形參順序作一個恰當的默認檢測。例如,咱們的實用函數不須要把複雜的解構形參給解析出來,由於不管如何咱們不太可能對擁有這種複雜形參的函數使用 spreadArgProps() 函數。所以該邏輯能搞定 80% 的需求,它容許咱們在其它不能正確解析複雜函數簽名的狀況下覆蓋 propOrder 數組形參。這是本書儘量尋找的一種實用性平衡。

讓咱們看看 spreadArgProps(..) 實用函數是怎麼用的:

function bar(x,y,z) {
    console.log( `x:${x} y:${y} z:${z}` );
}

var f3 = curryProps( spreadArgProps( bar ), 3 );
var f4 = partialProps( spreadArgProps( bar ), { y: 2 } );

f3( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3

f4( { z: 3, x: 1 } );
// x:1 y:2 z:3複製代碼

提個醒:本文中呈現的對象形參(object parameters)和命名實參(named arguments)模式,經過減小由調整實參順序帶來的干擾,明顯地提升了代碼的可讀性,不過據我所知,沒有哪一個主流的函數式編程庫使用該方案。因此你會看到該作法與大多數 JavaScript 函數式編程很不同.

此外,使用在這種風格下定義的函數要求你知道每一個實參的名字。你必須記住:「這個函數形參叫做 ‘fn’ 」,而不是隻記得:「噢,把這個函數做爲第一個實參傳進去」。

請當心地權衡它們。

無形參風格

在函數式編程的世界中,有一種流行的代碼風格,其目的是經過移除沒必要要的形參-實參映射來減小視覺上的干擾。這種風格的正式名稱爲 「隱性編程(tacit programming)」,通常則稱做 「無形參(point-free)」 風格。術語 「point」 在這裏指的是函數形參。

警告: 且慢,先說明咱們此次的討論是一個有邊界的提議,我不建議你在函數式編程的代碼裏不惜代價地濫用無形參風格。該技術是用於在適當狀況下提高可讀性。但你徹底可能像濫用軟件開發裏大多數東西同樣濫用它。若是你因爲必須遷移到無參數風格而讓代碼難以理解,請打住。你不會所以得到小紅花,由於你用看似聰明但晦澀難懂的方式抹除形參這個點的同時,還抹除了代碼的重點。

咱們從一個簡單的例子開始:

function double(x) {
    return x * 2;
}

[1,2,3,4,5].map( function mapper(v){
    return double( v );
} );
// [2,4,6,8,10]複製代碼

能夠看到 mapper(..) 函數和 double(..) 函數有相同(或相互兼容)的函數簽名。形參(也就是所謂的 「point「)v 能夠直接映射到 double(..) 函數調用裏相應的實參上。這樣,mapper(..) 函數包裝層是非必需的。咱們能夠將其簡化爲無形參風格:

function double(x) {
    return x * 2;
}

[1,2,3,4,5].map( double );
// [2,4,6,8,10]複製代碼

回顧以前的一個例子:

["1","2","3"].map( function mapper(v){
    return parseInt( v );
} );
// [1,2,3]複製代碼

該例中,mapper(..) 實際上起着重要做用,它排除了 map(..) 函數傳入的 index 實參,由於若是不這麼作的話,parseInt(..) 函數會錯把 index 看成 radix 來進行整數解析。該例子中咱們能夠藉助 unary(..) 函數:

["1","2","3"].map( unary( parseInt ) );
// [1,2,3]複製代碼

使用無形參風格的關鍵,是找到你代碼中,有哪些地方的函數直接將其形參做爲內部函數調用的實參。以上提到的兩個例子中,mapper(..) 函數拿到形參 v 單獨傳入了另外一個函數調用。咱們能夠藉助 unary(..) 函數將提取形參的邏輯層替換成無參數形式表達式。

警告: 你可能跟我同樣,已經嘗試着使用 map(partialRight(parseInt,10)) 來將 10 右偏應用爲 parseInt(..)radix 實參。然而,就像咱們以前看到的那樣,partialRight(..) 僅僅保證將 10 看成最後一個實參傳入原函數,而不是將其指定爲第二個實參。由於 map(..) 函數自己會將 3 個實參(valueindexarr)傳入它的映射函數,因此 10 就會被當成第四個實參傳入 parseInt(..) 函數,而這個函數只會對頭兩個實參做出反應。

來看另外一個例子:

// 將 `console.log` 當成一個函數使用
// 便於避免潛在的綁定問題

function output(txt) {
    console.log( txt );
}

function printIf( predicate, msg ) {
    if (predicate( msg )) {
        output( msg );
    }
}

function isShortEnough(str) {
    return str.length <= 5;
}

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf( isShortEnough, msg1 );            // Hello
printIf( isShortEnough, msg2 );複製代碼

如今,咱們要求當信息足夠長時,將它打印出來,換而言之,咱們須要一個 !isShortEnough(..) 斷言。你可能會首先想到:

function isLongEnough(str) {
    return !isShortEnough( str );
}

printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 );            // Hello World複製代碼

這太簡單了...但如今咱們的重點來了!你看到了 str 形參是如何傳遞的嗎?咱們可否不經過從新實現 str.length 的檢查邏輯,而重構代碼並使其變成無形參風格呢?

咱們定義一個 not(..) 取反輔助函數(在函數式編程庫中又被稱做 complement(..)):

function not(predicate) {
    return function negated(...args){
        return !predicate( ...args );
    };
}

// ES6 箭頭函數形式
var not =
    predicate =>
        (...args) =>
            !predicate( ...args );複製代碼

接着,咱們使用 not(..) 函數來定義無形參的 isLongEnough(..) 函數:

var isLongEnough = not( isShortEnough );

printIf( isLongEnough, msg2 );            // Hello World複製代碼

目前爲止已經不錯了,但還能更進一步。咱們實際上能夠將 printIf(..) 函數自己重構成無形參風格。

咱們能夠用 when(..) 實用函數來表示 if 條件句:

function when(predicate,fn) {
    return function conditional(...args){
        if (predicate( ...args )) {
            return fn( ...args );
        }
    };
}

// ES6 箭頭函數形式
var when =
    (predicate,fn) =>
        (...args) =>
            predicate( ...args ) ? fn( ...args ) : undefined;複製代碼

咱們把本章前面講到的另外一些輔助函數和 when(..) 函數結合起來搞定無形參風格的 printIf(..) 函數:

var printIf = uncurry( rightPartial( when, output ) );複製代碼

咱們是這麼作的:將 output 方法右偏應用爲 when(..) 函數的第二個(fn 形參)實參,這樣咱們獲得了一個仍然指望接收第一個實參(predicate 形參)的函數。當該函數被調用時,會產生另外一個指望接收(譯者注:須要被打印的)信息字符串的函數,看起來就是這樣:fn(predicate)(str)

多個(兩個)鏈式函數的調用看起來很挫,就像被柯里化的函數。因而咱們用 uncurry(..) 函數處理它,獲得一個指望接收 strpredicate 兩個實參的函數,這樣該函數的簽名就和 printIf(predicate,str) 原函數同樣了。

咱們把整個例子覆盤一下(假設咱們本章已經講解的實用函數都在這裏了):

function output(msg) {
    console.log( msg );
}

function isShortEnough(str) {
    return str.length <= 5;
}

var isLongEnough = not( isShortEnough );

var printIf = uncurry( partialRight( when, output ) );

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf( isShortEnough, msg1 );            // Hello
printIf( isShortEnough, msg2 );

printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 );            // Hello World複製代碼

希望無形參風格編程的函數式編程實踐逐漸變得更有意義。你仍然能夠經過大量實踐來訓練本身,讓本身接受這種風格。再次提醒,請三思然後行,掂量一下是否值得使用無形參風格編程,以及使用到什麼程度會益於提升代碼的可讀性。

有形參仍是無形參,你怎麼選?

注意: 還有什麼無形參風格編程的實踐呢?咱們將在第 4 章的 「回顧形參」 小節裏,站在新學習的組合函數知識之上來回顧這個技術。

總結

偏應用是用來減小函數的參數數量 —— 一個函數指望接收的實參數量 —— 的技術,它減小參數數量的方式是建立一個預設了部分實參的新函數。

柯里化是偏應用的一種特殊形式,其參數數量下降爲 1,這種形式包含一串連續的鏈式函數調用,每一個調用接收一個實參。當這些鏈式調用指定了全部實參時,原函數就會拿到收集好的實參並執行。你一樣能夠將柯里化還原。

其它相似 unary(..)identity(..) 以及 constant(..) 的重要函數操做,是函數式編程基礎工具庫的一部分。

無形參是一種書寫代碼的風格,這種風格移除了非必需的形參映射實參邏輯,其目的在於提升代碼的可讀性和可理解性。

【上一章】翻譯連載 |《JavaScript 輕量級函數式編程》- 第 2 章:函數基礎
【下一章】翻譯連載 |《你不知道的JS》姊妹篇 |《JavaScript 輕量級函數式編程》- 第4章:組合函數

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息