1. 函數(shù)式編程能解決的問題
- 可擴(kuò)展性--我是否需要不斷地重構(gòu)代碼來支持額外的功能?
- 易模塊化--如果我更改了一個(gè)文件,另一個(gè)文件是否會(huì)受到影響?
- 可重用性--是否有很多重復(fù)的代碼?
- 可測(cè)性--給這些函數(shù)添加單元測(cè)試是否讓我糾結(jié)?
- 易推理性--我寫的代碼是否非結(jié)構(gòu)化嚴(yán)重并難以推理?
2. 學(xué)習(xí)之前你需要了解的一些概念
函數(shù)輸入
在數(shù)學(xué)中,函數(shù)總是獲取一些輸入值,然后給出一個(gè)輸出值。
但在程序中,它或許有許多個(gè)輸入值,或許沒有。它或許有一個(gè)輸出值( return 值),或許沒有。
從上述的定義出發(fā),所有的函數(shù)都需要輸入。
大多數(shù)情況下,人們把函數(shù)的輸入值稱為 “arguments” 或者 “parameters” 。所以它到底是什么?
arguments 是你輸入的值(實(shí)參), parameters 是函數(shù)中的命名變量(形參),用于接收函數(shù)的輸入值。例子如下:
function fn (x, y) {
console.log(x, y)
}
fn(3, 4)
// 3, 4
3 和 4是函數(shù) fn(..) 調(diào)用的 arguments。x 和 y 是 parameters,用于接收參數(shù)值(分別為 3 和 4 )。
在javascript中定義的形參和實(shí)參可以是不等的:
function fn (x, y) {
console.log(x, y)
}
fn(3)
// 3, undefined
你傳入少于聲明形參個(gè)數(shù)的實(shí)參,所有缺少的參數(shù)將會(huì)被賦予 undefined 變量,意味著你仍然可以在函數(shù)作用域中使用它,但值是 undefined。
輸入計(jì)數(shù)
一個(gè)函數(shù)所“期望”的實(shí)參個(gè)數(shù)是取決于已聲明的形參個(gè)數(shù),即你希望傳入多少參數(shù)。
如以下函數(shù):
function fn (x, y, z) {
console.log(x, y, z)
}
fn期望三個(gè)參數(shù),因?yàn)樗暶髁巳齻€(gè)形參。這里有一個(gè)特殊的術(shù)語:Arity。Arity 指的是一個(gè)函數(shù)聲明的形參數(shù)量。 fn(..) 的 Arity 是 3。
函數(shù)的length
你可能需要在程序運(yùn)行時(shí)獲取函數(shù)的 Arity,使用函數(shù)的 length 屬性即可。
function fn (x, y, z) {
console.log(x, y, z)
}
fn.length // 3
提示: 函數(shù)的 length 屬性是一個(gè)只讀屬性,并且它是在最初聲明函數(shù)的時(shí)候就被確定了。它應(yīng)該當(dāng)做用來描述如何使用該函數(shù)的一個(gè)基本元數(shù)據(jù)。
引入ES6特性的一些函數(shù)的length:
function foo(x,y = 2) {
// ..
}
function bar(x,...args) {
// ..
}
function baz( {a,b} ) {
// ..
}
foo.length; // 1
bar.length; // 1
baz.length; // 1
arguments
上面我們已經(jīng)知道了,可以用函數(shù)的length屬性來獲取到這個(gè)函數(shù)的形參個(gè)數(shù),但是在實(shí)際使用時(shí),我們更需要知道的是函數(shù)傳入的實(shí)參個(gè)數(shù),此時(shí)可以使用每個(gè)函數(shù)都有的arguments對(duì)象(類數(shù)組),來獲取到傳入的實(shí)參個(gè)數(shù)。
如下面這個(gè)例子:
function foo(x,y,z) {
console.log( arguments.length ); // 2
}
foo( 3, 4 );
由于 ES5(特別是嚴(yán)格模式下)的 arguments 不被一些人認(rèn)同,很多人盡可能地避免使用。盡管如此,它永遠(yuǎn)不會(huì)被移除,這是因?yàn)樵?JS 中我們“永遠(yuǎn)不會(huì)”因?yàn)楸憷远奚蚝蟮募嫒菪裕谴蠖鄶?shù)人還是不建議使用它。
所以你只需要知道以下幾點(diǎn):
- 當(dāng)你需要知道參數(shù)個(gè)數(shù)的時(shí)候,
arguments.length還是可以用的。 -
不要通過
arguments[1]訪問參數(shù)的位置。只要記住arguments.length。
…ES6獲取剩余參數(shù)
當(dāng)你需要像數(shù)組那樣訪問參數(shù),很有可能的原因是你想要獲取的參數(shù)沒有在一個(gè)規(guī)范的位置。我們?nèi)绾翁幚恚?/p>
ES6 救星來了!讓我們用 ... 操作符聲明我們的函數(shù),也被當(dāng)做 “spread”、“rest” 或者 “gather” (我比較偏愛)提及。
function foo(x,y,z,...args) {
// ..
}
看到參數(shù)列表中的 ...args 了嗎?那就是 ES6 用來告訴解析引擎獲取所有剩余的未命名參數(shù),并把它們放在一個(gè)真實(shí)的命名為 args 的數(shù)組。args 無論是不是空的,它永遠(yuǎn)是一個(gè)數(shù)組。但它不包含已經(jīng)命名的 x,y 和 z 參數(shù),只會(huì)包含超出前三個(gè)值的傳入?yún)?shù).
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 ]
你甚至可以直接在參數(shù)列中使用 ... 操作符,沒有其他正式聲明的參數(shù)也沒關(guān)系:
function foo(...args) {
console.log(args)
}
foo(1, 2); // [1, 2]
現(xiàn)在 args 是一個(gè)由參數(shù)組成的完整數(shù)組,你可以盡情使用 args.length 來獲取傳入的參數(shù)。你也可以安全地使用 args[1] .
關(guān)于實(shí)參的小技巧
如果你希望調(diào)用函數(shù)的時(shí)候只傳一個(gè)數(shù)組代替之前的多個(gè)參數(shù),該怎么辦?
function foo(...args) {
console.log( args[3] );
}
var arr = [ 1, 2, 3, 4, 5 ];
foo( ...arr ); // 4
…操作符在這里也被用到了。
在形參中使用的時(shí)候function foo(…args){},是將形參整合,形成一個(gè)數(shù)組:
function foo(x, y){}
// 經(jīng)過...之后變?yōu)?function foo([x, y]){}
在實(shí)參中使用的時(shí)候foo(…arr),是將實(shí)參展開:
foo([1, 2, 3])
// 經(jīng)過...之后變?yōu)?foo(1, 2)
你甚至可以多個(gè)值和…一起來使用:
function foo(...args) {
console.log( args[3] );
}
var arr = [ 2 ];
foo( 1, ...arr, 3, ...[4,5] ); // 4
// 相當(dāng)于是foo(1, 2, 3, 4, 5); 所以獲取到的args[3]為4
關(guān)于形參的小技巧
默認(rèn)參數(shù)
在 ES6 中,形參可以聲明默認(rèn)值。當(dāng)形參沒有傳入到實(shí)參中,或者傳入值是 undefined,會(huì)進(jìn)行默認(rèn)賦值的操作:
function foo(x = 3) {
console.log( x );
}
foo(); // 3
foo( undefined ); // 3
foo( null ); // null
foo( 0 ); // 0
解構(gòu)
現(xiàn)在有這么一個(gè)函數(shù):
function foo(params) {
console.log(params)
}
foo([1, 2, 3])
我在拿到args數(shù)組之后,想要命名傳入數(shù)組的第 1、2 個(gè)值,也許你可以這么做:
// example1
function foo(params) {
console.log(params) // [1, 2, 3]
var x = params[0] // 1
var y = params[1] // 2
var args = params.slice( 2 ) // [3] (args就是剩余的參數(shù)集合)
}
foo([1, 2, 3])
現(xiàn)在你可以用更酷的ES解構(gòu)的方式來寫這個(gè)函數(shù):
// example
function foo( [x,y,...args] = [] ) {
console.log(x) // 1
console.log(y) // 2
console.log(args) // [3]
}
foo( [1,2,3] );
上面的這種[x, y, …args] = []就是一種數(shù)組解構(gòu),解構(gòu)是通過你期望的模式來描述數(shù)據(jù)(對(duì)象,數(shù)組等),并分配(賦值)值的一種方式。
在這里例子中,解構(gòu)告訴解析器,一個(gè)數(shù)組應(yīng)該出現(xiàn)的賦值位置(即參數(shù))。這種模式是:拿出數(shù)組中的第一個(gè)值,并且賦值給局部參數(shù)變量 x,第二個(gè)賦值給 y,剩下的則組成 args。
同樣這種解構(gòu)也可以用在對(duì)象中,稱為對(duì)象解構(gòu):
function foo( {x,y} = {} ) {
console.log( x, y );
}
foo( {
y: 3
} ); // undefined 3
我們傳入一個(gè)對(duì)象作為一個(gè)參數(shù),它解構(gòu)成兩個(gè)獨(dú)立的參數(shù)變量 x 和 y,從傳入的對(duì)象中分配相應(yīng)屬性名的值。我們不在意屬性值 x 到底存不存在對(duì)象上,如果不存在,它最終會(huì)如你所想被賦值為 undefined。
通過上面的學(xué)習(xí),我們需要認(rèn)識(shí)到很重要的一個(gè)原則:聲明性代碼通常比命令式代碼更干凈。
聲明性代碼也就是上面的example2,在定義形參的時(shí)候就給參數(shù)命好名,而example1就是命令式代碼,它在拿到形參之后再進(jìn)行命名。
隨著輸入而變化的函數(shù)
現(xiàn)在有這么一個(gè)函數(shù):
function foo(x,y) {
if (typeof x == "number" && typeof y == "number") {
return x * y;
}
else {
return x + y;
}
}
明顯地,這個(gè)函數(shù)會(huì)根據(jù)你傳入的值而有所不同,比如:
foo( 3, 4 ); // 12
foo( "3", 4 ); // "34"
程序員這樣定義函數(shù)的原因之一是,更容易通過同一個(gè)函數(shù)來重載不同的功能。乍一看這樣設(shè)計(jì)一個(gè)函數(shù)好像很方便,使得我們的函數(shù)可以有很多不同的行為,其實(shí)通過不同的輸入值讓一個(gè)函數(shù)重載擁有不同的行為的技巧叫做特定多態(tài)(ad hoc polymorphism)。但在函數(shù)式編程中,要對(duì)方便的誘惑有警惕之心。因?yàn)槟憧梢酝ㄟ^這種方式設(shè)計(jì)一個(gè)函數(shù),即使可以立即使用,但這個(gè)設(shè)計(jì)的長期成本可能會(huì)讓你后悔。
函數(shù)輸出
在 JavaScript 中,函數(shù)只會(huì)返回一個(gè)值。下面的三個(gè)函數(shù)都有相同的 return 操作。
function foo() {}
function bar() {
return;
}
function baz() {
return undefined;
}
如果你沒有 return 值,或者你使用 return;,那么則會(huì)隱式地返回 undefined 值。
如果想要盡可能靠近函數(shù)式編程的定義:使用函數(shù)而非程序,那么我們的函數(shù)必須永遠(yuǎn)有返回值。這也意味著他們必須明確地 return 一個(gè)值,通常這個(gè)值也不是 undefined。
上面已經(jīng)說了一個(gè)函數(shù)只能有一個(gè)返回值,那么如果我們現(xiàn)在想要一次返回多個(gè)值怎么辦?切實(shí)可行的辦法就是把你需要返回的值放到一個(gè)復(fù)合值當(dāng)中去,例如數(shù)組、對(duì)象:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
// 或者 return { retValue1, retValue2 }
}
解構(gòu)方法可以使用于解構(gòu)對(duì)象或者數(shù)組類型的參數(shù),也可以使用在平時(shí)的賦值當(dāng)中:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}
var [ x, y ] = foo();
console.log( x + y ); // 42
將多個(gè)值集合成一個(gè)數(shù)組(或?qū)ο螅┳鰹榉祷刂担缓笤俳鈽?gòu)回不同的值,這無形中讓一個(gè)函數(shù)能有多個(gè)輸出結(jié)果。
提前return
return 語句不僅僅是從函數(shù)中返回一個(gè)值,它也是一個(gè)流量控制結(jié)構(gòu),它可以結(jié)束函數(shù)的執(zhí)行。因此,具有多個(gè) return語句的函數(shù)具有多個(gè)可能的退出點(diǎn),這意味著如果輸出的路徑很多,可能難以讀取并理解函數(shù)的輸出行為。
現(xiàn)在有這么一個(gè)例子:
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;
}
請(qǐng)思考 foo(2) 返回什么? foo(4) 返回什么? foo(8), foo(12) 呢?
你對(duì)自己的回答有多少信心?你付出多少精力來獲得答案?
我認(rèn)為在許多可讀性的問題上,是因?yàn)槲覀儾粌H使用 return 返回不同的值,更把它作為一個(gè)流控制結(jié)構(gòu)——在某些情況下可以提前退出一個(gè)函數(shù)的執(zhí)行。我們顯然有更好的方法來編寫流控制( if 邏輯等),也有辦法使輸出路徑更加明顯。
上面的答案分別是:2,2,8,13。
現(xiàn)在我們把上面的代碼換個(gè)版本:
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;
}
這個(gè)版本毫無疑問是更冗長的。但是在邏輯上,我認(rèn)為這比上面的代碼更容易理解。因?yàn)樵诿總€(gè) retValue 可以被設(shè)置的分支, 這里都有個(gè)守護(hù)者以確保 retValue 沒有被設(shè)置過才執(zhí)行。
相比在函數(shù)中提早使用 return,我們更應(yīng)該用常用的流控制( if 邏輯 )來控制 retValue 的賦值。到最后,我們 return retValue。
未return的輸出和純函數(shù)
有個(gè)技巧你可能在你的大多數(shù)代碼里面使用過,并且有可能你自己并沒有特別意識(shí)到,那就是讓一個(gè)函數(shù)通過改變函數(shù)體外的變量產(chǎn)出一些值。
比如我們現(xiàn)在想要設(shè)計(jì)一個(gè)這樣功能的函數(shù):f(x) = 2x + 3:
var y;
function foo(x) {
y = 2 * x + 3;
}
foo( 2 );
y; // 7
或許我們完全可以用 return 來返回,而不是賦值給 y:
function foo(x) {
return 2 * x + 3;
}
var y = foo( 2 );
y; // 7
這兩個(gè)函數(shù)完成相同的任務(wù)。解釋這兩者不同的一種方法是,后一個(gè)版本中的 return 表示一個(gè)顯式輸出,而前者的 y 賦值是一個(gè)隱式輸出。在這種情況下,我們開發(fā)人員肯定更喜歡顯示模式而非隱式。
但是,改變一個(gè)外部作用域的變量,就像我們?cè)?foo(..) 中所做的賦值 y 一樣,只是實(shí)現(xiàn)隱式輸出的一種方式。一個(gè)更微妙的例子是通過引用對(duì)非局部值進(jìn)行更改。
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
上面的例子中,我們知道這個(gè)函數(shù)輸出為124,并且也非常明確的return了,但你是否發(fā)現(xiàn)其他的輸出?查看代碼,并檢查 nums 數(shù)組。你發(fā)現(xiàn)區(qū)別了嗎?
為了填補(bǔ) 4 位置的空值 undefined,這里使用了 0 代替。盡管我們?cè)诰植坎僮?list 參數(shù)變量,但我們?nèi)匀挥绊懥送獠康臄?shù)組。
因?yàn)?list 使用了 nums 的引用,不是對(duì) [1,3,9,..] 的值復(fù)制,而是引用復(fù)制。因?yàn)?JS 對(duì)數(shù)組、對(duì)象和函數(shù)都使用引用和引用復(fù)制,我們可以很容易地從函數(shù)中創(chuàng)建輸出,即使是無心的。
這個(gè)隱式函數(shù)輸出在函數(shù)式編程中有一個(gè)特殊的名稱:副作用。當(dāng)然,沒有副作用的函數(shù)也有一個(gè)特殊的名稱:純函數(shù)。我們將在以后的章節(jié)討論這些,但關(guān)鍵是我們應(yīng)該喜歡純函數(shù),并且要盡可能地避免副作用。
函數(shù)功能
高階函數(shù)
函數(shù)是可以接受并且返回任何類型的值。一個(gè)函數(shù)如果可以接受或返回一個(gè)甚至多個(gè)函數(shù),它被叫做高階函數(shù)。
例如:
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(..) 就是一個(gè)高階函數(shù),因?yàn)樗梢越邮芤粋€(gè)函數(shù)作為參數(shù)。
一個(gè)高階函數(shù)同樣可以把一個(gè)函數(shù)作為輸出,像這樣:
function foo() {
var fn = function inner(msg){
console.log( msg );
};
return fn;
}
var f = foo();
f( "Hello!" ); // Hello!
return 不是“輸出”函數(shù)的唯一辦法??梢钥聪旅孢@個(gè)例子:
function foo() {
var fn = function inner(msg){
console.log( msg );
};
bar( fn );
}
function bar(func) {
func( "Hello!" );
}
foo(); // Hello!
如果你感覺看起來很吃力,或者不理解什么意思,我建議你最好自己手敲一遍,并要習(xí)慣這樣的寫法,因?yàn)?strong>將其他函數(shù)視為值的函數(shù)是高階函數(shù)的定義。函數(shù)式編程者們應(yīng)該學(xué)會(huì)這樣寫!
保持作用域
在所有編程,尤其是函數(shù)式編程中,最強(qiáng)大的就是:當(dāng)一個(gè)函數(shù)內(nèi)部存在另一個(gè)函數(shù)的作用域時(shí),對(duì)當(dāng)前函數(shù)進(jìn)行操作。當(dāng)內(nèi)部函數(shù)從外部函數(shù)引用變量,這被稱作閉包。
比如我們來看一些閉包的例子:
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() 函數(shù)內(nèi)部有兩個(gè)閉包變量,參數(shù) id 和 randNumber。
閉包不僅限于獲取變量的原始值:它不僅僅是快照,而是直接鏈接。你可以更新該值,并在下次訪問時(shí)獲取更新后的值。
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
上面的寫法,就是閉包中一個(gè)典型的例子。
我們將函數(shù)runningCounter()函數(shù)賦值給變量score,此時(shí)將0傳遞進(jìn)函數(shù)中,也就是start變量,并將其賦值給val變量。在后面調(diào)用score()函數(shù)的時(shí)候,傳遞的實(shí)參也就是傳遞給runningCounter()函數(shù)的返回值current()函數(shù)。
執(zhí)行第一個(gè)score()函數(shù)的時(shí)候就相當(dāng)于執(zhí)行:
current(1) {
val = 0 + 1;
return val
}
此時(shí)runningCounter函數(shù)中的變量val變?yōu)榱?code>1并且被記錄下來(這也意味著下次在調(diào)用的時(shí)候val的值還是1)。
執(zhí)行第二個(gè)時(shí):
current(1) {
val = 1 + 1;
return val
}
第三次:
current(1) {
val = 2 + 13;
return val
}
利用js閉包的這種特性,我們可以做下面的事:
如果你需要設(shè)置兩個(gè)輸入,一個(gè)你已經(jīng)知道,另一個(gè)還需要后面才能知道,你可以使用閉包來記錄第一個(gè)輸入值,就像這樣:
function makeAdder(x) {
return function sum(y){
return x + y;
};
}
//我們已經(jīng)分別知道作為第一個(gè)輸入的 10 和 37
var addTo10 = makeAdder( 10 );
var addTo37 = makeAdder( 37 );
// 緊接著,我們指定第二個(gè)參數(shù)
addTo10( 3 ); // 13
addTo10( 90 ); // 100
addTo37( 13 ); // 50
通常, sum(..) 函數(shù)會(huì)一起接收 x 和 y 并相加。但是在這個(gè)例子中,我們接收并且首先記錄(通過閉包) x 的值,然后等待 y 被指定。
在連續(xù)函數(shù)調(diào)用中指定輸入,這種技巧在函數(shù)式編程中非常普遍,并且有兩種形式:偏函數(shù)應(yīng)用和柯里化。在后面的章節(jié)中我們會(huì)進(jìn)行詳細(xì)的講解。
當(dāng)然,因?yàn)楹瘮?shù)如果只是 JS 中的值,我們可以通過閉包來記住函數(shù)值,就像這個(gè)例子:
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
我知道看到這里你可能就有疑問了,感覺上面的代碼是否有太多重復(fù)的邏輯,而且為什么要有一個(gè)formatter()函數(shù)來做一個(gè)中間件樣的載體。
函數(shù)式編程并不是在我們的代碼中分配或重復(fù) toUpperCase() 和 toLowerCase() 邏輯,而是鼓勵(lì)我們用優(yōu)雅的封裝方式來創(chuàng)建簡(jiǎn)單的函數(shù)。
具體來說,我們創(chuàng)建兩個(gè)簡(jiǎn)單的一元函數(shù) lower(..) 和 upperFirst(..),因?yàn)檫@些函數(shù)在我們程序中,更容易與其他函數(shù)配合使用。
后語
這一章節(jié)主要是介紹了函數(shù)式編程的一些入門知識(shí)點(diǎn),為后面更加復(fù)雜的知識(shí)點(diǎn)打好基礎(chǔ)。
參考資料:
Functional-Light-JS