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

iKcamp發表於2017-08-27

關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。

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

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

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

在那個討論中,我建議儘可能設計單一形參的函式。但實際上你不能每次都做到,而且也不能每次都掌控你的函式簽名(譯者注:JS 中,函式簽名一般包含函式名和形參等函式關鍵資訊,例如 foo(a, b = 1, c))。

現在,我們把注意力放在更復雜、強大的模式上,以便討論處在這些場景下的函式輸入。

立即傳參和稍後傳參

如果一個函式接收多個實參,你可能會想先指定部分實參,餘下的稍後再指定。

來看這個函式:

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

想象一個場景,你要發起多個已知 URL 的 API 請求,但這些請求的資料和處理響應資訊的回撥函式要稍後才能知道。

當然,你可以等到這些東西都確定後再發起 ajax(..) 請求,並且到那時再引用全域性 URL 常量。但我們還有另一種選擇,就是建立一個已經預設 url 實參的函式引用。

我們將建立一個新函式,其內部仍然發起 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前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

相關文章