[譯] 函式式程式設計師的 JavaScript 簡介 (軟體編寫)(第三部分)

磊仔發表於2019-02-21

[譯] 函式式程式設計師的 JavaScript 簡介 (軟體編寫)(第三部分)

煙霧藝術魔方 — MattysFlicks — (CC BY 2.0)

注意:這是“軟體編寫”系列文章的第三部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 Composability)。後續還有更多精彩內容,敬請期待!
< 上一篇 | <<第一篇 | 下一篇 >

對於不熟悉 JavaScript 或 ES6+ 的同學,這裡做一個簡短的介紹。無論你是 JavaScript 開發新手還是有經驗的老兵,你都可能學到一些新東西。以下內容僅是淺嘗輒止,吊吊大家的興致。如果想知道更多,還需深入學習。敬請期待吧。

學習程式設計最好的方法就是動手程式設計。我建議您使用互動式 JavaScript 程式設計環境(如 CodePenBabel REPL)。

或者,您也可以使用 Node 或瀏覽器控制檯 REPL。

表示式和值

表示式是可以求得資料值的程式碼塊。

下面這些都是 JavaScript 中合法的表示式:

7;

7 + 1; // 8

7 * 2; // 14

'Hello'; // Hello複製程式碼

表示式的值可以被賦予一個名稱。執行此操作時,表示式首先被計算,取得的結果值被賦值給該名稱。對於這一點我們將使用 const 關鍵字。這不是唯一的方式,但這將是你使用最多的,所以目前我們就可以堅持使用 const

const hello = 'Hello';
hello; // Hello複製程式碼

var、let 和 const

JavaScript 支援另外兩種變數宣告關鍵字:var,還有 let。我喜歡根據選擇的順序來考慮它們。預設情況下,我選擇最嚴格的宣告方式:const。用 const 關鍵字宣告的變數不能被重新賦值。最終值必須在宣告時分配。這可能聽起來很嚴格,但限制是一件好事。這是個標識在提醒你“賦給這個名稱的值將不會改變”。它可以幫你全面瞭解這個名稱的意義,而無需閱讀整個函式或塊級作用域。

有時,給變數重新賦值很有用。比如,如果你正在寫一個手動的強制性迭代,而不是一個更具功能性的方法,你可以迭代一個用 let 賦值的計數器。

因為 var 能告訴你很少關於這個變數的資訊,所以它是最無力的宣告標識。自從開始用 ES6,我就再也沒在實際軟體專案中有意使用 var 作宣告瞭。

注意一下,一個變數一旦用 letconst 宣告,任何再次宣告的嘗試都將導致報錯。如果你在 REPL(讀取-求值-輸出迴圈)環境中更喜歡多一些實驗性和靈活性,那麼建議你使用 var 宣告變數,與 letconst 不同,使用 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' }複製程式碼

這些點是物件擴充套件運算子。它迭代 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(2); // 4複製程式碼

和一些函式式語言不同,這對括號是有意義的。沒有它們,函式將不會被呼叫。

double 4; // SyntaxError: Unexpected number複製程式碼

簽名

函式的簽名可以包含以下內容:

  1. 一個 可選的 函式名。
  2. 在括號裡的一組引數。 引數的命名是可選的。
  3. 返回值的型別。

JavaScript 的簽名無需指定型別。JavaScript 引擎將會在執行時斷定型別。如果你提供足夠的線索,簽名資訊也可以通過開發工具推斷出來,比如一些 IDE(整合開發環境)和使用資料流分析的 Tern.js

JavaScript 缺少它自己的函式簽名語法,所以有幾個競爭標準:JSDoc 在歷史上非常流行,但它太過笨拙臃腫,沒有人會不厭其煩地維護更新文件與程式碼同步,所以很多 JS 開發者都棄坑了。

TypeScript 和 Flow 是目前的大競爭者。這二者都不能讓我確定地知道怎麼表達我需要的一切,所以我使用 Rtype,僅僅用於寫文件。一些人倒退回 Haskell 的 curry-only Hindley–Milner 型別系統。如果僅用於文件,我很樂意看到 JavaScript 能有一個好的標記系統標準,但目前為止,我覺得當前的解決方案沒有能勝任這個任務的。現在,怪異的型別標記即使和你在用的不盡相同,也就將就先用著吧。

functionName(param1: Type, param2: Type) => Type複製程式碼

double 函式的簽名是:

double(x: n) => n複製程式碼

儘管事實上 JavaScript 不需要註釋簽名,知道何為簽名和它意味著什麼依然很重要,它有助於你高效地交流函式是如何使用和如何構建的。大多數可重複使用的函式構建工具都需要你傳入同樣型別簽名的函式。

預設引數值

JavaScript 支援預設引數值。下面這個函式類似一個恆等函式(以你傳入引數為返回值的函式),一旦你用 undefined 呼叫它,或者根本不傳入引數——它就會返回 0,來替代:

const orZero = (n = 0) => n;複製程式碼

如上,若想設定預設值,只需在傳入引數時帶上 = 操作符,比如 n = 0。當你用這種方式傳入預設值,像 Tern.js、Flow、或者 TypeScript 這些型別檢測工具可以自行推斷函式的型別簽名,甚至你不需要刻意宣告型別註解。

結果就是這樣,在你的編輯器或者 IDE 中安裝正確的外掛,在你輸入函式呼叫時,你可以看見內聯顯示的函式簽名。依據它的呼叫簽名,函式的使用方法也一目瞭然。無論起不起作用,使用預設值可以讓你寫出更具可讀性的程式碼。

注意: 使用預設值的引數不會增加函式的 .length 屬性,比如使用依賴 .length 值的自動柯里化會丟擲不可用異常。如果你碰上它,一些柯里化工具(比如 lodash/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 陣列中拷貝到一個剛剛建立的新的陣列。之前 head 已經是一個獨立元素了,我們只需把它放到陣列的末端,就完成了。

柯里化

可以通過返回另一個函式來實現柯里化(Curry)和偏應用(partial application):

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 中箭頭的大致意義就是“函式”。使用不同種的方式宣告,函式行為會有一些重要的不同點(=> 缺少了它自己的 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() 是一個以函式為值的物件屬性。當你呼叫它,這個函式會被應用給引數,和一個特別的引數叫做 thisthis 在方法被呼叫之時自動設定。這個 this 的存在使 .map() 能夠訪問陣列的內容。

注意我們傳遞給 map 的是 double 函式而不是直接呼叫。因為 map 攜帶一個函式作為引數並將函式應用給陣列的每一個元素。它返回一個包含了 double() 返回值的新的陣列。

注意原始的 arr 值沒有改變:

arr; // [1, 2, 3]複製程式碼

方法鏈

你也可以鏈式呼叫方法。方法鏈是指在函式返回值上直接呼叫方法的過程,在此期間不需要給返回值命名:

const arr = [1, 2, 3];
arr.map(double).map(double); // [4, 8, 12]複製程式碼

返回布林值(truefalse)的函式叫做 斷言(predicate)。.filter() 方法攜帶斷言並返回一個新的陣列,新陣列中只包含傳入斷言函式(返回 true)的元素:

[2, 4, 6].filter(gt4); // [4, 6]複製程式碼

你常常會想要從一個列表選擇一些元素,然後把這些元素序列化到一個新列表中:

[2, 4, 6].filter(gt4).map(double); [8, 12]複製程式碼

注意:後面的文章你將看到使用叫做 transducer 東西更高效地同時選擇元素並序列化,不過這之前還有一些其他東西要了解。

總結

如果你現在有點發懵,不必擔心。我們僅僅概覽了一下很多事情的表面,它們尚需大量的解釋和總結。很快我們就會回過頭來,深入探討其中的一些話題。

繼續閱讀 “高階函式”…

接下來

想要學習更多 JavaScript 函數語言程式設計知識?

和 Eric Elliott 一起學習 JavaScript。 如果你不是其中一員,千萬別錯過!

[譯] 函式式程式設計師的 JavaScript 簡介 (軟體編寫)(第三部分)

Eric Elliott“JavaScript 應用程式設計” (O’Reilly) 以及 “和 Eric Elliott 一起學習 JavaScript” 的作者。 曾就職於 Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN、BBC and top recording artists including Usher、Frank Ocean、Metallica 等公司,具有豐富的軟體實踐經驗。

他大多數時間在 San Francisco By Area ,和世界上最美麗的姑娘在一起。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章