《JavaScript 模式》讀書筆記(4)— 函數5

  • 2020 年 3 月 30 日
  • 筆記

  這一篇是函數部分的最後一篇。我們來聊聊Curry化。

十、Curry

  這部分我們主要討論Curry化和部分函數應用的內容。但是在深入討論之前,我們需要先了解一下函數應用的含義。

 

函數應用

  在一些純粹的函數式程式語言中,函數並不描述為被調用(即called或invoked),而是描述為應用(applied)。在JavaScript中,我們可以做同樣的事情,使用方法Function.prototype.apply()來應用函數,這是由於JavaScript中的函數實際上是對象,並且它們還具有如下方法。

// 定義函數  var sayHi = function(who) {      console.log("Hello" + (who? ", " + who : "") + "!");  };    // 調用函數  sayHi(); // 輸出"Hello"  sayHi('world'); // 輸出"Hello, world!"    // 應用函數  sayHi.apply(null, ["hello"]); // 輸出"Hello, hello!"

  正如上面的例子所看到的,調用(invoking)函數和應用(applying)函數可以得到完全相同的結果。apply()帶有兩個參數:第一個參數為將要綁定到該函數內部this的一個對象,而第二個參數是一個數組或多個參數變數,這些參數將變成可用於該函數內部的類似數組的arguments對象。如果第一個參數為null(空),那麼this將指向全局對象,此時得到的結果就恰好如同調用一個非指定對象時的方法。

  當函數是一個對象的方法時,此時不能傳遞null引用。這種情況下,這裡的對象將成為apply()的第一個參數:

// 定義函數  var alien= {      sayHi: function(who) {          console.log("Hello" + (who? ", " + who : "") + "!");      }  }  alien.sayHi('world'); // 輸出"Hello, world!"  sayHi.apply(alien, ["humans"]); // 輸出"Hello, humans!"

  在上面的程式碼中,sayHi()內部的this指向了alien對象。而在之前的例子中,this指向了全局對象。

  正如上面的兩個例子所展示的那樣,這些都表明我們考慮的“調用函數”並不只是“句法糖(syntactic sugar)”,而是等價於函數應用。

  請注意,除了apply()以外,Function.prototype對象還有一個call()方法,但是這仍然只是建立在apply()之上的語法糖而已。有時候最好使用該語法糖:即當函數僅帶有一個參數時,可以根據實際情況避免創建只有一個元素的數組的工作。

// 在這種情況下,第二種更有效率,節省了一個數組  sayHi.apply(alien,["humans"]);  sayHi.call(alien,"humans");

 

部分應用

  現在我們知道,調用函數實際上就是將一個參數集合應用到一個函數中,那有沒有可能只傳遞部分參數,而不是所有參數?這種情況就和手動處理一個數學函數所常採用的方法是相似的。假定有一個函數add()用以將兩個數字加在一起:x和y。下面的程式碼片段展示了給定x值為5,且y值為4的情況下的解決方案。

// 出於演示的目的  // 並不是合法的JavaScript  function add(x,y) {      return x + y;  }  // 有以下函數  add(5,4);    // 第1步,替換一個參數  function add(5, y){      return 5 + y;  }    // 第2步,替換其他參數  function add(5, 4) {      return 5 + 4;  }

  再提醒一遍,第1、2步的程式碼是不合法的,僅演示目的。

  上面的程式碼段演示了如何手工解決部分函數應用的問題。可以獲取第一個參數的值,並且在整個函數中用已知的值5替代未知的x,然後重複同樣的步驟直至用完了所有的參數。

  對這個例子中的步驟1可以稱為部分應用(partial application),即我們金鷹用了第一個參數。當執行部分應用時,並不會獲得結果,相反會獲得另一個函數。

  下面的程式碼片段演示了家鄉的partialApply()方法的使用示例:

var add = function (x,y) {      return x + y;  };    // 完全應用  add.apply(null,[5,4]); // 9    // 部分應用  var newadd = add.partialApply(null,[5]);  // 應用一個參數到新函數中  newadd.apply(null,[4]); // 9

  如上面的程式碼所示,部分應用向我們提供了另一個新函數,隨後再以其他參數調用該函數。這種運行方式實際上與add(5)(4)有一些類似,這是由於add(5)返回了一個可在後來用(4)來調用的函數。

  此外,我們所熟悉的add(5, 4)調用方式可能並不像是“句法糖(syntactic sugar)”,相反,使用add(5)(4)才像是“句法糖(syntactic sugar)”。

  現在,返回到現實,JavaScript中並沒有partialApply()方法和函數,默認情況下也並不會出現與上面類似的行為。但是可以構造出這些函數,因為JavaScript的動態性足夠支援這種行為。

  使函數理解並處理部分應用的過程就成為Curry過程(Currying)。

 

Curry化

  這裡的curry源於數學家Haskell Curry的名字。Curry化是一個轉換過程,即我們執行函數轉換的過程。那麼,我們如何Curry化一個函數?其他的函數式語言可能已經將這種Curry化轉換構建到語言本身中,並且所有的函數已經默認轉換過,在JavaScript中,可以將add()函數修改成一個用於處理部分應用的Curry化函數。

  下面,我們來看個例子:

// curry化的add()函數  // 接受部分參數列表  function add(x,y) {      var oldx = x,oldy = y;      if(typeof oldy === 'undefined') { // 部分          return function(newy) {              return oldx + newy;          };      }      // 完全應用      return x + y;  }    // 測試  console.log(typeof add(5)); // 輸出“function”  add(3)(4); // 7  // 創建並存儲一個新函數  var add2000 = add(2000);  add2000(19); //輸出2010

  在上面的程式碼段中,當第一次調用add()時,它為返回的內部函數創建了一個閉包。該閉包將原始的x和y值存儲到私有變數oldx和oldy中。第一個私有變數oldx將在內部函數執行的時候使用。如果沒有部分應用,並且同時傳遞x和y值,該函數則繼續執行,並簡單將其相加。這種add()實現與實際需求相比顯得比較冗長,在這裡只是出於演示的目的這樣實現。下面將顯示一個更為精簡的實現版本。其中並沒有oldx和oldy,僅是因為原始x隱式的存儲在閉包中,並且還將y作為局部變數復用,而不是像之前那樣創建一個新的變數newy:

// curry化的add()函數  // 接受部分參數列表  function add(x, y) {      if(typeof y === 'undefined') { //部分          return function(y) {              return x + y;          };      }      // 完全應用      return x + y;  }

  在這些例子中,函數add()本身負責處理部分應用。但是能夠以更通用的方式執行相同給的任務么?也就是說,是否可以將任意的函數轉換成一個新的可以接收部分參數的函數?

function schonfinkelize(fn) {      var slice = Array.prototype.slice,          stored_args = slice.call(arguments,1);      return function () {          var new_args = slice.call(arguments),              args = stored_args.concat(new_args);          return fn.apply(null,args);      }  }

  schonfinkelize()函數可能不應該有這麼複雜,只是由於JavaScript中arguments並不是一個真實的數組。從Array.prototype中借用slice()方法可以幫助我們將arguments變成一個數組,並且使用該數組更加方便。當schonfinkelize()第一次調用時,它存儲了一個指向slice()方法的私有引用(名為slice),並且還存儲了調用該方法後的參數(存入stored_args中),該方法僅剝離了第一個參數,這是因為第一個參數是將被curry化的函數。然後,schonfinkelize()返回了一個新函數。當這個新函數被調用時,它訪問了已經私有存儲的參數stored_args以及slice引用。這個新函數必須將原有的部分應用參數(stored_args)合併到新參數(new_args),然後再將它們應用到原始函數fn中(也僅在閉包中私有可用)。

 

 

  我們來測試下上面的轉換方法:

function schonfinkelize(fn) {      var slice = Array.prototype.slice,          stored_args = slice.call(arguments,1);      return function () {          var new_args = slice.call(arguments),              args = stored_args.concat(new_args);          return fn.apply(null,args);      }  }    // 普通函數  function add(x, y){      return x + y;  }    // 將一個函數curry化並獲得一個新的函數  var newadd = schonfinkelize(add,5);  console.log(newadd(4)); //輸出9    // 另一種選擇,直接調用新函數  console.log(schonfinkelize(add,6)(7)); //輸出13    // 轉換函數並不局限於單個參數或者單步Curry化  // 普通函數  function addSome(a, b, c, d, e) {      return a + b + c + d + e;  }    // 可運行於任意數量的參數  console.log(schonfinkelize(addSome,1,2,3)(5,5));    // 兩步curry化  var addOne = schonfinkelize(addSome,1);  console.log(addOne(10,10,10,10)); //41  var addSix = schonfinkelize(addOne,2,3);  console.log(addSix(5,5)); // 16

  上面是完整的例子和測試。

  那什麼時候適合使用Curry化呢?當發現正在調用同一個函數,並且傳遞的參數絕大多數都是相同的,那麼該函數可能是用於Curry化的一個很好的候選參數。可以通過將一個函數集合部分應用到函數中,從而動態創建一個新函數。這個新函數將會保存重複的參數(因此,不必每次都傳遞這些參數),並且還會使用預填充原始函數所期望的完整參數列表。

 

小結

  在JavaScript中,有關函數的部分是十分重要的,我們本系列文章相關的主要函數部分已經到此告一段落了。本篇討論了有關函數的背景和術語。學習了JavaScript中兩個重要的特徵。即:

  • 函數是第一類對象,可以作為帶有屬性和方法的值以及參數進行傳遞。
  • 函數提供了局部作用域,而其他打括弧並不能提供這種局部作用域(當然現在的let是可以的)。此外還需要記住的是,聲明的局部變數可被提升到局部作用域的頂部。

  創建函數的語法包括:

  • 1.  函數命名表達式。
  • 2. 函數表達式(與上面的相同,但是缺少一個名字),通常也稱為匿名函數。
  • 3. 函數聲明,與其他語言中的函數的語法類似。

  在涵蓋了函數的背景和語法之後,我們學習了一些有用的模式:

  1、API模式,它們可以幫助您為函數提供更好且更整潔的介面:

    回調模式:將函數作為參數進行傳遞。

    配置對象:有助於保持受到控制的函數的參數數量。

    返回函數:當一個函數的返回值是另一個函數時。

    Curry化:當新函數是基於現有函數,並加上部分參數列表創建時。

  2、初始化模式,它們可以幫助您在不污染全局命名空間的情況下,使用臨時變數以一種更加整潔、結構化的方式執行初始化以及設置任務(當涉及web網頁和應用程式時是非常普遍的)。這些模式包括:

    即時函數:只要定義之後就立即執行。

    即時對象初始化:匿名對象組織了初始化任務,提供了可被立即調用的方法。

    初始化時分支:幫助分支程式碼在初始化程式碼執行過程中僅檢測一次,這與以後在程式生命周期內多次檢測相反。

  3、性能模式,可以幫助加速程式碼運行,這些模式包括:

    備忘模式:使用函數屬性以便使得計算過的值無須再次計算。

    自定義模式:以新的主體重寫本身,以使得在第二次或以後調用時僅需執行更少的工作。

 

  好了,函數部分到此結束了。我們下面會開始學習對象模式部分。加油!fighting!