JavaScript的函數式特性

前言

作爲一門面向對象的語言,JavaScript本身卻具有明顯的函數式語言特徵。而這也是很多JavaScript的支持者鍾愛它的原因之一 – 函數式特性爲這門語言帶來了極大的靈活性。高階函數、偏函數、函數柯里化、閉包這些概念都不同程度地依賴JavaScript的函數式特性。下面我們就來了解一下JavaScript的函數式特性,以及上述與函數式特性息息相關的重要概念。

什麼是函數式特性?

目前計算機領域的編程語言按設計思想可以大致分爲三類:面向過程的語言、面向對象的語言和函數式語言。

最典型的面向過程的語言就是C語言,它強調解決一個問題需要經過什麼樣的過程,並把某些具有特定規律的過程抽象成算法。C語言開發者最樸素的想法就是,解決一個問題需要哪些步驟,而函數、結構體這些概念都服務於這些步驟。函數在C語言中更多是作爲工具存在的,比如:

int add(int a, int b){
  return a + b;
}

int a = 1;
int b = 2;
int sum = add(a, b);
printf("%d", sum); 

面嚮對象語言最典型的就是Java – 一門完全的面嚮對象語言,也是目前最爲流行的語言之一。面向對象的思想是把所有的實體都抽象成對象(比如一輛車可以抽象成一個對象),同一類對象的集合又抽象成類。每個對象有自己的屬性和方法,屬性用於描述對象的某些特性(如車的顏色、重量等),方法用於描述對象的行爲(如移動、停止等),對象之間會產生聯繫和相互作用。JavaScript也是一門面向對象的語言,但與Java存在一個重要的差別:Java中的函數通常作爲對象的方法存在,而JavaScript的函數本身就是對象(所以可以說,函數在JavaScript中的地位比在Java中更高)。

而對於函數式語言來說,函數是一等公民。它把所有的過程都抽象成一系列函數的嵌套組合,所有的數據結構都服務於函數。從某些方面來說,函數式編程與面向過程的編程具有一定的相似性,兩者都強調過程,但函數式編程會把所有的過程封裝成函數,而不是將其視爲工具。

簡單介紹了三種設計思想後我們看到,一門語言屬於哪種類型主要取決於它採用哪種思維方式。雖然使用同一門語言的開發者傾向於採用同一種思維方式,但也有例外,C++就是一個很好的例子。它完全兼容C語言,也就意味着開發者可以以面向過程的思維方式進行開發(甚至直接採用C語言);同時它也支持類和對象,開發者也可以選擇面向對象的思維方式進行開發。類似的,JavaScript中函數也是對象的語言特性使得它可以從函數式語言中借鑑到很多技巧,高階函數就是其函數式特性的集中體現。

那什麼是高階函數呢?這是一個比較廣義的概念,在JavaScript中,只要一個函數允許接收其他函數作爲參數,或者可以返回一個函數,那麼這個函數就被稱爲高階函數。這類函數在Java中是不允許的,在C++中需要藉助函數指針來實現,但是在函數式語言(如Lisp)中,這就是最基本的操作(因爲函數封裝了過程,而函數式編程的基本思路就是過程的組裝和嵌套)。舉個例子:

function f(a, b){
  return a + b;
}

function g(func){
  func();
}

g(f);

這裏的g就是一個高階函數,因爲它以另一個函數作爲參數(當然我們一般不會這樣使用高階函數,這樣封裝g幾乎毫無意義)。

如果你認爲高階函數一般是高級JavaScript開發者纔會用到的那就錯了。實際上我們最常用的addEventListener、map、sort這些函數都是高階函數,此外我們經常使用的閉包,也是在封裝一個高階函數。

下面來看一下一些常見的高階函數應用。

高階函數的應用

下面的案例只是個人分類,請勿直接引用。

1. 回調函數

回調函數模式可能是JavaScript開發者最基本的思維方式之一了。比如下面的例子:

let button = document.querySelector("#add");
button.addEventListener("click", function(e){
  ...  //處理用戶點擊事件
})

這裏就是在調用DOM節點對象button原型上的原生高階方法addEventListener,我們傳入的回調函數會在用戶點擊按鈕時被瀏覽器執行。注意,這裏我們傳入的不過是一個匿名函數罷了,它的調用發生在事件循環中。由於接收函數作爲參數,顯然addEventListener是一個高階函數。

2. 通用函數封裝

在Java中如果要實現一個通用的數組排序方法,通常需要對函數進行重載,也就是需要定義該方法的不同版本,來用於不同的需求。但是在JavaScript中我們不需要這麼做(事實上由於沒有函數簽名,JavaScript也不支持重載)。我們知道,JavaScript的數組原型上只有一個通用的sort方法,對於不同的排序需求,我們只需要傳入不同的排序函數即可實現。比如:

var arr = [5, 8, 2];

// ascArr爲從小到大排序的結果
var ascArr = arr.sort(function(a, b){
  return a - b;
})

// descArr爲從大到小排序的結果
var descArr = arr.sort(function(a, b){
  return b - a;
})

傳入sort的函數是我們定義的排序規則。現在假設瀏覽器正在對數組元素5和8進行排序(可能是使用冒泡排序,也可能是歸併排序等任意排序方法,它們都一定會涉及到兩個元素的比較問題)。瀏覽器需要知道5和8哪個值應該排在前面,於是它調用我們傳入的函數,以5和8爲參數,結果得到一個負數,js引擎就知道,第一個參數(也就是5)應該排在前面。如果返回正數,那麼8會被排在前面(這是接口所規定的)。

除了比較一般的數字,它還可以用來比較字符串,如:

var arr = ['sdfer', 'wre'];
arr.sort(function(a, b){
  if(a <= b){
    return -1;
  }else{
    return 1;
  }
})

原理很簡單,我們定義了a <= b時返回-1,這表示我們希望這種情況下a應該排在b前面,否則a應該排在b後面。字符串大小的比較規則是以字符的ASCII值爲依據的。同樣的,sort還可以用來給對象數組排序,只要我們定義了排序規則即可,如:

var arr = [
  {name: "bbb", age: 24},
  {name: "aaa", age: 21},
  {name: "ccc", age: 26},
]
//按照名字的字母正序排序,這裏"aaa" < "bbb" < "ccc"
var arrName = arr.sort(function(obj1, obj2){
  if(obj1.name <= obj2.name){
    return -1;
  } else {
    return 1;
  }
})
//按年齡從低到高來排序
var arrAge = arr.sort(function(obj1, obj2){
  return obj1.age - obj2.age;
})

顯然,你可以以任意的規則來對數組進行排序,你甚至可以決定讓數字全部排在字母的前面,或者以自定義的規則對中文排序,只要你的規則傳入任意兩個元素都可以返回一個數值即可。對於js引擎而言,sort函數只定義排序算法,不關心排序規則。對於開發者而言,sort採用什麼算法並不重要,但是需要定義“大小”規則。

兩者的解耦正是這種情況下使用高階函數的精華所在。數組的很多原型方法如map、forEach、filter都是這個原理。它們既幫開發者封裝了有用的工具方法,又給了開發者足夠的靈活性。

3. 偏函數

偏函數是JavaScript中相對高級一些的概念,它將一個接收很多參數的函數轉化爲只接收較少參數的函數,主要目的是降低函數的通用性,提高函數的適用性。

舉個例子,假設我們有一個可以計算三個數乘積的函數:

function multiple(a, b, c){
  if(typeof a === 'number' && 
          typeof b === 'number' && 
          typeof c === 'number'){
          
    return a * b * c;
  } else {
    return null;
  }
}

這個函數的通用性很好,只要傳入三個數字,就可以計算他們的乘積。但是假如在某個模塊中我需要計算圓形的周長(計算公式爲C = 2 * π * r),顯然2和π都是固定的數字,如果每次計算周長時都需要傳入,其實並沒有必要。但是我們又不想重寫一個專門計算周長的函數(因爲實際中的函數可能很複雜,我們必須考慮如何複用已有的函數),所以我們可以像下面這樣把multiple包裝一下:

function createComputeC(func, num1, num2){
  return function(radius){
    return func(num1, num2, radius);
  }
}
//調用該函數,傳入通用函數multiple和兩個固定參數,
//得到一個專門用於計算圓周長的函數
var computeC = createComputeC(multiple, 2, Math.PI);

//計算半徑爲2的圓的周長
computeC(2);

createComputeC接收原始的函數multiple和兩個固定參數,返回了一個專門用於計算圓的周長的函數。於是現在我們在任何地方只需要調用computeC,傳入一個半徑,立即就可以計算出圓的周長。

想象一下,假如這是一個可以接收七八個參數的通用函數,而在某種用途中只需要傳入一兩個參數,使用上面的方法生成一個專用函數可以大大減輕我們的負擔。

不過偏函數的價值並不限於此。在Vue 2.6的編譯器源碼中有一種更典型的用法,那就是跨平臺代碼的解耦。我們知道,Vue可以在瀏覽器、服務端和weex三個平臺下運行,而同樣的模板在不同平臺下進行編譯時行爲並不完全一致。假如Vue爲每個平臺都單獨維護一個編譯器,由於三者的核心邏輯相差不大,這三個編譯器將存在大量重複的代碼。如果代碼發生變更,整個項目將幾乎無法維護。

爲了避免這種情況,Vue爲各個平臺定義一個核心版本的編譯器,它是一個通用函數,接收模板和一個配置對象options作爲參數。但是在覈心模塊中並不傳入這個配置對象,而是向外暴露這個通用函數。各個平臺模塊都會導入這個核心版本的編譯器(也就是一個函數),向它注入當前平臺的環境參數,從而得到該平臺下一個完整的編譯器。

這個模式下,核心版本中只需要維護一個通用函數,各個跨平臺的模塊引入這個函數後注入環境參數得到專用編譯器,從而實現了核心實現與平臺兼容的解耦,便於分別維護。

4. 函數柯里化

柯里化,英語:Currying,是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回一個接受餘下參數的新函數的技術。

假如一個函數接收三個參數,那麼將它柯里化後,將得到一個只接受第一個參數的函數,並且這個函數的返回值是一個可以接收剩餘兩個參數的函數,這是柯里化最簡單的例子。

舉個例子說明,仍以上面的multiple函數爲例:

function multiple(a, b, c){
  return a * b * c;
}

function curringMultiple(a){
  return function(b, c){
    return a * b * c;
  }
}

乍一看上去和偏函數非常相似。實際上這個函數本身就是一個偏函數,但是它比一般的偏函數要更嚴格。它嚴格規定這個函數必須只能接收原來的第一個參數,剩餘的參數需要交給返回的函數來處理(不過JavaScript中所說的柯里化往往沒有這麼嚴格)。把上面的multiple轉化成curringMultiple的過程就稱爲函數的柯里化。

經過上述修改,你可以這樣調用柯里化後的函數:

curringMultiple(1)(2, 3);

內部返回的函數還可以進一步柯里化:

function deepCurring(a){
  return function(b){
    return function(c){
      return a * b * c;
    }
  }
}

現在你可以這樣調用函數:

deepCurring(1)(2)(3);

也可以不一次得到最後結果:

var res1 = deepCurring(1);

...   //執行某些其他操作
var res2 = res1(2);

...   //執行某些其他操作
var res = res2(3);

console.log(res);  //輸出6

上面的例子是通過直接修改原來函數的實現來進行柯里化的,但不一定要這樣做(而且也很少這樣做)。大多數情況下,我們會封裝一個函數對原函數進行柯里化,如下面的例子:

//用於將一個函數柯里化的函數
function curry(fn, args) {
    var length = fn.length;
    var args = args || [];
    return function(){
        newArgs = args.concat(Array.prototype.slice.call(arguments));
        if(newArgs.length < length){
            return curry.call(this,fn,newArgs);
        }else{
            return fn.apply(this,newArgs);
        }
    }
}
//被用於柯里化的測試函數
function multiFn(a, b, c) {
    return a * b * c;
}

var multi = curry(multiFn); //執行柯里化
//下面的調用都可以返回正確的結果
multi(1)(2)(3);
multi(1, 2)(3);
multi(1, 2, 3);
multi()(1)(2, 3);

var multi2 = curry(multiFn, 1);
multi2(2, 3);
...

這個函數接收一個需要柯里化的函數作爲參數,同時允許傳入若干個參數,然後返回一個新的函數,返回的函數可以接收剩餘的一個或多個參數。這樣實際上每次都可以只傳入任意多個參數,剩餘的參數可以等到合適的時候再傳入。我們可以看到,一個多參數的函數經過這樣的改造,傳參的過程將變得極其靈活(實際上這裏不能算嚴格的柯里化,因爲改造後的結果每次都可以接收不止一個參數。柯里化之所以要求如此嚴格,是因爲它在函數式編程中非常有利於函數分析,但是在這裏,不完全嚴格的實現更加靈活)。

柯里化有以下三個用途:

  1. 參數複用
  2. 提前確認
  3. 延遲執行

函數柯里化後,某些參數會被預先傳入參數,之後不需要重新傳,類似於偏函數的參數固定,這稱爲參數複用。

對於提前確認,我們舉一個封裝監聽器的例子:

var on = function(element, event, handler){
  if (document.addEventListener) {
        if (element && event && handler) {
            element.addEventListener(event, handler, false);
        }
    } else {
        if (element && event && handler) {
            element.attachEvent('on' + event, handler);
        }
    }
}

我們封裝了一個on方法,它可以兼容addEventListener和attachEvent兩個原生的綁定監聽器的方法,這樣的需求在jQuery這樣的框架中很常見。但是這裏的問題是,每次調用on來綁定監聽器,函數都需要判斷document.addEventListener是否存在。這實際上沒有必要,因爲在同一個環境下,這樣的檢查只進行一次就可以了。於是上面的函數可以改造成下面的樣子:

var on = (function() {
    if (document.addEventListener) {
        return function(element, event, handler) {
            if (element && event && handler) {
                element.addEventListener(event, handler, false);
            }
        };
    } else {
        return function(element, event, handler) {
            if (element && event && handler) {
                element.attachEvent('on' + event, handler);
            }
        };
    }
})();

之前的邏輯被封裝在了一個匿名函數內,它會在框架第一次被加載時執行,然後根據document.addEventListener是否存在返回不同的兩個函數,前一個是document.addEventListener存在的版本,後一個則是它不存在的版本。相當於在生成on函數之前,我們提前判斷了document.addEventListener是否存在,根據判斷結果返回不同環境下的on函數,避免了調用on時進行判斷的性能損耗。

表面上看這個函數並不是原來的on函數柯里化的結果,但它可以看做另一個版本的on函數的柯里化結果:

var addExist = !!document.addEventListener;
var on = function(addExist, element, event, handler){
  if(addExist){
    ...
  } else {
    ...
  }
}

所以柯里化的運用還是需要一定的技巧的。

關於第三個好處,延遲執行,在我看來是JavaScript靈活性的一種體現。在調用一個函數時,你可以在任何時候傳入任意數量的參數,而不是每次都必須傳入所有參數並立即執行。

5. 閉包

在大多數情況下,閉包都是通過返回一個函數來實現的,所以很顯然,它是高階函數的一種應用場景。我們來看一個例子:

var multi = (function(){
  var a = 1;
  var b = 2;
  return function(c){
    return a * b * c;
  }
})();

multiple(3);

上述( function(){} )()的寫法是在定義並執行一個匿名函數。括號裏的函數沒有名字,而且定義完馬上被執行。我們知道,在一個函數外部是無法訪問函數內部定義的任何變量和函數的。所以我們沒有辦法直接訪問這個函數裏定義的變量a和b。

但是如果返回了一個函數(也可以是一個擁有方法的對象,或者更復雜的對象),問題就不是這樣了。因爲作用域鏈的存在,這個函數將可以訪問這個匿名函數內部的變量(因爲被返回的函數是在這個匿名函數內部定義的),也就是說,現在只有通過返回的函數可以訪問a和b了。

所以藉助閉包,我們得到了一塊內存,這塊內存的變量只能被返回的函數或對象訪問,不能通過其他方式訪問。這樣就實現了對變量的保護。

總結

以上就是關於JavaScript的函數式特性的介紹,主要圍繞着高階函數展開,希望大家對函數式編程有一個初步的瞭解。偏函數、柯里化以及閉包這些概念在JavaScript中都非常的重要,它們也是各大框架經常用到的技巧。

有一點順帶提一下,React中的高階組件,其實也是高階函數的一種擴展。React官網中這樣介紹高階組件:高階組件就是一個函數,且該函數接受一個組件作爲參數,並返回一個新的組件。如果把組件視爲一個函數,那高階組件就可以稱爲高階函數了(如果傳入的是函數組件,那它本身就是高階函數)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章