組合軟件:3. 函數式程序員的 JavaScript 介紹

https://www.zcfy.cc/article/a-functional-programmer-s-introduction-to-javascript-composing-software-2695.html

原文鏈接: medium.com

對於不熟悉 JavaScript 或者 ES6+ 的人來說,本文的目的是做一個簡單介紹。不管你是初學者,還是有經驗的 JavaScript 開發者,都可以學到一些新東西。如下的內容只是蜻蜓點水,讓你興奮起來。如果想了解更多,就必須更深層次探索了。後面還有更多東西。

學會寫代碼的最佳方式就是開始寫代碼。推薦用一個交互式的 JavaScript 編程環境來跟着寫,比如 CodePen 或者 Babel REPL。當然,就用 Node 或者瀏覽器控制檯 REPL 也能搞定。

表達式和值

表達式是要計算爲一個值的代碼段。

如下都是 JavaScript 中有效的表達式:

7;

7 + 1; // 8

7 * 2; // 14

'Hello'; // Hello

可以給表達式的值一個名稱。這樣做時,是先對錶達式求值,然後將結果值賦值給該名稱。爲此,我們會用 const 關鍵字。雖然這個關鍵字並非變量聲明的唯一方式,不過會是用得最多的。所以,我們從現在開始就堅持用 const

const hello = 'Hello';
hello; // Hello

var、let 和 const

const 外,JavaScript 還支持有兩個變量聲明關鍵字:varlet。我喜歡從選擇順序上去思考它們。默認情況下,我會選用最嚴格的聲明:const。用 const 關鍵字聲明的變量是不能被重新賦值的。變量必須在聲明時就賦了最終值。這聽起來可能很死板,但是嚴格是件好事。這是一個信號,用來告訴你:賦給這個名稱的值是不打算改變的。它能幫助我們馬上完全理解變量名的含義,而不需要去讀整個函數或者塊作用域。

有時對變量重新賦值是佷用的。例如,如果是在用手動的、命令式的迭代,而不是更函數式的方式,那麼我們就可以迭代一個用 let 賦值的計數器。

因爲 var 告訴我們有關變量的信息是最少的,所以它是最弱的信號。從我開始用 ES6 開始,我就不再故意在真實軟件項目中使用 var 聲明變量。

請注意,一旦變量是用 letconst 聲明的,任何企圖再次聲明它的做法都會導致出錯。如果你喜歡在 REPL(Read,Eval, Print Loop)中更實驗性的靈活性,可能會用 var 而不是 const 來聲明變量。重新聲明用 var 聲明過的變量是允許的。

本文會使用 const,從而讓你習慣默認在實際程序中使用 const,但是爲交互性實驗的目的,可以隨意用 var 替代。

類型

迄今爲止我們已經看到了兩種類型:數字和字符串。JavaScript 還有布爾類型(truefalse)、數組、對象等等類型。稍後我們會接觸其它類型。

數組是一個有序的值列表。將它當作一個可以裝很多東西的盒子。如下是數組字面量表示法:

[1, 2, 3];

當然,也可以給表達式一個名稱:

const arr = [1, 2, 3];

JavaScript 中的對象是鍵值對的集合。它也有一個字面量表示法:

{
  key: 'value'
}

當然,也可以把對象賦值給一個名稱:

const foo = {
  bar: 'bar'
}

如果想把已有的變量賦值給同名的對象屬性鍵,這裏有一個快捷方式,只鍵入變量名即可,不用同時提供鍵和值:

const a = 'a';
const oldA = { a: a }; // 長而冗餘的方式
const oA = { a }; // 短而酷的方式!

只是爲了好玩,下面我們再來一次:

const b = 'b';
const oB = { b };

對象可以很容易組合在一起成爲一個新對象:

const c = {...oA, ...oB}; // { a: 'a', b: 'b' }

這些點就是對象剩餘(Rest)運算符。它遍歷 oA 中的屬性,將其賦值給新對象,然後對 oB 做同樣的動作,覆蓋新對象上已有的所有鍵。到本文編寫時,對象剩餘運算符還只是一個新的試驗性特性,還沒有被所有流行瀏覽器所支持。不過如果它不起作用的話,這裏還有替代品:Object.assign():

const d = Object.assign({}, oA, oB); // { a: 'a', b: 'b' }

Object.assign() 例子中只多打了幾個字,並且如果是在組合很多對象的話,這樣做甚至會省掉更多鍵入。不過要注意的是,在使用 Object.assign() 時,必須將目標對象傳遞爲第一個參數。屬性將會被複制到該對象。如果你忘記了,漏掉了目標對象,那麼傳遞爲第一個參數的對象就會被修改。

根據我的經驗,修改一個已有的對象,而不是創建一個新對象,通常會是一個 bug,起碼也是很容易出錯的。用 Object.assign() 時候一定要注意這點。

解構

對象和數組都支持解構,也就是說我們可以從對象和數組中抽取值,並將其賦值給命名的變量:

const [t, u] = ['a', 'b'];
t; // 'a'
u; // 'b'

const blep = {
  blop: 'blop'
};

// 如下語句等於:const blop = blep.blop;
const { blop } = blep;
blop; // 'blop'

如同上例中的數組一樣,我們可以一次解構爲多個賦值。如下是在很多 Redux 項目中會看到的一行:

const { type, payload } = action;

如下是在 reducer 上下文中使用它的示例(隨後會講解更多有關該主題的知識):

const myReducer = (state = {}, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case 'FOO': return Object.assign({}, state, payload);
    default: return state;
  }
};

如果我們不想對新綁定用不同的名稱,可以把它賦值給一個新名稱:

const { blop: bloop } = blep;
bloop; // 'blop'

讀作:將 blep.blop 賦值爲 bloop

比較運算符和三元運算符

可以用嚴格相等運算符來比較值:

3 + 1 === 4; // true

還有一個非嚴格相等運算符。其正式叫法是等於運算符,非正式叫法是雙等號。雙等號有一到兩個合法的例子,但是用默認的 === 運算符替換通常會更好一些。

其它比較運算符包括:

  • > 大於
  • < 小於
  • >= 大於等於
  • <= 小於等於
  • != 不等於
  • !== 不嚴格相等
  • && 邏輯與
  • || 邏輯或

三元表達式是先用比較運算符問一個問題,然後根據表達式是否爲真,來求值爲一個不同的答案:

14 - 7 === 7 ? 'Yep!' : 'Nope.'; // Yep!

函數

JavaScript 有函數表達式,函數表達式可以賦值給一個名稱:

const double = x => x * 2;

這跟數學函數 f(x)=2x 是一樣的。大聲說出來,該函數讀爲 xf 等於 2x。這個函數只有在將它應用到一個特定的 x 值時,纔有意思。要在其它等式中使用該函數,得寫 f(2),它與 4 是一個意思。

換句話說,f(2) = 4。你可以把數學函數當作是從輸入到輸出的映射。本例中的 f(x)x 的輸入值映射到相應的輸出值,等於輸入值和 2 的積。

在 JavaScript 中,函數表達式的值就是函數本身:

double; // [Function: double]

可以用 .toString() 方法看看函數定義:

double.toString(); // 'x => x * 2'

如果想把一個函數應用到某些參數上,必須用一個函數調用來調用它。函數調用將一個函數應用到其參數上,計算結果爲一個返回值。

可以用 <functionName>(argument1, argument2, ...rest) 調用一個函數。例如,要調用 double 函數,只需要添加括號,然後給 double 傳遞進一個值:

double(2); // 4

與其它函數式語言有所不同,JavaScript 中這些括號是有意義的。如果沒有括號,函數就不會被調用:

double 4; // SyntaxError: Unexpected number

函數簽名

函數有簽名,簽名由如下部分組成:

  1. 一個可選的函數名。
  2. 在括號中的一個形式參數類型列表。形式參數可以有名稱。
  3. 返回值的類型。

在 JavaScript 中類型簽名不需要指定。JavaScript 引擎在運行時會算出類型。如果提供足夠的線索,簽名還可以被開發者工具推斷出來,比如使用數據流分析的 IDE 和 Tern.js

JavaScript 自身沒有函數簽名表示法,所以有幾個相互競爭的標準:從歷史上看,JSDoc 曾經很流行過,不過它超級囉嗦,沒人勞神費心去讓文檔註釋保持與代碼同步,所以很多 JS 開發者已經停止使用它了。

TypeScript 和 Flow 目前是最大的競爭者。我不確定如何用二者中任何一個表示我所需要的一切,所以我只用 Rtype 來生成文檔。有些人轉過去求助於 Haskell 的 Hindley–Milner 類型推導系統。如果只是爲文檔化的用途,我倒是很想看到針對 JavaScript 標準化的一個好的表示法體系,不過我認爲當今沒有任何目前的解決方案能勝任這一任務。眼前,只能睜一眼閉一隻眼,盡力適應這種可能與任何你所用過的看起來有點不同的古怪的類型簽名。

functionName(param1: Type, param2: Type) => Type

double 函數的簽名是:

double(x: n) => n

儘管 JavaScript 不需要用簽名來做批註,不過知道簽名是什麼以及簽名的含義,依然對搞清楚函數如何使用以及如何組成是很重要的。大部分可重用的函數組合實用程序需要我們傳遞共享相同類型簽名的函數。

默認的形參值

JavaScript 支持默認的形參值。如下的函數就像一個恆等函數(一個返回值與傳遞進來的值相同的函數),除非你用 undefined 做參數調用它,或者就不傳進參數(此時它會返回零):

const orZero = (n = 0) => n;

要設置一個默認的形參值,只需要在函數簽名中用 = 將默認的值賦值給形參,比如上例中 n = 0。如果按這種方式給形參設置默認值,像 Tern.js、Flow 或者 TypeScript 這類類型推斷工具就可以自動推斷出函數的類型簽名,即使沒有顯式聲明類型註解也是如此。

於是,如果在編輯器或者 IDE 中裝好了插件,就能在鍵入函數調用同時看到函數簽名顯示在行內,看一眼函數調用簽名,馬上就理解了如何用該函數。很顯然,使用默認賦值可以幫助我們編寫更能自我說明的代碼。

注意:帶默認值的形參不會計入到函數的 .length 屬性中,這會打亂像自動柯里化這種依賴於 .length 值的實用程序。如果遇到這種情況,有些柯里化實用程序(比如 loadh/curry)允許傳遞一個自定義的參數元數來規避這種限制。

命名參數

JavaScript 函數可以將對象字面量作爲實參,並在形參簽名中使用解構賦值,從而實現命名參數一樣的效果。注意,還可以用默認形參特性,將默認值賦值給形參:

const createUser = ({
  name = 'Anonymous',
  avatarThumbnail = '/avatars/anonymous.png'
}) => ({
  name,
  avatarThumbnail
});

const george = createUser({
  name: 'George',
  avatarThumbnail: 'avatars/shades-emoji.png'
});

George;
/*
{
  name: 'George',
  avatarThumbnail: 'avatars/shades-emoji.png'
}
*/

剩餘運算符和展開運算符

JavaScript 中函數的一個基本特徵是能在函數簽名中用剩餘運算符 ... 將一組剩餘的實參聚集在一起。

例如,如下的函數只是丟掉第一個實參,並將剩餘的返回爲一個數組:

const aTail = (head, ...tail) => tail;
aTail(1, 2, 3); // [2, 3]

剩餘運算符將單個元素聚集在一起,成爲一個數組。擴展運算符則相反:它將數組中的元素展開爲一個一個元素。考慮如下的示例:

const shiftToLast = (head, ...tail) => [...tail, head];
shiftToLast(1, 2, 3); // [2, 3, 1]

在使用擴展運算符時,JavaScript 的數組會有一個迭代器被調用。對於數組中的每個元素,迭代器會提供一個值。在表達式 [...tail, head] 中,迭代器從 tail 數組中按順序將每個元素複製到一個由周圍文本符號(即 shiftToLast)創建的新數組中。因爲 head 已經是一個單個元素,我們只需要將它放到數字末尾就搞定了。

柯里化

通過返回另一個函數,可以啓用柯里化和偏函數應用:

const highpass = cutoff => n => n >= cutoff;
const gt4 = highpass(4); // highpass() 返回一個新函數

這裏其實不必用箭頭函數。JavaScript 還有一個 function 關鍵字。我們之所以用箭頭函數是因爲 function 關鍵字敲的字要多一些。上面的 highPass()定義等於:

const highpass = function highpass(cutoff) {
  return function (n) {
    return n >= cutoff;
  };
};

JavaScript 中的箭頭大致就是 function 的意思。根據所用的函數類型,函數行爲有一些重大的區別(=> 沒有自己的 this,不能被用作爲構造器),之後我們講到這裏的時候會講清楚這些區別。目前,當你看到 x=>x 時,就把它當作“以 x 爲參數,並返回 x 的函數”。所以我們可以把 const highpass = cutoff => n => n >= cutoff; 讀爲:"highpass 是一個以 cutoff 爲參數的函數,它返回一個以 n 爲參數,返回值爲 n >= cutoff 的結果的函數"。

既然 highpass() 返回一個函數,那麼我們就可以用它來創建更專用的函數:

const gt4 = highpass(4);

gt4(6); // true
gt4(3); // false

自動柯里化讓我們可以自動地柯里化函數,以達到最大的靈活性。假如有一個函數 add3()

const add3 = curry((a, b, c) => a + b + c);

有了自動柯里化,我們就可以以幾種不同的方式來用它,並且它會根據我們傳遞進多少個實參,來返回正確的事:

add3(1, 2, 3); // 6
add3(1, 2)(3); // 6
add3(1)(2, 3); // 6
add3(1)(2)(3); // 6

不過,對不起,Haskell 粉絲,JavaScript 缺乏內置的自動柯里化機制。但是我們可以從 Lodash 中導入一個:

$ npm install --save lodash

然後在你的模塊中導入:

import curry from 'lodash/curry';

或者,你可以使用如下的魔咒:

// 很小的、遞歸的自動柯里化
const curry = (
  f, arr = []
) => (...args) => (
  a => a.length === f.length ?
    f(...a) :
    curry(f, a)
)([...arr, ...args]);

函數組合

我們當然還可以組合函數。函數組合是將一個函數的返回值傳遞爲另一個函數的實參。用數學符號表示就是:

f . g

在 JavaScript 中翻譯爲:

f(g(x))

它是從內向外求值:

  1. x 被求值
  2. g() 應用到 x
  3. f() 應用到 g(x) 的返回值

例如:

const inc = n => n + 1;
inc(double(2)); // 5

2 被傳遞進 double(),生成 44 被傳遞進 inc(),得到 5

我們可以將任何表達式作爲實參傳遞給函數。表達式會在函數應用之前就被求值:

inc(double(2) * double(2)); // 17

既然 double(2) 的計算結果爲 4,我們就可以將上面的代碼讀爲 inc(4 * 4),其結果爲 inc(16),最終其結果爲 17

函數組合是函數式編程的核心。後續文章中我們會對此有更進一步的講解。

數組

數組有內置的方法。方法是與對象關聯的函數:通常是被關聯對象的一個屬性:

const arr = [1, 2, 3];
arr.map(double); // [2, 4, 6]

本例中,arr 是對象,.map() 是該對象的一個屬性,.map() 的參數是一個用於一個值的函數。當調用它時,函數就被應用到實參以及一個特殊的形參 this 上,this 是在方法被調用時自動設置的。.map() 是通過 this 值訪問數組內容。

注意這裏我們是將 double 函數作爲一個值傳遞進 map,而不是調用它。這是因爲 map 是用一個函數作爲實參,並將其應用到數組中的每個元素上。它返回一個新數組,這個數組包含了被 double() 返回的值。

注意原始 arr 值沒有改變:

arr; // [1, 2, 3]

方法鏈

我們還可以把方法調用鏈起來。方法鏈是直接在一個函數的返回值上調用一個方法的過程,而不需要通過名稱來引用返回值:

const arr = [1, 2, 3];
arr.map(double).map(double); // [4, 8, 12]

斷言(predicate)是一個返回布爾值(true 或者 false)的函數。.filter() 方法以一個斷言爲參數,並返回一個新列表,只有通過斷言(返回 true)的條目纔會被包含在新列表中:

[2, 4, 6].filter(gt4); // [4, 6]

我們經常會想從一個列表中選擇一些條目,然後將這些條目映射到一個新的列表:

[2, 4, 6].filter(gt4).map(double); [8, 12]

注意:在本系列稍後的文章中,會看到一種更高效的方式,即使用一種稱爲 transducer 的方式同時選擇和映射。但是我們還有其它事情需要先探討。

總結

如果現在你已經頭暈了,不要着急。我們只是觸及了很多應該有更多探索和思考的事情的表面。我們不久會再繞回來,更深入探討這些主題。

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