「前端面試題系列6」理解函式的柯里化

Micherwa發表於2019-02-18

前言

這是前端面試題系列的第 6 篇,你可能錯過了前面的篇章,可以在這裡找到:

最近,朋友T 在準備面試,他為一道程式設計題所困,向我求助。原題如下:

// 寫一個 sum 方法,當使用下面的語法呼叫時,能正常工作
console.log(sum(2, 3)); // Outputs 5
console.log(sum(2)(3)); // Outputs 5
複製程式碼

這道題要考察的,就是對函式柯里化的理解。讓我們先來解析一下題目的要求:

  • 如果傳遞兩個引數,我們只需將它們相加並返回。
  • 否則,我們假設它是以sum(2)(3)的形式被呼叫的,所以我們返回一個匿名函式,它將傳遞給sum()(在本例中為2)的引數和傳遞給匿名函式的引數(在本例中為3)。

所以,sum 函式可以這樣寫:

function sum (x) {
    if (arguments.length == 2) {
        return arguments[0] + arguments[1];
    }
    
    return function(y) {
        return x + y;
    }
}
複製程式碼

arguments 的用法挺靈活的,在這裡它則用於分割兩種不同的情況。當引數只有一個的時候,進行柯里化的處理。

那麼,到底什麼是函式的柯里化呢?接下來,我們將從概念出發,探究函式柯里化的實現與用途。

什麼是柯里化

柯里化,是函數語言程式設計的一個重要概念。它既能減少程式碼冗餘,也能增加可讀性。另外,附帶著還能用來裝逼。

先給出柯里化的定義:在數學和電腦科學中,柯里化是一種將使用多個引數的一個函式轉換成一系列使用一個引數的函式的技術。

柯里化的定義,理解起來有點費勁。為了更好地理解,先看下面這個例子:

function sum (a, b, c) {
    console.log(a + b + c);
}
sum(1, 2, 3); // 6
複製程式碼

毫無疑問,sum 是個簡單的累加函式,接受3個引數,輸出累加的結果。

假設有這樣的需求,sum的前2個引數保持不變,最後一個引數可以隨意。那麼就會想到,在函式內,是否可以把前2個引數的相加過程,給抽離出來,因為引數都是相同的,沒必要每次都做運算。

如果先不管函式內的具體實現,呼叫的寫法可以是這樣: sum(1, 2)(3); 或這樣 sum(1, 2)(10); 。就是,先把前2個引數的運算結果拿到後,再與第3個引數相加。

這其實就是函式柯里化的簡單應用。

柯里化的實現

sum(1, 2)(3); 這樣的寫法,並不常見。拆開來看,sum(1, 2) 返回的應該還是個函式,因為後面還有 (3) 需要執行。

那麼反過來,從最後一個引數,從右往左看,它的左側必然是一個函式。以此類推,如果前面有n個(),那就是有n個函式返回了結果,只是返回的結果,還是一個函式。是不是有點遞迴的意思?

網上有一些不同的柯里化的實現方式,以下是個人覺得最容易理解的寫法:

function curry (fn, currArgs) {
    return function() {
        let args = [].slice.call(arguments);

        // 首次呼叫時,若未提供最後一個引數currArgs,則不用進行args的拼接
        if (currArgs !== undefined) {
            args = args.concat(currArgs);
        }

        // 遞迴呼叫
        if (args.length < fn.length) {
            return curry(fn, args);
        }

        // 遞迴出口
        return fn.apply(null, args);
    }
}
複製程式碼

解析一下 curry 函式的寫法:

首先,它有 2 個引數,fn 指的就是本文一開始的源處理函式 sum。currArgs 是呼叫 curry 時傳入的引數列表,比如 (1, 2)(3) 這樣的。

再看到 curry 函式內部,它會整個返回一個匿名函式。

再接下來的 let args = [].slice.call(arguments);,意思是將 arguments 陣列化。arguments 是一個類陣列的結構,它並不是一個真的陣列,所以沒法使用陣列的方法。我們用了 call 的方法,就能愉快地對 args 使用陣列的原生方法了。在這篇 「乾貨」細說 call、apply 以及 bind 的區別和用法 中,有關於 call 更詳細的用法介紹。

currArgs !== undefined 的判斷,是為了解決遞迴呼叫時的引數拼接。

最後,判斷 args 的個數,是否與 fn (也就是 sum )的引數個數相等,相等了就可以把引數都傳給 fn,進行輸出;否則,繼續遞迴呼叫,直到兩者相等。

測試一下:

function sum(a, b, c) {
    console.log(a + b + c);
}

const fn = curry(sum);

fn(1, 2, 3); // 6
fn(1, 2)(3); // 6
fn(1)(2, 3); // 6
fn(1)(2)(3); // 6
複製程式碼

都能輸出 6 了,搞定!

柯里化的用途

理解了柯里化的實現之後,讓我們來看一下它的實際應用。柯里化的目的是,減少程式碼冗餘,以及增加程式碼的可讀性。來看下面這個例子:

const persons = [
    { name: 'kevin', age: 4 },
    { name: 'bob', age: 5 }
];

// 這裡的 curry 函式,之前已實現
const getProp = curry(function (obj, index) {
    const args = [].slice.call(arguments);
    return obj[args[args.length - 1]];
});

const ages = persons.map(getProp('age')); // [4, 5]
const names = persons.map(getProp('name')); // ['kevin', 'bob']
複製程式碼

在實際的業務中,我們常會遇到類似的列表資料。用 getProp 就可以很方便地,取出列表中某個 key 對應的值。

需要注意的是,const names = persons.map(getProp('name')); 執行這條語句時 getProp 的引數只有一個 name,而定義 getProp 方法時,傳入 curry 的引數有2個,objindex(這裡必須寫 2 個及以上的引數)。

為什麼要這麼寫?關鍵就在於 arguments 的隱式傳參。

const getProp = curry(function (obj, index) {
    console.log(arguments);
    // 會輸出4個類陣列,取其中一個來看
    // {
    //     0: {name: "kevin", age: 4},
    //     1: 0,
    //     2: [
    //         {name: "kevin", age: 4},
    //         {name: "bob", age: 5}
    //     ],
    //     3: "age"
    // }
});
複製程式碼

map 是 Array 的原生方法,它的用法如下:

var new_array = arr.map(function callback(currentValue[, index[, array]]) {
    // Return element for new_array
}[, thisArg]);
複製程式碼

所以,我們傳入的 name,就排在了 arguments 的最後。為了拿到 name 對應的值,需要對類陣列 arguments 做點轉換,讓它可以使用 Array 的原生方法。所以,最終 getProp 方法定義成了這樣:

const getProp = curry(function (obj, index) {
    const args = [].slice.call(arguments);
    return obj[args[args.length - 1]];
});
複製程式碼

當然,還有另外一種寫法,curry 的實現更好理解,但是呼叫的程式碼卻變多了,大家可以根據實際情況進行取捨。

const getProp = curry(function (key, obj) {
    return obj[key];
});

const ages = persons.map(item => {
    return getProp(item)('age');
});
const names = persons.map(item => {
    return getProp(item)('name');
});
複製程式碼

最後,來看一個 Memoization 的例子。它用於優化比較耗時的計算,通過將計算結果快取到記憶體中,這樣對於同樣的輸入值,下次只需要中記憶體中讀取結果。

function memoizeFunction(func) {
    const cache = {};
    return function() {
        let key = arguments[0];
        if (cache[key]) {
            return cache[key];
        } else {
            const val = func.apply(null, arguments);
            cache[key] = val;
            return val;
        }
    };
}

const fibonacci = memoizeFunction(function(n) {
    return (n === 0 || n === 1) ? n : fibonacci(n - 1) + fibonacci(n - 2);
});

console.log(fibonacci(100)); // 輸出354224848179262000000
console.log(fibonacci(100)); // 輸出354224848179262000000
複製程式碼

程式碼中,第2次計算 fibonacci(100) 則只需要在記憶體中直接讀取結果。

總結

函式的柯里化,是 Javascript 中函數語言程式設計的一個重要概念。它返回的,是一個函式的函式。其實現方式,需要依賴引數以及遞迴,通過拆分引數的方式,來呼叫一個多引數的函式方法,以達到減少程式碼冗餘,增加可讀性的目的。

雖然一開始理解起來有點雲裡霧裡的,但一旦理解了其中的含義和具體的使用場景,用起來就會得心應手了。

PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。

「前端面試題系列6」理解函式的柯里化

相關文章