感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
函數式編程 不是使用 function
關鍵字編程。 如果它真有那么簡單,我在這里就可以結束這本書了!但重要的是,函數確實是 FP 的中心。使我們的代碼成為 函數式 的,是我們如何使用函數。
但是,你確信你知道 函數 是什么意思嗎?
在這一章中,我們將要通過講解函數的所有基本方面來為本書的剩余部分打下基礎。在某種意義上,這里的內容即便是對非 FP 程序員來說也是應當知道的關于函數的一切。但是如果我們想要從 FP 的概念中學到竟可能多的東西,我們必須 知道 函數的里里外外。
振作起來,關于函數東西可能比你已經知道的東西多得多。
什么是函數?
要解釋函數式編程,我所能想到的最自然的起點就是 函數。這看起來再明顯不過了,但我想我們的旅程需要堅實的第一步。
那么……什么是函數?
數學簡憶
我知道我承諾過盡可能遠離數學,但稍稍忍耐我片刻,在繼續之前我們快速地檢視一些東西:代數中有關函數和圖像的基礎。
你還記得在學校里學過的關于 f(x)
的一些東西嗎?等式 y = f(x)
呢?
比如說一個等式這樣定義的:<code>f(x) = 2x2 + 3</code>。這是什么意思?給這個函數畫出圖像是什么意思?這就是圖像:
你能注意到,對于任何 x
的值,比如 2
,如果你將它插入這個等式,你會得到 11
。那么 11
是什么?它是函數 f(x)
的 返回值,代表我們剛才說到的 y
值。
換句話說,在圖像的曲線上有一個點 (2,11)
。而且對于我們插入的任意的 x
的值,我們都能得到另一個與之相對應的 y
值作為一個點的坐標。比如另外一個點 (0,3)
,以及另一個點 (-1,5)
。將這些點放在一起,你就得到了上面的拋物線圖像。
那么這到底與 FP 有什么關系?
在數學中,一個函數總是接受輸入,并且總是給出輸出。一個你將經常聽到的 FP 術語是“態射(morphism)”;這個很炫的詞用來描述一個值的集合映射到另一個值的集合,就像一個函數的輸入與這個函數的輸入的關系一樣。
在代數中,這些輸入與輸出經常被翻譯為被繪制的圖像的坐標的一部分。然而,我們我可以使用各種各樣的輸入與輸出定義函數,而且它們不必與視覺上圖像的曲線有任何關系。
函數 vs 過程
那么為什么說了半天數學和圖像?因為在某種意義上,函數式編程就是以這種數學意義上的 函數 來使用函數。
你可能更習慣于將函數考慮為過程(procedures)。它有什么區別?一個任意功能的集合。它可能有輸入,也可能沒有。它可能有一個輸出(return
值),也可能沒有。
而一個函數接收輸入并且絕對總是有一個 return
值。
如果你打算進行函數式編程,你就應當盡可能多地使用函數,而不是過程。你所有的 function
都應當接收輸入并返回輸出。為什么?這個問題的答案有許多層次的含義,我們將在這本書中逐一揭示它們。
函數輸入
根據這個定義,所有函數都需要輸入。
你有時會聽到人們稱它們為“實際參數(arguments)”,而有時稱為“形式參數(parameters)”。那么這都是什么意思?
實際參數 是你傳入的值,而 形式參數 在函數內部被命名的變量,它們接收那些被傳入的值。例如:
function foo(x,y) {
// ..
}
var a = 3;
foo( a, a * 2 );
a
和 a * 2
(實際上,是這個表達式的值,6
) 是 foo(..)
調用的 實際參數。x
和 y
是接收實際參數值(分別是 3
和 6
)的 形式參數。
注意: 在 JavaScript 中,不要求 實際參數 的數量與 形式參數 的數量相吻合。如果你傳入的 實際參數 多于被聲明來接受它們的 形式參數 ,那么這些值會原封不動地被傳入。這些值可以用幾種不同的方式訪問,包括老舊的 arguments
對象。如果你傳入的 實際參數 少于被聲明的 形式參數,那么每一個無人認領的形式參數都是一個 “undefined” 值,這意味著它在這個函數的作用域中存在而且可用,只是初始值是空的 undefined
。
輸入計數
被“期待”的實際參數的數量 —— 你可能想向它傳遞多少實際參數 —— 是由被聲明的形式參數的數量決定的。
function foo(x,y,z) {
// ..
}
foo(..)
期待 三個實際參數,因為它擁有三個被聲明的形式參數。這個數量有一個特殊的術語:元(arity)。元是函數聲明中形式參數的數量。foo(..)
的元是 3
。
你可能會想在運行時期間檢查一個函數引用來判定它的元。這可以通過這個函數引用的 length
屬性來完成:
function foo(x,y,z) {
// ..
}
foo.length; // 3
一個在執行期間判定元的原因可能是,一段代碼從多個源頭接受一個函數引用,并且根據每個函數引用的元來發送不同的值。
例如,想象這樣一種情況,一個函數引用 fn
可能期待一個,兩個,或三個實際參數,但你總是想要在最后一個位置上傳遞變量 x
:
// `fn` 被設置為某個函數的引用
// `x` 存在并擁有一些值
if (fn.length == 1) {
fn( x );
}
else if (fn.length == 2) {
fn( undefined, x );
}
else if (fn.length == 3) {
fn( undefined, undefined, x );
}
提示: 一個函數的 length
屬性是只讀的,而且它在你聲明這個函數時就已經被決定了。它應當被認為實質上是一種元數據,用來描述這個函數意料之中的用法。
一個要小心的坑是,特定種類的形式參數列表可以使函數的 length
屬性報告的東西與你期待的不同。不要擔心,我們會在本章稍后講解每一種(ES6 引入的)特性:
function foo(x,y = 2) {
// ..
}
function bar(x,...args) {
// ..
}
function baz( {a,b} ) {
// ..
}
foo.length; // 1
bar.length; // 1
baz.length; // 1
如果你使用這些形式參數中的任意一種,那么要小心你函數的 length
值可能會使你驚訝。
那么如何計數當前函數調用收到的實際參數數量呢?這曾經是小菜一碟,但現在情況變得稍微復雜一些。每個函數都有一個可以使用的 arguments
(類數組)對象,它持有每個被傳入的實際參數的引用。你可檢查 arguments
的 length
屬性來搞清楚有多少參數被實際傳遞了:
function foo(x,y,z) {
console.log( arguments.length ); // 2
}
foo( 3, 4 );
在 ES5(具體地說,strict 模式)中,arguments
被認為是有些軟廢棄了;許多人都盡量避免使用它。它永遠都不會被移除 —— 在 JS 中,不論那將會變得多么方便,我們“永遠”都不會破壞向下的兼容性 —— 但是由于種種原因依然強烈建議你盡可能避免使用它。
然而,我建議 arguments.length
,而且僅有它,在你需要關心被傳入的實際參數的數量時是可以繼續使用的。某個未來版本的 JS 中有可能會加入一個特性,在沒有 arguments.length
的情況下恢復判定被傳遞的實際參數數量的能力;如果這真的發生了,那么我們就可以完全放棄 arguments
的使用了。
小心:絕不要 按位置訪問實際參數,比如 arguments[1]
。如果你必須這么做的話,堅持只使用 arguments.length
。
除非……你如何訪問一個在超出被聲明的形式參數位置上傳入的實際參數?我一會就會回答這個問題;但首先,退一步問你自己,“為什么我想要這么做?”。把這個問題認真地考慮幾分鐘。
這種情況的發生應該非常少見;它不應當是你通常所期望的,或者在你編寫函數時所依靠的東西。如果你發現自己身陷于此,那么就再多花20分鐘,試著用一種不同的方式來設計這個函數的交互。即使這個參數是特殊的,也給它起個名字。
一個接收不確定數量的實際參數的函數簽名稱為可變參函數(variadic function)。有些人喜歡這種風格的函數設計,但我想你將會發現 FP 程序員經常想要盡量避免這些。
好了,在這一點上嘮叨得夠多了。
假定你需要以一種類似數組下標定位的方式來訪問實際參數,這可能是因為你正在訪問一個沒有正式形式參數位置的實際參數。我們該如何做?
ES6 前來拯救!讓我們使用 ...
操作符來聲明我們的函數 —— 它有多個名稱:“擴散”、“剩余”、或者(我最喜歡的)“聚集”。
function foo(x,y,z,...args) {
// ..
}
看到形式參數列表中的 ...args
了嗎?這是一種新的 ES6 聲明形式,它告訴引擎去收集(嗯哼,聚集)所有剩余的(如果有的話)沒被賦值給命名形式參數的實際參數,并將它們放到名為 args
的真正的數組中。args
將總是一個數組,即便是空的。但它 不會 包含那些已經賦值給形式參數 x
、y
、和 z
的值,只有超過前三個值被傳入的所有東西。
function foo(x,y,z,...args) {
console.log( x, y, z, args );
}
foo(); // undefined undefined undefined []
foo( 1, 2, 3 ); // 1 2 3 []
foo( 1, 2, 3, 4 ); // 1 2 3 [ 4 ]
foo( 1, 2, 3, 4, 5 ); // 1 2 3 [ 4, 5 ]
所以,如果你 真的 想要設計一個解析任意多實際參數的函數,就在末尾使用 ...args
(或你喜歡的其他任何名字)。現在,你將得到一個真正的,沒有被廢棄的,不討人嫌的數組來訪問那些實際參數。
只不過要注意,值 4
在這個 args
的位置 0
上,而不是位置 3
。而且它的 length
值將不會包括 1
、2
、和 3
這三個值。...args
聚集所有其余的東西,不包含 x
、y
、和 z
。
你甚至 可以 在沒有聲明任何正式形式參數的參數列表中使用 ...
操作符:
function foo(...args) {
// ..
}
無論實際參數是什么,args
現在都是一個完全的實際參數的數組,而且你可以使用 args.length
來知道究竟有多少個實際參數被傳入了。而且如果你選擇這樣做的話,你可以安全地使用 args[1]
或 args[317]
。但是,拜托不要傳入318個實際參數。
說到 ES6 的好處,關于你函數的實際參數與形式參數,還有幾種你可能想知道的其他的技巧。這個簡要概覽之外的更多信息,參見我的 “你不懂 JS —— ES6 與未來” 的第二章。
實際參數技巧
要是你想要傳遞一個值的數組作為你函數調用的實際參數呢?
function foo(...args) {
console.log( args[3] );
}
var arr = [ 1, 2, 3, 4, 5 ];
foo( ...arr ); // 4
我們使用了我們的新朋友 ...
,它不只是在形式參數列表中可以使用;而且還可以在調用點的實際參數列表中使用。在這樣的上下文環境中它將擁有相反的行為。在形式參數列表中,我們說它將實際參數 聚集 在一起。在實際參數列表中,它將它們 擴散 開來。所以 arr
的內容實際上被擴散為 foo(..)
調用的各個獨立的實際參數。你能看出這與僅僅傳入 arr
數組的整個引用有什么不同嗎?
順帶一提,多個值與 ...
擴散是可以穿插的,只要你認為合適:
var arr = [ 2 ];
foo( 1, ...arr, 3, ...[4,5] ); // 4
以這種對稱的感覺考慮 ...
:在一個值的列表的位置,它 擴散。在一個賦值的位置 —— 比如形式參數列表,因為實際參數被 賦值給 了形式參數 —— 它 聚集。
不管你調用哪一種行為,...
都令使用實際參數列表變得非常簡單。用slice(..)
、concat(..)
和 apply(..)
來倒騰我們實際參數值數組的日子一去不復返了。
形式參數技巧
在 ES6 中, 形式參數可以被聲明 默認值。在這個形式參數的實際參數沒有被傳遞,或者被傳遞了一個 undefined
值的情況下,默認的賦值表達式將會取而代之。
考慮如下代碼:
function foo(x = 3) {
console.log( x );
}
foo(); // 3
foo( undefined ); // 3
foo( null ); // null
foo( 0 ); // 0
注意: 我們不會在此涵蓋更多的細節,但是默認值表達式是懶惰的,這意味著除非需要它不會被求值。另外,它可以使用任意合法的 JS 表達式,甚至是一個函數調用。這種能力使得許多很酷的技巧成為可能。例如,你可以在形式參數列表中聲明 x = required()
,而在 required()
函數中簡單地 throw "This argument is required."
,來確保其他人總是帶著指定的實際/形式參數來調用你的函數。
另一個我們可以在形式參數列表中使用的技巧稱為 “解構”。我們將簡要地掃它一眼,因為這個話題要比我們在這里討論的復雜太多了。同樣,更多信息參考我的 “ES6 與未來”。
還記得剛才可以接收318個實際參數的 foo(..)
嗎!?
function foo(...args) {
// ..
}
foo( ...[1,2,3] );
要是我們想改變這種互動方式,讓我們函數的調用方傳入一個值的數組而非各個獨立的實際參數值呢?只要去掉這兩個 ...
就好:
function foo(args) {
// ..
}
foo( [1,2,3] );
這很簡單。但如果我們想給被傳入的數組的前兩個值賦予形式參數名呢?我們不再聲明獨立的形式參數了,看起來我們失去了這種能力。但解構就是答案:
function foo( [x,y,...args] = [] ) {
// ..
}
foo( [1,2,3] );
你發現現在形式參數列表周圍的方括號 [ .. ]
了嗎?這就是數組解構。解構為你想看到的某種結構(對象,數組等)聲明了一個 范例,描述應當如何將它分解(分配)為各個獨立的部分。
在這個例子中,解構告訴引擎在這個賦值的位置(也就是形式參數)上期待一個數組。范例中說將這個數組的第一個值賦值給稱為 x
的本地形式參數變量,第二個賦值給 y
,而剩下的所有東西都 聚集 到 args
中。
你本可以像下面這樣手動地做同樣的事情:
function foo(params) {
var x = params[0];
var y = params[1];
var args = params.slice( 2 );
// ..
}
但是現在我們要揭示一個原則 —— 我們將在本文中回顧它許多許多次 —— 的第一點:聲明式代碼經常要比指令式代碼表意更清晰。
聲明式代碼,就像前面代碼段中的解構,關注于一段代碼的結果應當是什么樣子。指令式代碼,就像剛剛展示的手動賦值,關注于如何得到結果。如果稍后再讀這段代碼,你就不得不在大腦中執行它來得到期望的結果。它的結果被 編碼 在這里,但不清晰。
不論什么地方,也不論我們的語言或庫/框架允許我們這樣做到多深的程度,我們都應當努力使用聲明式的、自解釋的代碼。
正如我們可以解構數組,我們還可以解構對象形式參數:
function foo( {x,y} = {} ) {
console.log( x, y );
}
foo( {
y: 3
} ); // undefined 3
我們將一個對象作為實際參數傳入,它被解構為兩個分離的形式參數變量 x
和 y
,被傳入的對象中具有相應屬性名稱的值將會被賦予這兩個變量。對象中不存在 x
屬性并不要緊;它會如你所想地那樣得到一個 undefined
變量。
但是在這個形式參數對象解構中我想讓你關注的是被傳入 foo(..)
的對象。
像 foo(undefined,3)
這樣普通的調用點,位置用于將實際參數映射到形式參數上;我們將 3
放在第二個位置上使它被賦值給形式參數 y
。但是在這種引入了形式參數解構的新型調用點中,一個簡單的對象-屬性指示了哪個形式參數應該被賦予實際參數值 3
。
我們不必在這個調用點中說明 x
,因為我們實際上不關心 x
。我們只是忽略它,而不是必須去做傳入 undefined
作為占位符這樣令人分心的事情。
有些語言直接擁有這種行為特性:命名實際參數。換句話說,在調用點中,給一個輸入值打上一個標簽來指示它映射到哪個形式參數上。JavaScript 不具備命名實際參數,但是形式參數對象解構是最佳后備選項。
使用對象解構傳入潛在的多個實際參數 —— 這樣做的一個與 FP 關聯的好處是,只接收單一形式參數(那個對象)的函數與另一個函數的單一輸出組合起來要容易得多。稍后會詳細講解這一點。
回想一下,“元”這個術語指一個函數期待接收多少形式參數。一個元為 1 的函數也被稱為一元函數。在 FP 中,我們將盡可能使我們的函數是一元的,而且有時我們甚至會使用各種函數式技巧將一個高元函數轉換為一個一元的形式。
注意: 在第三章中,我們將重溫這種命名實際參數解構技巧,來對付惱人的形式參數順序問題。
根據輸入變化的函數
考慮這個函數:
function foo(x,y) {
if (typeof x == "number" && typeof y == "number") {
return x * y;
}
else {
return x + y;
}
}
顯然,這個造作的例子會根據你傳入的輸入不同而表現出不同的行為。
例如:
foo( 3, 4 ); // 12
foo( "3", 4 ); // "34"
程序員們像這樣定義函數的原因之一,是可以更方便地將不同的行為 重載(overload) 入一個函數中。最廣為人知的例子就是由許多像 JQuery 這樣的主流庫提供的 $(..)
函數。根據你向它傳遞什么實際參數,這個“錢號”函數大概擁有十幾種非常不同的行為 —— 從 DOM 元素查詢到 DOM 元素創建,以及將一個函數拖延到 DOMContentLoaded
事件之后。
感覺這種方式有一種優勢,就是需要學習的 API 少一些(只有一個 $(..)
函數),但是在代碼可讀性上具有明顯的缺陷,而且不得不小心地檢查到底什么東西被傳入了,才能解讀一個調用要做什么。
這種基于一個函數的輸入來重載許多不同行為的技術稱為特設多態(ad hoc polymorphism)。
這種設計模式的另一種表現形式是,使一個函數在不同場景下擁有不同的輸出(更多細節參加下一節)。
警告: 要對這里的 方便 的沖動特別小心。僅僅因為你可以這樣設計一個函數,而且即便可能立即感知到一些好處,這種設計決定所帶來的長期成本也可能不令人愉快。
函數輸出
在 JavaScript 中,函數總是返回一個值。這三個函數都擁有完全相同的 return
行為:
function foo() {}
function bar() {
return;
}
function baz() {
return undefined;
}
如果你沒有 return
或者你僅僅有一個空的 return;
,那么 undefined
值就會被隱含地 return
。
但是要盡可能地保持 FP 中函數定義的精神 —— 使用函數而不是過程 —— 我們的函數應當總是擁有輸出,這意味著它們應當明確地 return
一個值,而且通常不是 undefined
。
一個 return
語句只能返回一個單一的值。所以如果你的函數需要返回多個值,你唯一可行的選項是將它們收集到一個像數組或對象這樣的復合值中:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}
就像解構允許我們在形式參數中拆分數組/對象一樣,我們也可以在普通的賦值中這么做:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}
var [ x, y ] = foo();
console.log( x + y ); // 42
將多個值收集到一個數組(或對象)中返回,繼而將這些值解構回獨立的賦值,對于函數來說是一種透明地表達多個輸出的方法。
提示: 如果我沒有這么提醒你,那將是我的疏忽:花點時間考慮一下,一個需要多個輸出的函數是否能夠被重構來避免這種情況,也許分成兩個或更多更小的意圖單一的函數?有時候這是可能的,有時候不;但你至少應該考慮一下。
提前返回
return
語句不僅是從一個函數中返回一個值。它還是一種流程控制結構;它會在那一點終止函數的運行。因此一個帶有多個 return
語句的函數就擁有多個可能的出口,如果有許多路徑可以產生輸出,那么這就意味著閱讀一個函數來理解它的輸出行為可能更加困難。
考慮如下代碼:
function foo(x) {
if (x > 10) return x + 1;
var y = x / 2;
if (y > 3) {
if (x % 2 == 0) return x;
}
if (y > 1) return y;
return x;
}
突擊測驗:不使用瀏覽器運行這段代碼,foo(2)
返回什么?foo(4)
呢?foo(8)
呢?foo(12)
呢?
你對自己的答案有多自信?你為這些答案交了多少智商稅?我考慮它時,前兩次都錯了,而且我是用寫的!
我認為這里的一部分可讀性問題是,我們不僅將 return
用于返回不同的值,而且還將它作為一種流程控制結構,在特定的情況下提前退出函數的執行。當然有更好的方式編寫這種流程控制(例如 if
邏輯),但我也認為有辦法使輸出的路徑更加明顯。
注意: 突擊測驗的答案是 2
、2
、8
、和 13
.
考慮一下這個版本的代碼:
function foo(x) {
var retValue;
if (retValue == undefined && x > 10) {
retValue = x + 1;
}
var y = x / 2;
if (y > 3) {
if (retValue == undefined && x % 2 == 0) {
retValue = x;
}
}
if (retValue == undefined && y > 1) {
retValue = y;
}
if (retValue == undefined) {
retValue = x;
}
return retValue;
}
這個版本無疑更加繁冗。但我要爭辯的是它的邏輯追溯起來更簡單,因為每一個 retValue
可能被設置的分支都被一個檢查它是否已經被設置過的條件 守護 著。
我們沒有提前從函數中 return
出來,而是使用了普通的流程控制來決定 retValue
的賦值。最后,我們單純地 return retValue
。
我并不是在無條件地宣稱你應當總是擁有一個單獨的 return
,或者你絕不應該提早 return
,但我確實認為你應該對 return
在你的函數定義中制造隱晦流程控制的部分多加小心。試著找出表達邏輯的最明確的方式;那通常是最好的方式。
沒有被 return
的輸出
你可能在你寫過的大部分代碼中用過,但可能沒有太多考慮過的技術之一,就是通過簡單地改變函數外部的變量來使它輸出一些或全部的值。
記得我們在本章早先的 <code>f(x) = 2x2 + 3</code> 函數嗎?我們可以用 JS 這樣定義它:
var y;
function foo(x) {
y = (2 * Math.pow( x, 2 )) + 3;
}
foo( 2 );
y; // 11
我知道這是一個愚蠢的例子;我們本可以簡單地 return
值,而非在函數內部將它設置在 y
中:
function foo(x) {
return (2 * Math.pow( x, 2 )) + 3;
}
var y = foo( 2 );
y; // 11
兩個函數都完成相同的任務。我們有任何理由擇優選用其中之一嗎?有,絕對有。
一個解釋它們的不同之處的方式是,第二個版本中的 return
標明了一個明確的輸出,而前者中的 y
賦值是一種隱含的輸出。此時你能已經有了某種指引你的直覺;通常,開發者們優先使用明確的模式,而非隱含的。
但是改變外部作用域中的變量,就像我們在 foo(..)
內部中對 y
賦值所做的,只是得到隱含輸出的方式之一。一個更微妙的例子是通過引用來改變非本地值。
考慮如下代碼:
function sum(list) {
var total = 0;
for (let i = 0; i < list.length; i++) {
if (!list[i]) list[i] = 0;
total = total + list[i];
}
return total;
}
var nums = [ 1, 3, 9, 27, , 84 ];
sum( nums ); // 124
這個函數最明顯的輸出是我們明確地 return
的和 124
。但你發現其他的輸出了嗎?試著運行這代碼然后檢查 nums
數組。現在你發現不同了嗎?
現在在位置 4
上取代 undefined
空槽值的是一個 0
。看起來無害的 list[i] = 0
操作影響了外部的數組值,即便我們操作的是本地形式參數變量 list
。
為什么?因為 list
持有一個 nums
引用的引用拷貝,而不是數組值 [1,3,9,..]
的值拷貝。因為 JS 對數組,對象,以及函數使用引用和引用拷貝,所以我們可以很容易地從我們的函數中制造輸出,這甚至是偶然的。
這種隱含的函數輸出在 FP 世界中有一個特殊名稱:副作用(side effects)。而一個 沒有副作用 的函數也有一個特殊名稱:純函數(pure function)。在后面的章節中我們將更多地討論這些內容,但要點是,我們將盡一切可能優先使用純函數并避免副作用。
函數的函數
函數可以接收并返回任意類型的值。一個接收或返回一個或多個其他函數的函數有一個特殊的名稱:高階函數(higher-order function)。
考慮如下代碼:
function forEach(list,fn) {
for (let i = 0; i < list.length; i++) {
fn( list[i] );
}
}
forEach( [1,2,3,4,5], function each(val){
console.log( val );
} );
// 1 2 3 4 5
forEach(..)
是一個高階函數,因為它接收一個函數作為實際參數。
一個高階函數還可以輸出另一個函數,比如:
function foo() {
var fn = function inner(msg){
console.log( msg );
};
return fn;
}
var f = foo();
f( "Hello!" ); // Hello!
return
不是“輸出”另一個函數的唯一方法:
function foo() {
var fn = function inner(msg){
console.log( msg );
};
bar( fn );
}
function bar(func) {
func( "Hello!" );
}
foo(); // Hello!
高階函數的定義就是將其他函數看做值的函數。FP 程序員一天到晚都在寫這些東西!
保持作用域
在一切編程方式 —— 特別是 FP —— 中最強大的東西之一,就是當一個函數位于另一個函數的作用域中時如何動作。當內部函數引用外部函數的一個變量時,這稱為閉包(closure)。
實用的定義是,閉包是在一個函數即使在不同的作用域中被執行時,也能記住并訪問它自己作用域之外的變量。
考慮如下代碼:
function foo(msg) {
var fn = function inner(){
console.log( msg );
};
return fn;
}
var helloFn = foo( "Hello!" );
helloFn(); // Hello!
在 foo(..)
的作用域中的形式參數變量 msg
在內部函數中被引用了。當 foo(..)
被執行,內部函數被創建時,它就會捕獲對 msg
變量的訪問權,并且即使在被 return
之后依然保持這個訪問權。
一旦我們有了 helloFn
,一個內部函數的引用,foo(..)
已經完成運行而且它的作用域看起來應當已經消失了,這意味著變量 msg
將不復存在。但是這沒有發生,因為內部函數擁有一個對 msg
的閉包使它保持存在。只要這個內部函數(現在在一個不同的作用域中通過 helloFn
引用)存在,被閉包的變量 msg
就會保持下來。
再讓我們看幾個閉包在實際中的例子:
function person(id) {
var randNumber = Math.random();
return function identify(){
console.log( "I am " + id + ": " + randNumber );
};
}
var fred = person( "Fred" );
var susan = person( "Susan" );
fred(); // I am Fred: 0.8331252801601532
susan(); // I am Susan: 0.3940753308893741
內部函數 identify()
閉包著兩個變量,形式參數 id
和內部變量 randNumber
。
閉包允許的訪問權不僅僅限于讀取變量的原始值 —— 它不是一個快照而是一個實時鏈接。你可以更新這個值,而且在下一次訪問之前這個新的當前狀態會被一直記住。
function runningCounter(start) {
var val = start;
return function current(increment = 1){
val = val + increment;
return val;
};
}
var score = runningCounter( 0 );
score(); // 1
score(); // 2
score( 13 ); // 15
警告: 由于我們將在本文稍后講解的一些理由,這種使用閉包來記住改變的狀態(val
)的例子可能是你想要盡量避免的。
如果你有一個操作需要兩個輸入,你現在知道其中之一但另一個將會在稍后指定,你就可以使用閉包來記住第一個輸入:
function makeAdder(x) {
return function sum(y){
return x + y;
};
}
// 我們已經知道 `10` 和 `37` 都是第一個輸入了
var addTo10 = makeAdder( 10 );
var addTo37 = makeAdder( 37 );
// 稍后,我們指定第二個輸入
addTo10( 3 ); // 13
addTo10( 90 ); // 100
addTo37( 13 ); // 50
一般說來,一個 sum(..)
函數將會拿著 x
和 y
兩個輸入并把它們加在一起。但是在這個例子中我們首先收到并(通過閉包)記住值 x
,而值 y
是在稍后被分離地指定的。
注意: 這種在連續的函數調用中指定輸入的技術在 FP 中非常常見,而且擁有兩種形式:局部應用(partial application)與柯里化(currying)。我們將在本書稍后更徹底地深入它們。
當然,因為在 JS 中函數只是一種值,所以我們可以通過閉包來記住函數值。
function formatter(formatFn) {
return function inner(str){
return formatFn( str );
};
}
var lower = formatter( function formatting(v){
return v.toLowerCase();
} );
var upperFirst = formatter( function formatting(v){
return v[0].toUpperCase() + v.substr( 1 ).toLowerCase();
} );
lower( "WOW" ); // wow
upperFirst( "hello" ); // Hello
與其將 toUpperCase()
和 toLowerCase()
的邏輯在我們的代碼中散布/重復得到處都是,FP 鼓勵我們創建封裝(encapsulate) —— “包起來”的炫酷說法 —— 這種行為的簡單函數。
具體地說,我們創建了兩個簡單的一元函數 lower(..)
和 upperFirst(..)
,在我們程序的其余部分中,這些函數將會更容易地與其他函數組合起來工作。
提示: 你是否發現了 upperFirst(..)
本可以使用 lower(..)
?
我們將在本書的剩余部分重度依賴閉包。如果不談整個編程世界,它可能是一切 FP 中最重要的基礎實踐。要非常熟悉它!
語法
在我們從這個函數的入門教程啟程之前,讓我們花點兒時間討論一下它們的語法。
與本書的其他許多部分不同,這一節中的討論帶有最多的個人意見與偏好,不論你是否同意或者反對這里出現的看法。這些想法非常主觀,雖然看起來許多人感覺它們更絕對。不過說到頭來,由你決定。
名稱有何含義?
從語法上講,函數聲明要求包含一個名稱:
function helloMyNameIs() {
// ..
}
但是函數表達式可以以命名和匿名兩種形式出現:
foo( function namedFunctionExpr(){
// ..
} );
bar( function(){ // <-- 看,沒有名稱!
// ..
} );
順便問一下,我們說匿名究竟是什么意思?具體地講,函數有一個 name
屬性,它持有這個函數在語法上被賦予的名稱的字符串值,比如 "helloMyNameIs"
或者 "namedFunctionExpr"
。這個 name
屬性最常被用于你的 JS 環境的控制臺/開發者工具中,當這個函數存在于調用棧中時將它顯示出來。
匿名函數通常被顯示為 (anonymous function)
。
如果你曾經在除了一個異常的調用棧軌跡以外沒有任何可用信息的情況下調試 JS 程序,你就可能感受過看到一行接一行的 (anonymous function)
的痛苦。對于該異常從何而來,這種列表不會給開發者任何線索。它幫不到開發者。
如果你給你的函數表達式命名,那么這個名稱將總是被使用。所以如果你使用了一個像 handleProfileClicks
這樣的好名字取代 foo
,那么你將得到有用得多的調用棧軌跡。
在 ES6 中,匿名函數表達式可以被 名稱推斷(name inferencing) 所輔助。考慮如下代碼:
var x = function(){};
x.name; // x
如果引擎能夠猜測你 可能 想讓這個函數叫什么名字,它就會立即這么做。
但要小心,不是所有的語法形式都能從名稱推斷中受益。函數表達式可能最常出現的地方就是作為一個函數調用的實際參數:
function foo(fn) {
console.log( fn.name );
}
var x = function(){};
foo( x ); // x
foo( function(){} ); //
當從最近的外圍語法中無法推斷名稱時,它會保留一個空字符串。這樣的函數將會在調用棧軌跡中報告為 (anonymous function)
。
除了調試的問題之外,被命名的函數還有其他的好處。首先,語法名稱(也叫詞法名稱)對于內部自引用十分有用。自引用對于遞歸來說是必要的,在事件處理器中也十分有幫助。
考慮這些不同的場景:
// 同步遞歸:
function findPropIn(propName,obj) {
if (obj == undefined || typeof obj != "object") return;
if (propName in obj) {
return obj[propName];
}
else {
let props = Object.keys( obj );
for (let i = 0; i < props.length; i++) {
let ret = findPropIn( propName, obj[props[i]] );
if (ret !== undefined) {
return ret;
}
}
}
}
// 異步遞歸
setTimeout( function waitForIt(){
// `it` 還存在嗎?
if (!o.it) {
// 稍后重試
setTimeout( waitForIt, 100 );
}
}, 100 );
// 解除事件處理器綁定
document.getElementById( "onceBtn" )
.addEventListener( "click", function handleClick(evt){
// 解除事件綁定
evt.target.removeEventListener( "click", handleClick, false );
// ..
}, false );
在所有這些情況下,命名函數的名稱都是它內部的一個有用且可靠的自引用。
另外,即使是在一個一行函數的簡單情況下,將它們命名也會使代碼更具自解釋性,因此使代碼對于那些以前沒有讀過它的人來說變得更易讀:
people.map( function getPreferredName(person){
return person.nicknames[0] || person.firstName;
} )
// ..
函數名 getPreferredName(..)
告訴讀者映射操作的意圖是什么,而這僅從代碼來看的話沒那么明顯。這個名稱標簽使得代碼更具可讀性。
另一個匿名函數表達式常見的地方是 IIFE(即時調用的函數表達式):
(function(){
// 看,我是一個 IIFE!
})();
你幾乎永遠看不到 IIFE 為它們的函數表達式使用名稱,但它們應該這么做。為什么?為了我們剛剛講過的所有理由:調用棧軌跡調試、可靠的自引用、與可讀性。如果你實在想不出任何其他名稱,至少要使用 IIFE 這個詞:
(function IIFE(){
// 你已經知道我是一個 IIFE 了!
})();
我的意思是有多種理由可以解釋為什么 命名函數總是優于匿名函數。 事實上,我甚至可以說基本上不存在匿名函數更優越的情況。對于命名的另一半來說它們根本沒有任何優勢。
編寫匿名函數不可思議地容易,因為那樣會讓我們投入精力找出的名稱減少一個。
我承認;我和所有人一樣有罪。我不喜歡在命名上掙扎。我想到的頭三個或四個名稱通常都很差勁。我不得不一次又一次地重新考慮命名。我寧愿撒手不管而使用匿名函數表達式。
但我們是在用好寫與難讀做交易。這不是一樁好買賣。由于懶惰或沒有創意而不想為你的函數找出名稱,是一個使用匿名函數的太常見,但很爛的借口。
為每個函數命名。 如果你坐在那里很為難,不能為你寫的某個函數想出一個好名字,那么我會強烈地感覺到你還沒有完全理解這個函數的目的 —— 或者它的目的太泛泛或太抽象了。你需要回過頭去重新設計這個函數,直到它變得更清晰。而到了那個時候,一個名稱將顯而易見。
我可以用我的經驗作證,在給某個東西良好命名的掙扎中,我通常對它有了更好的理解,甚至經常為了改進可讀性和可維護性而重構它的設計。這種時間上的投資是值得的。
沒有 function
的函數
至此我們一直在使用完全規范的函數語法。但毫無疑問你也聽說過關于新的 ES6 =>
箭頭函數語法的討論。
比較一下:
people.map( function getPreferredName(person){
return person.nicknames[0] || person.firstName;
} )
// ..
people.map( person => person.nicknames[0] || person.firstName );
哇哦。
關鍵詞 function
不見了,return
、括號 ( )
、花括號 { }
、和引號 ;
也不見了。所有這些,換來了所謂的大箭頭符號 =>
。
但這里我們忽略了另一個東西。你發現了嗎?函數名 getPreferredName
。
沒錯;=>
箭頭函數是詞法上匿名的;沒有辦法在語法上給它提供一個名稱。它們的名稱可以像普通函數那樣被推斷,但同樣地,在最常見的函數表達式作為實際參數的情況下它幫不上什么忙。
如果由于某些原因 person.nicknames
沒有被定義,一個異常被拋出,這意味著 (anonymous function)
將會位于調用棧軌跡的頂端。呃。
老實說,對我而言,=>
箭頭函數的匿名性是一把指向心臟的 =>
匕首。我無法忍受命名的缺失。它更難讀、更難調試、而且不可能進行自引用。
如果說這還不夠壞,那另一個打臉的地方是,如果你的函數定義有不同的場景,你就必須趟過一大堆有微妙不同的語法。我不會在這里涵蓋它們的所有細節,但簡單地說:
people.map( person => person.nicknames[0] || person.firstName );
// 多個形式參數?需要 ( )
people.map( (person,idx) => person.nicknames[0] || person.firstName );
// 形式參數解構?需要 ( )
people.map( ({ person }) => person.nicknames[0] || person.firstName );
// 形式參數默認值?需要 ( )
people.map( (person = {}) => person.nicknames[0] || person.firstName );
// 返回一個對象?需要 ( )
people.map( person =>
({ preferredName: person.nicknames[0] || person.firstName })
);
在 FP 世界中 =>
激動人心的地方主要在于它幾乎完全符合數學上函數的符號,特別是在像 Haskell 這樣的 FP 語言中。箭頭函數語法 =>
的形狀可以進行數學上的交流。
再挖深一些,我覺得支持 =>
的爭辯是,通過使用輕量得多的語法,我們減少了函數之間的視覺邊界,這允許我們像曾經使用懶惰表達式那樣使用簡單的函數表達式 —— 這是另一件 FP 程序員們最喜歡的事。
我想大多數 FP 程序員將會對這些問題不屑一顧。他們深愛著匿名函數,也愛簡潔的語法。但正如我之前說的:這由你來決定。
注意: 雖然在實際中我不喜歡在我的應用程序中使用 =>
,但我們將會在本書剩余部分的許多地方使用它 —— 特別是當我們展示常用的 FP 工具時 —— 當簡潔性在代碼段有限的物理空間中成為不錯的優化方式時。這種方式是否會使你的代碼可讀性提高或降低,你要做出自己的決斷。
This 是什么?
如果你對 JavaScript 中的 this
綁定規則不熟悉,我推薦你看看我的“你不懂 JS:this 與對象原型”一書。對于這一節的目的來說,我假定你知道在一個函數調用中 this
是如何被決定的(四種規則之一)。但就算你對 this 還不甚了解,好消息是我們會得出這樣的結論:如果你想使用 FP,那么你就不應當使用 this
。
JavaScript 的 function
擁有一個在每次函數調用時自動綁定的 this
關鍵字。這個 this
關鍵字可以用許多不同的方式描述,但我喜歡稱它為函數運行的對象上下文環境。
對于你的函數來說,this
是一個隱含形式參數輸入。
考慮如下代碼:
function sum() {
return this.x + this.y;
}
var context = {
x: 1,
y: 2
};
sum.call( context ); // 3
context.sum = sum;
context.sum(); // 3
var s = sum.bind( context );
s(); // 3
當然,如果 this
可以是一個函數的隱含輸入,那么相同的對象環境就可以作為明確的實際參數發送:
function sum(ctx) {
return ctx.x + ctx.y;
}
var context = {
x: 1,
y: 2
};
sum( context );
更簡單。而且這種代碼在 FP 中處理起來容易得多。當輸入總是明確的時候,將多個函數組合在一起,或者使用我們將在下一章中學到的其他搬弄輸入的技術都將簡單得多。要使這些技術與 this
這樣的隱含輸入一起工作,在不同場景下要么很尷尬要么就是幾乎不可能。
我們可以在一個基于 this
的系統中利用其他技巧,例如原型委托(也在“this 與對象原型”一書中有詳細講解):
var Auth = {
authorize() {
var credentials = this.username + ":" + this.password;
this.send( credentials, resp => {
if (resp.error) this.displayError( resp.error );
else this.displaySuccess();
} );
},
send(/* .. */) {
// ..
}
};
var Login = Object.assign( Object.create( Auth ), {
doLogin(user,pw) {
this.username = user;
this.password = pw;
this.authorize();
},
displayError(err) {
// ..
},
displaySuccess() {
// ..
}
} );
Login.doLogin( "fred", "123456" );
注意: Object.assign(..)
是一個 ES6+ 工具,用于從一個或多個源對象向一個目標對象進行屬性的淺賦值拷貝:Object.assign( target, source1, ... )
。
如果你解讀這段代碼有困難:我們有兩個分離的對象 Login
和 Auth
,Login
實施了向 Auth
的原型委托。通過委托與隱含的 this
上下文環境共享,這兩個對象在 this.authorize()
函數調用中被虛擬地組合在一起,這樣在 Auth.authorize(..)
函數中 this
上的屬性/方法被動態地共享。
由于各種原因這段代碼不符合 FP 的種種原則,但是最明顯的問題就是隱含的 this
共享。我們可以使它更明確一些,保持代碼可以更容易地向 FP 的方向靠攏:
// ..
authorize(ctx) {
var credentials = ctx.username + ":" + ctx.password;
Auth.send( credentials, function onResp(resp){
if (resp.error) ctx.displayError( resp.error );
else ctx.displaySuccess();
} );
}
// ..
doLogin(user,pw) {
Auth.authorize( {
username: user,
password: pw
} );
}
// ..
從我的觀點看,這其中的問題并不是使用了對象來組織行為。而是我們試圖使用隱含輸入取代明確輸入。當我帶上我的 FP 帽子時,我會想將 this
這東西留在衣架上。
總結
函數十分強大。
但我們要清楚什么是函數。它不只是一個語句/操作的集合。特別地,一個函數需要一個或多個輸入(理想情況,只有一個!)以及一個輸出。
函數內部的函數可以擁有外部變量的閉包,為稍后的訪問記住它們。這是所有種類的編程中最重要的概念之一,而且是 FP 基礎的基礎。
要小心匿名函數,特別是箭頭函數 =>
。它們寫起來方便,但是將作者的成本轉嫁到了讀者身上。我們學習 FP 的所有原因就是寫出可讀性更強的代碼,所以先不要那么快就趕這個潮流。
不要使用 this
敏感的函數。別這么干。