翻譯連載 | JavaScript輕量級函數式編程-第4章:組合函數 |《你不知道的JS》姊妹篇

關於譯者:這是一個流淌着滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裏最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。通過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,但願能夠幫助你們在學習函數式編程的道路上走的更順暢。比心。前端

譯者團隊(排名不分前後):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry蘿蔔vavd317vivaxy萌萌zhouyaogit

JavaScript輕量級函數式編程

第 4 章:組合函數

到目前爲止,我但願你能更輕鬆地理解在函數式編程中使用函數意味着什麼。程序員

一個函數式編程者,會將他們程序中的每個函數當成一小塊簡單的樂高部件。他們能一眼辨別出藍色的 2x2 方塊,並準確地知道它是如何工做的、能用它作些什麼。當構建一個更大、更復雜的樂高模型時,當每一次須要下一塊部件的時候,他們可以準確地從備用部件中找到這些部件並拿過來使用。github

但有些時候,你把藍色 2x2 的方塊和灰色 4x1 的方塊以某種形式組裝到一塊兒,而後意識到:「這是個有用的部件,我可能會經常使用到它」。ajax

那麼你如今想到了一種新的「部件」,它是兩種其餘部件的組合,在須要的時候能觸手可及。這時候,將這個藍黑色 L 形狀的方塊組合體放到須要使用的地方,比每次分開考慮兩種獨立方塊的組合要有效的多。編程

函數有多種多樣的形狀和大小。咱們可以定義某種組合方式,來讓它們成爲一種新的組合函數,程序中不一樣的部分均可以使用這個函數。這種將函數一塊兒使用的過程叫作組合。設計模式

輸出到輸入

咱們已經見過幾種組合的例子。好比,在第 3 章中,咱們對 unary(..) 的討論包含了以下表達式:unary(adder(3))。仔細想一想這裏發生了什麼。api

爲了將兩個函數整合起來,將第一個函數調用產生的輸出當作第二個函數調用的輸入。在 unary(adder(3)) 中,adder(3) 的調用返回了一個值(值是一個函數);該值被直接做爲一個參數傳入到 unary(..) 中,一樣的,這個調用返回了一個值(值爲另外一個函數)。數組

讓咱們回放一下過程而且將數據流動的概念視覺化,是這個樣子:安全

functionValue <-- unary <-- adder <-- 3
複製代碼

3adder(..) 的輸入。而 adder(..) 的輸出是 unary(..) 的輸入。unary(..) 的輸出是 functionValue。 這就是 unary(..)adder(..) 的組合。

把數據的流向想象成糖果工廠的一條傳送帶,每一次操做其實都是冷卻、切割、包裝糖果中的一步。在該章節中,咱們將會用糖果工廠的類比來解釋什麼是組合。

讓咱們一步一步的來了解組合。首先假設你程序中可能存在這麼兩個實用函數。

function words(str) {
	return String( str )
		.toLowerCase()
		.split( /\s|\b/ )
		.filter( function alpha(v){
			return /^[\w]+$/.test( v );
		} );
}

function unique(list) {
	var uniqList = [];

	for (let i = 0; i < list.length; i++) {
		// value not yet in the new list?
		if (uniqList.indexOf( list[i] ) === -1 ) {
			uniqList.push( list[i] );
		}
	}

	return uniqList;
}
複製代碼

使用這兩個實用函數來分析文本字符串:

var text = "To compose two functions together, pass the \ output of the first function call as the input of the \ second function call.";

var wordsFound = words( text );
var wordsUsed = unique( wordsFound );

wordsUsed;
// ["to","compose","two","functions","together","pass",
// "the","output","of","first","function","call","as",
// "input","second"]
複製代碼

咱們把 words(..) 輸出的數組命名爲 wordsFoundunique(..) 的輸入也是一個數組,所以咱們能夠將 wordsFound 傳入給它。

讓咱們從新回到糖果工廠的流水線:第一臺機器接收的「輸入」是融化的巧克力,它的「輸出」是一堆成型且冷卻的巧克力。流水線上的下一個機器將這堆巧克力做爲它的「輸入」,它的「輸出」是一片片切好的巧克力糖果。下一步就是,流水線上的另外一臺機器將這些傳送帶上的小片巧克力糖果處理,並輸出成包裝好的糖果,準備打包和運輸。

糖果工廠靠這套流程運營的很成功,可是和全部的商業公司同樣,管理者們須要不停的尋找增加點。

爲了跟上更多糖果的生產需求,他們決定拿掉傳送帶這麼個玩意,直接把三臺機器疊在一塊兒,這樣第一臺的輸出閥就直接和下一臺的輸入閥直接連一塊兒了。這樣第一臺機器和第二臺機器之間,就不再會有一堆巧克力在傳送帶上慢吞吞的移動了,而且也不會有空間浪費和隆隆的噪音聲了。

這項革新爲工廠節省了很大的空間,因此管理者很高興,他們天天可以造更多的糖果了!

等價於這種升級後的糖果工廠配置的代碼跳過了中間步驟(上面代碼片斷中的 wordsFound 變量),僅僅是將兩個函數調用一塊兒使用:

var wordsUsed = unique( words( text ) );
複製代碼

注意: 儘管咱們一般以從左往右的方式閱讀函數調用 ———— 先 unique(..) 而後 words(..) ———— 這裏的操做順序其實是從右往左的,或者說是自內而外。words(..) 將會首先運行,而後纔是 unique(..)。晚點咱們會討論符合咱們天然的、從左往右閱讀執行順序的模式,叫作 pipe(..)

堆在一塊兒的機器工做的還不錯,但有些笨重了,電線掛的處處都是。創造的機器堆越多,工廠車間就會變得越凌亂。並且,裝配和維護這些機器堆太佔用時間了。

有一天早上,一個糖果工廠的工程師忽然想到了一個好點子。她想,若是她能在外面作一個大盒子把全部的電線都藏起來,效果確定超級棒;盒子裏面,三臺機器相互鏈接,而盒子外面,一切都變得很整潔、乾淨。在這個很讚的機器的頂部,是傾倒融化巧克力的管道,在它的底部,是吐出包裝好的巧克力糖果的管道。

這樣一個單個的組合版機器,變得更易移動和安裝到工廠須要的地方中去了。工廠的車間工人也會變得更高興,由於他們不用再擺弄三臺機子上的那些按鈕和錶盤了;他們很快更喜歡使用這個獨立的很讚的機器。

回到代碼上:咱們如今瞭解到 words(..)unique(..) 執行的特定順序 -- 思考:組合的樂高 -- 是一種咱們在應用中其它部分也可以用到的東西。因此,如今讓咱們定義一個組合這些玩意的函數:

function uniqueWords(str) {
	return unique( words( str ) );
}
複製代碼

uniqueWords(..) 接收一個字符串並返回一個數組。它是 unique(..)words(..) 的組合,而且知足咱們的數據流向要求:

wordsUsed <-- unique <-- words <-- text
複製代碼

你如今應該可以明白了:糖果工廠設計模式的演變革命就是函數的組合。

製造機器

糖果工廠一切運轉良好,多虧了省下的空間,他們如今有足夠多的地方來嘗試製做新的糖果了。鑑於以前的成功,管理者迫切的想要發明新的棒棒的組合版機器,從而製造愈來愈多種類的糖果。

但工廠的工程師們跟不上老闆的節奏,由於每次造一臺新的棒棒的組合版機器,他們就要花費不少的時間來造新的外殼,從而適應那些獨立的機器。

因此工程師們聯繫了一家工業機器制供應商來幫他們。他們很驚訝的發現這家供應商居然提供 機器製造 器!聽起來好像難以想象,他們買入了一臺這樣的機器,這臺機器可以將工廠中小一點的機器 ———— 好比負責巧克力冷卻、切割的機器 ———— 自動連線,甚至在外面還自動包了一個乾淨的大盒子。這麼牛的機器簡直能把這家糖果工廠送上天了!

回到代碼上,讓咱們定義一個實用函數叫作 compose2(..),它可以自動建立兩個函數的組合,這和咱們手動作的是如出一轍的。

function compose2(fn2,fn1) {
	return function composed(origValue){
		return fn2( fn1( origValue ) );
	};
}

// ES6 箭頭函數形式寫法
var compose2 =
	(fn2,fn1) =>
		origValue =>
			fn2( fn1( origValue ) );
複製代碼

你是否注意到咱們定義參數的順序是 fn2,fn1,不只如此,參數中列出的第二個函數(也被稱做 fn1)會首先運行,而後纔是參數中的第一個函數(fn2)?換句話說,這些函數是以從右往左的順序組合的。

這看起來是種奇怪的實現,但這是有緣由的。大部分傳統的 FP 庫爲了順序而將它們的 compose(..) 定義爲從右往左的工做,因此咱們沿襲了這種慣例。

可是爲何這麼作?我認爲最簡單的解釋(但不必定符合真實的歷史)就是咱們在以手動執行的書寫順序來列出它們時,或是與咱們從左往右閱讀這個列表時看到它們的順序相符合。

unique(words(str)) 以從左往右的順序列出了 unique, words 函數,因此咱們讓 compose2(..) 實用函數也以這種順序接收它們。如今,更高效的糖果製造機定義以下:

var uniqueWords = compose2( unique, words );
複製代碼

組合的變體

看起來貌似 <-- unique <-- words 的組合方式是這兩種函數可以被組合起來的惟一順序。但咱們實際上可以以另外的目的建立一個實用函數,將它們以相反的順序組合起來。

var letters = compose2( words, unique );

var chars = letters( "How are you Henry?" );
chars;
// ["h","o","w","a","r","e","y","u","n"]
複製代碼

由於 words(..) 實用函數,上面的代碼才能正常工做。爲了值類型的安全,首先使用 String(..) 將它的輸入強轉爲一個字符串。因此 unique(..) 返回的數組 -- 如今是 words(..) 的輸入 -- 成爲了 "H,o,w, ,a,r,e,y,u,n,?" 這樣的字符串。而後 words(..) 中的行爲將字符串處理成爲 chars 數組。

不得不認可,這是個刻意的例子。但重點是,函數的組合不老是單向的。有時候咱們將灰方塊放到藍方塊上,有時咱們又會將藍方塊放到最上面。

假如糖果工廠嘗試將包裝好的糖果放入攪拌和冷卻巧克力的機器,那他們最好要當心點了。

通用組合

若是咱們可以定義兩個函數的組合,咱們也一樣可以支持組合任意數量的函數。任意數目函數的組合的通用可視化數據流以下:

finalValue <-- func1 <-- func2 <-- ... <-- funcN <-- origValue
複製代碼

如今糖果工廠擁有了最好的製造機:它可以接收任意數量獨立的小機器,並吐出一個大隻的、超讚的機器,能把每一步都按照順序作好。這個糖果製做流程簡直棒呆了!簡直是威利·旺卡(譯者注:《查理和巧克力工廠》中的人物,他擁有一座巧克力工廠)的夢想!

咱們可以像這樣實現一個通用 compose(..) 實用函數:

function compose(...fns) {
	return function composed(result){
		// 拷貝一份保存函數的數組
		var list = fns.slice();

		while (list.length > 0) {
			// 將最後一個函數從列表尾部拿出
			// 並執行它
			result = list.pop()( result );
		}

		return result;
	};
}

// ES6 箭頭函數形式寫法
var compose =
	(...fns) =>
		result => {
			var list = fns.slice();

			while (list.length > 0) {
				// 將最後一個函數從列表尾部拿出
				// 並執行它
				result = list.pop()( result );
			}

			return result;
		};
複製代碼

如今看一下組合超過兩個函數的例子。回想下咱們的 uniqueWords(..) 組合例子,讓咱們增長一個 skipShortWords(..)

function skipShortWords(list) {
	var filteredList = [];

	for (let i = 0; i < list.length; i++) {
		if (list[i].length > 4) {
			filteredList.push( list[i] );
		}
	}

	return filteredList;
}
複製代碼

讓咱們再定義一個 biggerWords(..) 來包含 skipShortWords(..)。咱們指望等價的手工組合方式是 skipShortWords(unique(words(text))),因此讓咱們採用 compose(..) 來實現它:

var text = "To compose two functions together, pass the \ output of the first function call as the input of the \ second function call.";

var biggerWords = compose( skipShortWords, unique, words );

var wordsUsed = biggerWords( text );

wordsUsed;
// ["compose","functions","together","output","first",
// "function","input","second"]
複製代碼

如今,讓咱們回憶一下第 3 章中出現的 partialRight(..) 來讓組合變的更有趣。咱們可以構造一個由 compose(..) 自身組成的右偏函數應用,經過提早定義好第二和第三參數(unique(..)words(..));咱們把它稱做 filterWords(..)(以下)。

而後,咱們可以經過屢次調用 filterWords(..) 來完成組合,可是每次的第一參數卻各不相同。

// 注意: 使用 a <= 4 來檢查,而不是 skipShortWords(..) 中用到的 > 4
function skipLongWords(list) { /* .. */ }

var filterWords = partialRight( compose, unique, words );

var biggerWords = filterWords( skipShortWords );
var shorterWords = filterWords( skipLongWords );

biggerWords( text );
// ["compose","functions","together","output","first",
// "function","input","second"]

shorterWords( text );
// ["to","two","pass","the","of","call","as"]
複製代碼

花些時間考慮一下基於 compose(..) 的右偏函數應用給了咱們什麼。它容許咱們在組合的第一步以前作指定,而後以不一樣後期步驟 (biggerWords(..) and shorterWords(..)) 的組合來建立特定的變體。這是函數式編程中最強大的手段之一。

你也能經過 curry(..) 建立的組合來替代偏函數應用,但由於從右往左的順序,比起只使用 curry( compose, ..),你可能更想使用 curry( reverseArgs(compose), ..)

注意: 由於 curry(..)(至少咱們在第 3 章中實現的是這樣)依賴於探測參數數目(length)或手動指定其數目,而 compose(..) 是一個可變的函數,因此你須要手動指定數目,就像這樣 curry(.. , 3)

不一樣的實現

固然,你可能永遠不會在生產中使用本身寫的 compose(..),而更傾向於使用某個庫所提供的方案。但我發現瞭解底層工做的原理實際上對強化理解函數式編程中通用概念很是有用。

因此讓咱們看看對於 compose(..) 的不一樣實現方案。咱們能看到每一種實現的優缺點,特別是性能方面。

咱們將稍後在文中查看 reduce(..) 實用函數的細節,但如今,只需瞭解它將一個列表(數組)簡化爲一個單一的有限值。看起來像是一個很棒的循環體。

舉個例子,若是在數字列表 [1,2,3,4,5,6] 上作加法約減,你將要循環它們,而且隨着循環將它們加在一塊兒。這一過程將首先將 12,而後將結果加 3,而後加 4,等等。最後獲得總和:21

原始版本的 compose(..) 使用一個循環而且飢渴的(也就是,馬上)執行計算,將一個調用的結果傳遞到下一個調用。咱們能夠經過 reduce(..) (代替循環)作到一樣的事。

function compose(...fns) {
	return function composed(result){
		return fns.reverse().reduce( function reducer(result,fn){
			return fn( result );
		}, result );
	};
}

// ES6 箭頭函數形式寫法
var compose = (...fns) =>
	result =>
		fns.reverse().reduce(
			(result,fn) =>
				fn( result )
			, result
		);
複製代碼

注意到 reduce(..) 循環發生在最後的 composed(..) 運行時,而且每個中間的 result(..) 將會在下一次調用時做爲輸入值傳遞給下一個迭代。

這種實現的優勢就是代碼更簡練,而且使用了常見的函數式編程結構:reduce(..)。這種實現方式的性能和原始的 for 循環版本很相近。

可是,這種實現侷限處在於外層的組合函數(也就是,組合中的第一個函數)只能接收一個參數。其餘大多數實如今首次調用的時候就把全部參數傳進去了。若是組合中的每個函數都是一元的,這個方案沒啥大問題。但若是你須要給第一個調用傳遞多參數,那麼你可能須要不一樣的實現方案。

爲了修正第一次調用的單參數限制,咱們能夠仍使用 reduce(..) ,但加一個懶執行函數包裹器:

function compose(...fns) {
	return fns.reverse().reduce( function reducer(fn1,fn2){
		return function composed(...args){
			return fn2( fn1( ...args ) );
		};
	} );
}

// ES6 箭頭函數形式寫法
var compose =
	(...fns) =>
		fns.reverse().reduce( (fn1,fn2) =>
			(...args) =>
				fn2( fn1( ...args ) )
		);
複製代碼

注意到咱們直接返回了 reduce(..) 調用的結果,該結果自身就是個函數,不是一個計算過的值。函數讓咱們可以傳入任意數目的參數,在整個組合過程當中,將這些參數傳入到第一個函數調用中,而後依次產出結果給到後面的調用。

相較於直接計算結果並把它傳入到 reduce(..) 循環中進行處理,這種實現經過在組合以前只運行 一次 reduce(..) 循環,而後將全部的函數調用運算所有延遲了 ———— 稱爲惰性運算。每個簡化後的局部結果都是一個包裹層級更多的函數。

當你調用最終組合函數而且提供一個或多個參數的時候,這個層層嵌套的大函數內部的全部層級,由內而外調用,以相反的方式連續執行(不是經過循環)。

這個版本的性能特徵和以前 reduce(..) 基礎實現版有潛在的差別。在這兒,reduce(..) 只在生成大個的組合函數時運行過一次,而後這個組合函數只是簡單的一層層執行它內部所嵌套的函數。在前一版本中,reduce(..) 將在每一次調用中運行。

在考慮哪種實現更好時,你的狀況可能會不同,可是要記得後面的實現方式並無像前一種限制只能傳一個參數。

咱們也可以使用遞歸來定義 compose(..)。遞歸式定義的 compose(fn1,fn2, .. fnN) 看起來會是這樣:

compose( compose(fn1,fn2, .. fnN-1), fnN );
複製代碼

注意: 咱們將在第 9 章揭示更多的細節,因此若是這塊看起來讓你疑惑,那麼暫時跳過該部分是沒問題的,你能夠在閱讀完第 9 章後再來看。

這裏是咱們用遞歸實現 compose(..) 的代碼:

function compose(...fns) {
	// 拿出最後兩個參數
	var [ fn1, fn2, ...rest ] = fns.reverse();

	var composedFn = function composed(...args){
		return fn2( fn1( ...args ) );
	};

	if (rest.length == 0) return composedFn;

	return compose( ...rest.reverse(), composedFn );
}

// ES6 箭頭函數形式寫法
var compose =
	(...fns) => {
		// 拿出最後兩個參數
		var [ fn1, fn2, ...rest ] = fns.reverse();

		var composedFn =
			(...args) =>
				fn2( fn1( ...args ) );

		if (rest.length == 0) return composedFn;

		return compose( ...rest.reverse(), composedFn );
	};
複製代碼

我認爲遞歸實現的好處是更加概念化。我我的以爲相較於不得不在循環裏跟蹤運行結果,經過遞歸的方式進行重複的動做反而更易懂。因此我更喜歡以這種方式的代碼來表達。

其餘人可能會以爲遞歸的方法在智力上形成的困擾更讓人有些畏懼。我建議你做出本身的評估。

重排序組合

咱們早期談及的是從右往左順序的標準 compose(..) 實現。這麼作的好處是可以和手工組合列出參數(函數)的順序保持一致。

不足之處就是它們排列的順序和它們執行的順序是相反的,這將會形成困擾。同時,不得不使用 partialRight(compose, ..) 提前定義要在組合過程當中 第一個 執行的函數。

相反的順序,從右往左的組合,有個常見的名字:pipe(..)。這個名字聽說來自 Unix/Linux 界,那裏大量的程序經過「管道傳輸」(| 運算符)第一個的輸出到第二個的輸入,等等(即,ls -la | grep "foo" | less)。

pipe(..)compose(..) 如出一轍,除了它將列表中的函數從左往右處理。

function pipe(...fns) {
	return function piped(result){
		var list = fns.slice();

		while (list.length > 0) {
			// 從列表中取第一個函數並執行
			result = list.shift()( result );
		}

		return result;
	};
}
複製代碼

實際上,咱們只需將 compose(..) 的參數反轉就能定義出來一個 pipe(..)

var pipe = reverseArgs( compose );
複製代碼

很是簡單!

回憶下以前的通用組合的例子:

var biggerWords = compose( skipShortWords, unique, words );
複製代碼

pipe(..) 的方式來實現,咱們只須要反轉參數的順序:

var biggerWords = pipe( words, unique, skipShortWords );
複製代碼

pipe(..) 的優點在於它以函數執行的順序排列參數,某些狀況下可以減輕閱讀者的疑惑。pipe(words,unique,skipShortWords) 看起來和讀起來會更簡單,能知道咱們首先執行 words(..),而後 unique(..),最後是 skipShortWords(..)

假如你想要部分的應用第一個函數(們)來負責執行,pipe(..) 一樣也很方便。就像咱們以前使用 compose(..) 構建的右偏函數應用同樣。

對比:

var filterWords = partialRight( compose, unique, words );

// vs

var filterWords = partial( pipe, words, unique );
複製代碼

你可能會回想起第 3 章 partialRight(..) 中的定義,它實際使用了 reverseArgs(..),就像咱們的 pipe(..) 如今所作的。因此,無論怎樣,咱們獲得了一樣的結果。

在這一特定場景下使用 pipe(..) 的輕微性能優點在於咱們沒必要再經過右偏函數應用的方式來使用 compose(..) 保存從右往左的參數順序,使用 pipe(..) 咱們沒必要再跟 partialRight(..) 同樣須要將參數順序反轉回去。因此在這裏 partial(pipe, ..)partialRight(compose, ..) 要好一點。

通常來講,在使用一個完善的函數式編程庫時,pipe(..)compose(..) 沒有明顯的性能區別。

抽象

抽象常常被定義爲對兩個或多個任務公共部分的剝離。通用部分只定義一次,從而避免重複。爲了展示每一個任務的特殊部分,通用部分須要被參數化。

舉個例子,思考以下(明顯刻意生成的)代碼:

function saveComment(txt) {
	if (txt != "") {
		comments[comments.length] = txt;
	}
}

function trackEvent(evt) {
	if (evt.name !== undefined) {
		events[evt.name] = evt;
	}
}
複製代碼

這兩個實用函數都是將一個值存入一個數據源,這是通用的部分。不一樣的是一個是將值放置到數組的末尾,另外一個是將值放置到對象的某個屬性上。

讓咱們抽象一下:

function storeData(store,location,value) {
	store[location] = value;
}

function saveComment(txt) {
	if (txt != "") {
		storeData( comments, comments.length, txt );
	}
}

function trackEvent(evt) {
	if (evt.name !== undefined) {
		storeData( events, evt.name, evt );
	}
}
複製代碼

引用一個對象(或數組,多虧了 JS 中方便的 [] 符號)屬性和將值設入的通用任務被抽象到獨立的 storeData(..) 函數。這個函數當前只有一行代碼,該函數能提出其它多任務中通用的行爲,好比生成惟一的數字 ID 或將時間戳存入。

若是咱們在多處重複通用的行爲,咱們將會面臨改了幾處但忘了改別處的維護風險。在作這類抽象時,有一個原則是,一般被稱做 DRY(don't repeat yourself)。

DRY 力求能在程序的任何任務中有惟一的定義。代碼不夠 DRY 的另外一個託辭就是程序員們太懶,不想作非必要的工做。

抽象可以走得更遠。思考:

function conditionallyStoreData(store,location,value,checkFn) {
	if (checkFn( value, store, location )) {
		store[location] = value;
	}
}

function notEmpty(val) { return val != ""; }

function isUndefined(val) { return val === undefined; }

function isPropUndefined(val,obj,prop) {
	return isUndefined( obj[prop] );
}

function saveComment(txt) {
	conditionallyStoreData( comments, comments.length, txt, notEmpty );
}

function trackEvent(evt) {
	conditionallyStoreData( events, evt.name, evt, isPropUndefined );
}
複製代碼

爲了實現 DRY 和避免重複的 if 語句,咱們將條件判斷移動到了通用抽象中。咱們一樣假設在程序中其它地方可能會檢查非空字符串或非 undefined 的值,因此咱們也能將這些東西 DRY 出來。

這些代碼如今變得更 DRY 了,但有些抽象過分了。開發者須要對他們程序中每一個部分使用恰當的抽象級別保持謹慎,不能太過,也不能不夠。

關於咱們在本章中對函數的組合進行的大量討論,看起來它的好處是實現這種 DRY 抽象。但讓咱們別急着下結論,由於我認爲組合實際上在咱們的代碼中發揮着更重要的做用。

並且,即便某些東西只出現了一次,組合仍然十分有用 (沒有重複的東西能夠被抽出來)。

除了通用化和特殊化的對比,我認爲抽象有更多有用的定義,正以下面這段引用所說:

... 抽象是一個過程,程序員將一個名字與潛在的複雜程序片斷關聯起來,這樣該名字就可以被認爲表明函數的目的,而不是表明函數如何實現的。經過隱藏無關的細節,抽象下降了概念複雜度,讓程序員在任意時間均可以集中注意力在程序內容中的可維護子集上。

《程序設計語言》, 邁克爾 L 斯科特

books.google.com/books?id=jM…

// TODO: 給這本書或引用弄一個更好的參照,至少找到一個更好的在線連接

這段引用表述的觀點是抽象 ———— 一般來講,是指把一些代碼片斷放到本身的函數中 ———— 是圍繞着能將兩部分功能分離,從而達到能夠專一於某一獨立的部分爲主要目的來服務的。

須要注意的是,這種場景下的抽象並非爲了隱藏細節,好比把一些東西看成黑盒來對待。這一觀念其實更貼近於編程中的封裝性原則。咱們不是爲了隱藏細節而抽象,而是爲了經過分離來突出關注點

還記得這段文章的開頭,我說函數式編程的目的是爲了創造更可讀、更易理解的代碼。一個有效的方法是將交織纏繞的 ———— 牢牢編織在一塊兒,像一股繩子 ———— 代碼解綁爲分離的、更簡單的 ———— 鬆散綁定的 ———— 代碼片斷。以這種方式來作的話,代碼的閱讀者將不會在尋找其它部分細節的時候被其中某塊的細節所分心。

咱們更高的目標是不僅對某些東西實現一次,這是 DRY 的觀念。實際上,有些時候咱們確實在代碼中不斷重複。因而,咱們尋求更分離的實現方式。咱們嘗試突出關注點,由於這能提升可讀性。

另外一種描述這個目標的方式就是 ———— 經過命令式 vs 聲明式的編程風格。命令式代碼主要關心的是描述怎麼作來準確完成一項任務。聲明式代碼則是描述輸出應該是什麼,並將具體實現交給其它部分。

換句話說,聲明式代碼從怎麼作中抽象出了是什麼。儘管普通的聲明式代碼在可讀性上強於命令式,但沒有程序(除了機器碼 1 和 0)是徹底的聲明式或者命令式代碼。編程者必須在它們之間尋找平衡。

ES6 增長了不少語法功能,能將老的命令式操做轉換爲新的聲明式形式。可能最清晰的當屬解構了。解構是一種賦值模式,它描述瞭如何將組合值(對象、數組)內的構成值分解出來的方法。

這裏是一個數組解構的例子:

function getData() {
	return [1,2,3,4,5];
}

// 命令式
var tmp = getData();
var a = tmp[0];
var b = tmp[3];

// 聲明式
var [ a ,,, b ] = getData();
複製代碼

是什麼就是將數組中的第一個值賦給 a,而後第四個值賦給 b怎麼作就是獲得一個數組的引用(tmp)而後手動的經過數組索引 03,分別賦值給 ab

數組的解構是否隱藏了賦值細節?這要看你看待的角度了。我認爲它知識簡單的將是什麼怎麼作中分離出來。JS 引擎仍然作了賦值的工做,但它阻止了你本身去抽象怎麼作的過程。

相反的是,你閱讀 [ a ,,, b ] = .. 的時候,便能看到該賦值模式只不過是告訴你將要發生的是什麼。數組的解構是聲明式抽象的一個例子。

將組合看成抽象

函數組合到底作了什麼?函數組合一樣也是一種聲明式抽象。

回想下以前的 shorterWords(..) 例子。讓咱們對比下命令式和聲明式的定義。

// 命令式
function shorterWords(text) {
	return skipLongWords( unique( words( text ) ) );
}

// 聲明式
var shorterWords = compose( skipLongWords, unique, words );
複製代碼

聲明式關注點在是什麼上 -- 這 3 個函數傳遞的數據從一個字符串到一系列更短的單詞 -- 而且將怎麼作留在了 compose(..)的內部。

在一個更大的層面上看,shorterWords = compose(..) 行解釋了怎麼作來定義一個 shorterWords(..) 實用函數,這樣在代碼的別處使用時,只需關注下面這行聲明式的代碼輸出是什麼

shorterWords( text );
複製代碼

組合將一步步獲得一系列更短的單詞的過程抽象了出來。

相反的看,若是咱們不使用組合抽象呢?

var wordsFound = words( text );
var uniqueWordsFound = unique( wordsFound );
skipLongWords( uniqueWordsFound );
複製代碼

或者這種:

skipLongWords( unique( words( text ) ) );
複製代碼

這兩個版本展現的都是一種更加命令式的風格,違背了聲明式風格優先原則。閱讀者關注這兩個代碼片斷時,會被更多的要求瞭解怎麼作而不是是什麼

函數組合並非經過 DRY 的原則來節省代碼量。即便 shorterWords(..) 的使用只出現了一次 -- 因此並無重複問題須要避免!-- 從怎麼作中分離出是什麼仍能幫助咱們提高代碼。

組合是一個抽象的強力工具,它可以將命令式代碼抽象爲更可讀的聲明式代碼。

回顧形參

既然咱們已經把組合都瞭解了一遍 -- 那麼是時候拋出函數式編程中不少地方都有用的小技巧了 -- 讓咱們經過在某個場景下回顧第 3 章的「無形參」(譯者注:「無形參」指的是移除對函數形參的引用)段落中的 point-free 代碼,並把它重構的稍微複雜點來觀察這種小技巧。

// 提供該API:ajax( url, data, cb )
var getPerson = partial( ajax, "http://some.api/person" );
var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } );

getLastOrder( function orderFound(order){
	getPerson( { id: order.personId }, function personFound(person){
		output( person.name );
	} );
} );
複製代碼

咱們想要移除的「點」是對 orderperson 參數的引用。

讓咱們嘗試將 person 形參移出 personFound(..) 函數。要達到目的,咱們須要首先定義:

function extractName(person) {
	return person.name;
}
複製代碼

但據咱們觀察這段操做可以表達的更通用些:將任意對象的任意屬性經過屬性名提取出來。讓咱們把這個實用函數稱爲 prop(..)

function prop(name,obj) {
	return obj[name];
}

// ES6 箭頭函數形式
var prop =
	(name,obj) =>
		obj[name];
複製代碼

咱們處理對象屬性的時候,也須要定義下反操做的工具函數:setProp(..),爲了將屬性值設到某個對象上。

可是,咱們想當心一些,不改動現存的對象,而是建立一個攜帶變化的複製對象,並將它返回出去。這樣處理的緣由將在第 5 章中討論更多細節。

function setProp(name,obj,val) {
	var o = Object.assign( {}, obj );
	o[name] = val;
	return o;
}
複製代碼

如今,定義一個 extractName(..) ,它能將對象中的 "name" 屬性拿出來,咱們將部分應用 prop(..)

var extractName = partial( prop, "name" );
複製代碼

注意: 不要誤解這裏的 extractName(..),它其實什麼都尚未作。咱們只是部分應用 prop(..) 來建立了一個等待接收包含 "name"屬性的對象的函數。咱們也能經過curry(prop)("name")作到同樣的事。

下一步,讓咱們縮小關注點,看下例子中嵌套的這塊查找操做的調用:

getLastOrder( function orderFound(order){
	getPerson( { id: order.personId }, outputPersonName );
} );
複製代碼

咱們該如何定義 outputPersonName(..)?爲了方便形象化咱們所須要的東西,想一下咱們須要的數據流是什麼樣:

output <-- extractName <-- person
複製代碼

outputPersonName(..) 須要是一個接收(對象)值的函數,並將它傳遞給 extractName(..),而後將處理後的值傳給 output(..)

但願你能看出這裏須要 compose(..) 操做。因此咱們可以將 outputPersonName(..) 定義爲:

var outputPersonName = compose( output, extractName );
複製代碼

咱們剛剛建立的 outputPersonName(..) 函數是提供給 getPerson(..) 的回調。因此咱們還能定義一個函數叫作 processPerson(..) 來處理回調參數,使用 partialRight(..)

var processPerson = partialRight( getPerson, outputPersonName );
複製代碼

讓咱們用新函數來重構下以前的代碼:

getLastOrder( function orderFound(order){
	processPerson( { id: order.personId } );
} );
複製代碼

唔,進展還不錯!

但咱們須要繼續移除掉 order 這個「形參」。下一步是觀察 personId 可以被 prop(..) 從一個對象(好比 order)中提取出來,就像咱們在 person 對象中提取 name 同樣。

var extractPersonId = partial( prop, "personId" );
複製代碼

爲了建立傳遞給 processPerson(..) 的對象( { id: .. } 的形式),讓咱們建立一個實用函數 makeObjProp(..),用來以特定的屬性名將值包裝爲一個對象。

function makeObjProp(name,value) {
	return setProp( name, {}, value );
}

// ES6 箭頭函數形式
var makeObjProp =
	(name,value) =>
		setProp( name, {}, value );
複製代碼

提示: 這個實用函數在 Ramda 庫中被稱爲 objOf(..)

就像咱們以前使用 prop(..) 來建立 extractName(..),咱們將部分應用 makeObjProp(..) 來建立 personData(..) 函數用來製做咱們的數據對象。

var personData = partial( makeObjProp, "id" );
複製代碼

爲了使用 processPerson(..) 來完成經過 order 值查找一我的的功能,咱們須要的數據流以下:

processPerson <-- personData <-- extractPersonId <-- order
複製代碼

因此咱們只須要再使用一次 compose(..) 來定義一個 lookupPerson(..)

var lookupPerson = compose( processPerson, personData, extractPersonId );
複製代碼

而後,就是這樣了!把這整個例子從新組合起來,不帶任何的「形參」:

var getPerson = partial( ajax, "http://some.api/person" );
var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } );

var extractName = partial( prop, "name" );
var outputPersonName = compose( output, extractName );
var processPerson = partialRight( getPerson, outputPersonName );
var personData = partial( makeObjProp, "id" );
var extractPersonId = partial( prop, "personId" );
var lookupPerson = compose( processPerson, personData, extractPersonId );

getLastOrder( lookupPerson );
複製代碼

哇哦。沒有形參。而且 compose(..) 在兩處地方看起來至關有用!

我認爲在這樣的場景下,即便推導出咱們最終答案的步驟有些多,但最終的代碼卻變得更加可讀,由於咱們不用再去詳細的調用每一步了。

即便你不想看到或命名這麼多中間步驟,你依然能夠經過不使用獨立變量而是將表達式串起來來來保留無點特性。

partial( ajax, "http://some.api/order", { id: -1 } )
(
	compose(
		partialRight(
			partial( ajax, "http://some.api/person" ),
			compose( output, partial( prop, "name" ) )
		),
		partial( makeObjProp, "id" ),
		partial( prop, "personId" )
	)
);
複製代碼

這段代碼確定沒那麼羅嗦了,但我認爲比以前的每一個操做都有其對應的變量相比,可讀性略有下降。可是無論怎樣,組合幫助咱們實現了無點的風格。

總結

函數組合是一種定義函數的模式,它能將一個函數調用的輸出路由到另外一個函數的調用上,而後一直進行下去。

由於 JS 函數只能返回單個值,這個模式本質上要求全部組合中的函數(可能第一個調用的函數除外)是一元的,當前函數從上一個函數輸出中只接收一個輸入。

相較於在咱們的代碼裏詳細列出每一個調用,函數組合使用 compose(..) 實用函數來提取出實現細節,讓代碼變得更可讀,讓咱們更關注組合完成的是什麼,而不是它具體作什麼

組合 ———— 聲明式數據流 ———— 是支撐函數式編程其餘特性的最重要的工具之一。

** 【上一章】翻譯連載 | JavaScript 輕量級函數式編程-第3章:管理函數的輸入 |《你不知道的JS》姊妹篇 **

** 【下一章】翻譯連載 | JavaScript輕量級函數式編程-第5章:減小反作用 |《你不知道的JS》姊妹篇 **

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

>> 滬江Web前端上海團隊招聘【Web前端架構師】,有意者簡歷至:zhouyao@hujiang.com <<


2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息