函數式編程**不是僅僅用 `function` 這個關鍵詞來編程**。如果真這麼簡單,那我這本書可以到此為止了!重點在於:函數**是**函數式編程的核心。這也是如何使用函數(function)才能使我們的代碼具有函數式(functional)的方法。 然而,你真的明白**函數**的含義嗎? 在這... ...
關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,希望可以幫助大家在學習函數式編程的道路上走的更順暢。比心。
譯者團隊(排名不分先後):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿蔔、vavd317、vivaxy、萌萌、zhouyao
第 2 章:函數基礎
函數式編程不是僅僅用 function
這個關鍵詞來編程。如果真這麼簡單,那我這本書可以到此為止了!重點在於:函數是函數式編程的核心。這也是如何使用函數(function)才能使我們的代碼具有函數式(functional)的方法。
然而,你真的明白函數的含義嗎?
在這一章,我們將會介紹函數的基礎知識,為閱讀本書的後續章節打下基礎。從某些方面來講,這章回顧的函數知識並不是針對函數式編程者,非函數式編程者同樣需要瞭解。但如果我們想要充分、全面地學習函數式編程的概念,我們需要從裡到外地理解函數。
請做好準備,因為還有好多你未知的函數知識。
什麼是函數?
針對函數式編程,很自然而然的我會想到從函數開始。這太明顯不過了,但是我認為我們需要扎實地走好旅程的第一步。
所以......什麼是函數?
簡要的數學回顧
我知道我曾說過,離數學越遠越好,但是讓我們暫且忍一小段時間,在這段時間里,我們會儘快地回顧在代數中一些函數和圖像的基本知識。
你還記得你在學校里學習任何有關 f(x)
的知識嗎?還有方程 y = f(x)
?
現有方程式定義如下:f(x) = 2x2 + 3
。這個方程有什麼意義?它對應的圖像是什麼樣的呢?如下圖:
你可以註意到:對於 x
取任意值,例如 2
,帶入方程後會得到 11
。這裡的 11
代表函數的返回值,更簡單來說就是 y
值。
根據上述,現在有一個點 (2,11)
在圖像的曲線上,並且當我們有一個 x
值,我們都能獲得一個對應的 y
值。把兩個值組合就能得到一個點的坐標,例如 (0,3)
, (-1,5)
。當把所有的這些點放在一起,就會獲得這個拋物線方程的圖像,如上圖所示。
所以,這些和函數式編程有什麼關係?
在數學中,函數總是獲取一些輸入值,然後給出一個輸出值。你能聽到一個函數式編程的術語叫做“態射”:這是一個優雅的方式來描述一組值和另一組值的映射關係,就像一個函數的輸入值與輸出值之間的關聯關係。
在代數數學中,那些輸入值和輸出值經常代表著繪製坐標的一部分。不過,在我們的程式中,我們可以定義函數有各種的輸入和輸出值,並且它們不需要和繪製在圖表上的曲線有任何關係。
函數 vs 程式
為什麼所有的討論都圍繞數學和圖像?因為在某種程度上,函數式編程就是使用在數學意義上的方程作為函數。
你可能會習以為常地認為函數就是程式。它們之間的區別是什麼?程式就是一個任意的功能集合。它或許有許多個輸入值,或許沒有。它或許有一個輸出值( return
值),或許沒有。
而函數則是接收輸入值,並明確地 return
值。
如果你計劃使用函數式編程,你應該儘可能多地使用函數,而不是程式。你所有編寫的 function
應該接收輸入值,並且返回輸出值。這麼做的原因是多方面的,我們將會在後面的書中來介紹的。
函數輸入
從上述的定義出發,所有的函數都需要輸入。
你有時聽人們把函數的輸入值稱為 “arguments” 或者 “parameters” 。所以它到底是什麼?
arguments 是你輸入的值(實參), parameters 是函數中的命名變數(形參),用於接收函數的輸入值。例子如下:
function foo(x,y) {
// ..
}
var a = 3;
foo( a, a * 2 );
a
和 a * 2
(即為 6
)是函數 foo(..)
調用的 arguments。x
和 y
是 parameters,用於接收參數值(分別為 3
和 6
)。
註意: 在 JavaScript 中,實參的個數沒必要完全符合形參的個數。如果你傳入許多個實參,而且多過你所聲明的形參,這些值仍然會原封不動地被傳入。你可以通過不同的方式去訪問,包含了你以前可能聽過的老辦法 —— arguments
對象。反之,你傳入少於聲明形參個數的實參,所有缺少的參數將會被賦予 undefined
變數,意味著你仍然可以在函數作用域中使用它,但值是 undefined
。
輸入計數
一個函數所“期望”的實參個數是取決於已聲明的形參個數,即你希望傳入多少參數。
function foo(x,y,z) {
// ..
}
foo(..)
期望三個實參,因為它聲明瞭三個形參。這裡有一個特殊的術語:Arity。Arity 指的是一個函數聲明的形參數量。 foo(..)
的 Arity 是 3
。
你可能需要在程式運行時獲取函數的 Arity,使用函數的 length
屬性即可。
function foo(x,y,z) {
// ..
}
foo.length; // 3
在執行時要確定 Arity 的一個原因是:一段代碼接受一個函數的指針引用,有可能這個引用指向不同來源,我們要根據這些來源的 Arity 傳入不同的參數值。
舉個例子,如果 fn
可能指向的函數分別期望 1、2 或 3 個參數,但你只希望把變數 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(特別是嚴格模式下)的 arguments
不被一些人認同,很多人儘可能地避免使用。儘管如此,它永遠不會被移除,這是因為在 JS 中我們“永遠不會”因為便利性而去犧牲向後的相容性,但我還是強烈建議不要去使用它。
然而,當你需要知道參數個數的時候,arguments.length
還是可以用的。在未來版本的 JS 或許會新增特性來替代 arguments.length
,如果成真,那麼我們可以完全把 arguments
拋諸腦後。
請註意:不要通過 arguments[1]
訪問參數的位置。只要記住 arguments.length
。
除此之外,你或許想知道如何訪問那些超出聲明的參數?這個問題我一會兒會告訴你,不過你先要問自己的問題是,“為什麼我想要知道這個?”。認真地思考一段時間。
發生這種情況應該是非常罕見的。因為這不會是你日常需要的,也不會是你編寫函數時所必要的東西。如果這種情況真的發生,你應該花 20 分鐘來試著重新設計函數,或者命名那些多出來的參數。
帶有可變數量參數的函數被稱為 variadic。有些人更喜歡這樣的函數設計,不過你會發現,這正是函數式編程者想要避免的。
好了,上面的重點已經講得夠多了。
例如,當你需要像數組那樣訪問參數,很有可能的原因是你想要獲取的參數沒有在一個規範的位置。我們如何處理?
ES6 救星來了!讓我們用 ...
操作符聲明我們的函數,也被當做 “spread”、“rest” 或者 “gather” (我比較偏愛)提及。
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 的好,你肯定想知道一些小秘訣。在這裡將會介紹一些,更多的內容推薦你閱讀《You Don't Know JS: ES6 & Beyond》這本書的第 2 章。
關於實參的小技巧
如果你希望調用函數的時候只傳一個數組代替之前的多個參數,該怎麼辦?
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()
中 拋出 "This argument is required."
來確信總有人用你指定的實參或形參來引用你的函數。
另一個我們可以在參數中使用的 ES6 技巧,被稱為“解構”。在這裡我們只會簡單一提,因為要說清這個話題實在太過繁雜。在這裡推薦《ES6 & Beyond》這本書瞭解更多信息。
還記得我們之前提到的可以接受 318 個參數的 foo(..)
嗎?
function foo(...args) {
// ..
}
foo( ...[1,2,3] );
如果我們想要把函數內的參數從一個個單獨的參數值替換為一個數組,應該怎麼做?這裡有兩個 ...
的寫法:
function foo(args) {
// ..
}
foo( [1,2,3] );
這個非常簡單。但如果我們想要命名傳入數組的第 1、2 個值,該怎麼做?我們不能用單獨傳入參數的辦法了,所以這似乎看起來無能為力。不過解構可以回答這個問題:
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
應該分配給形參(y
)。
我們不需要操心 x
應該放在哪個調用現場。因為事實上,我們不用去關心 x
,我們只需要省略它,而不是分配 undefined
值。
有一些語言對這樣的操作有一個直接的特性:命名參數。換句話說,在調用現場,通過標記輸入值來告訴它映射關係。JavaScript 沒有命名參數,不過退而求其次,參數對象解構是一個選擇。
使用對象解構來傳入多個匿名參數是函數式編程的優勢,這個優勢在於使用一個參數(對象)的函數能更容易接受另一個函數的單個輸出。這點會在後面討論到。
回想一下,術語 Arity 是指期望函數接收多少個參數。Arity 為 1 的函數也被稱為一元函數。在函數式編程中,我們希望我們的函數在任何的情況下是一元的,有時我們甚至會使用各種技巧來將高 Arity 的函數都轉換為一元的形式。
註意: 在第 3 章,我們將重新討論命名參數的解構技巧,並使用它來處理關於參數排序的問題。
隨著輸入而變化的函數
思考以下函數
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"
程式員這樣定義函數的原因之一是,更容易通過同一個函數來重載不同的功能。最廣為人知的例子就是 jQuery 提供的 $(..)
。"$" 函數大約有十幾種不同的功能 —— 從 DOM 元素查找,到 DOM 元素創建,到等待 “DOMContentLoaded” 事件後,執行一個函數,這些都取決於你傳遞給它的參數。
上述函數,顯而易見的優勢是 API 變少了(僅僅是一個 $(..)
函數),但缺點體現在閱讀代碼上,你必須仔細檢查傳遞的內容,理解一個函數調用將做什麼。
通過不同的輸入值讓一個函數重載擁有不同的行為的技巧叫做特定多態(ad hoc polymorphism)。
這種設計模式的另一個表現形式就是在不同的情況下,使函數具有不同的輸出(在下一章節會提到)。
警告: 要對方便的誘惑有警惕之心。因為你可以通過這種方式設計一個函數,即使可以立即使用,但這個設計的長期成本可能會讓你後悔。
函數輸出
在 JavaScript 中,函數只會返回一個值。下麵的三個函數都有相同的 return
操作。
function foo() {}
function bar() {
return;
}
function baz() {
return undefined;
}
如果你沒有 return
值,或者你使用 return;
,那麼則會隱式地返回 undefined
值。
如果想要儘可能靠近函數式編程的定義:使用函數而非程式,那麼我們的函數必須永遠有返回值。這也意味著他們必須明確地 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
語句不僅僅是從函數中返回一個值,它也是一個流量控制結構,它可以結束函數的執行。因此,具有多個 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
可以被設置的分支, 這裡都有個守護者以確保 retValue
沒有被設置過才執行。
相比在函數中提早使用 return
,我們更應該用常用的流控制( if
邏輯 )來控制 retValue
的賦值。到最後,我們 return retValue
。
我不是說,你只能有一個 return
,或你不應該提早 return
,我只是認為在定義函數時,最好不要用 return
來實現流控制,這樣會創造更多的隱含意義。嘗試找出最明確的表達邏輯的方式,這往往是最好的辦法。
未 return
的輸出
有個技巧你可能在你的大多數代碼裡面使用過,並且有可能你自己並沒有特別意識到,那就是讓一個函數通過改變函數體外的變數產出一些值。
還記得我們之前提到的函數f(x) = 2x2 + 3
嗎?我們可以在 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
很明顯,這個函數輸出為 124
,我們也非常明確地 return
了。但你是否發現其他的輸出?查看代碼,並檢查 nums
數組。你發現區別了嗎?
為了填補 4
位置的空值 undefined
,這裡使用了 0
代替。儘管我們在局部操作 list
參數變數,但我們仍然影響了外部的數組。
為什麼?因為 list
使用了 nums
的引用,不是對 [1,3,9,..]
的值複製,而是引用複製。因為 JS 對數組、對象和函數都使用引用和引用複製,我們可以很容易地從函數中創建輸出,即使是無心的。
這個隱式函數輸出在函數式編程中有一個特殊的名稱:副作用。當然,沒有副作用的函數也有一個特殊的名稱:純函數。我們將在以後的章節討論這些,但關鍵是我們應該喜歡純函數,並且要儘可能地避免副作用。
函數功能
函數是可以接受並且返回任何類型的值。一個函數如果可以接受或返回一個甚至多個函數,它被叫做高階函數。
思考:
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!
將其他函數視為值的函數是高階函數的定義。函數式編程者們應該學會這樣寫!
保持作用域
在所有編程,尤其是函數式編程中,最強大的就是:當一個函數內部存在另一個函數的作用域時,對當前函數進行操作。當內部函數從外部函數引用變數,這被稱作閉包。
實際上,閉包是它可以記錄並且訪問它作用域外的變數,甚至當這個函數在不同的作用域被執行。
思考:
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
被指定。
註意: 在連續函數調用中指定輸入,這種技巧在函數式編程中非常普遍,並且有兩種形式:偏函數應用和柯里化。我們稍後會在文中深入討論。
當然,因為函數如果只是 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()
邏輯,而是鼓勵我們用優雅的封裝方式來創建簡單的函數。
具體來說,我們創建兩個簡單的一元函數 lower(..)
和 upperFirst(..)
,因為這些函數在我們程式中,更容易與其他函數配合使用。
提示: 你知道如何讓 upperFirst(..)
使用 lower(..)
嗎?
我們將在本書的後續中大量使用閉包。如果拋開整個編程來說,它可能是所有函數式編程中最重要的基礎。希望你能用得舒服!
句法
在我們函數入門開始之前,讓我們花點時間來討論它的語法。
不同於本書中的許多其他部分,本節中的討論主要是意見和偏好,無論你是否同意這裡提出的觀點或採取相反的觀點。這些想法是非常主觀的,儘管許多人似乎對此非常執著。不過最終,都由你決定。
什麼是名稱?
在語法上,函數聲明需要包含一個名稱:
function helloMyNameIs() {
// ..
}
但是函數表達式可以命名或者匿名:
foo( function namedFunctionExpr(){
// ..
} );
bar( function(){ // <-- 這就是匿名的!
// ..
} );
順便說一句,匿名的意思是什麼?具體來說,函數具有一個 name
的屬性,用於保存函數在語法上設定名稱的字元串值,例如 "helloMyNameIs"
或 "FunctionExpr"
。 這個name
屬性特別用於 JS 環境的控制台或開發工具。當我們在堆棧軌跡中追蹤(通常來自異常)時,這個屬性可以列出該函數。
而匿名函數通常顯示為:(anonymous function)
。
如果你曾經試著在一個異常的堆棧軌跡中調試一個 JS 程式,你可能已經發現痛苦了:看到 (anonymous function)
出現。這個列表條目不給開發人員任何關於異常來源路徑的線索。它沒有給我們開發者提供任何幫助。
如果你命名了你的函數表達式,名稱將會一直被使用。所以如果你使用了一個良好的名稱 handleProfileClicks
來取代 foo
,你將會在堆棧軌跡中獲得更多的信息。
在 ES6 中,匿名錶達式可以通過名稱引用來獲得名稱。思考:
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(){