翻譯連載 | JavaScript輕量級函數式編程-第 8 章:列表操做 |《你不知道的JS》姊妹篇

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

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

第 8 章:列表操做

你是否還沉迷於上一節介紹的閉包/對象之中?歡迎回來!git

若是你能作一些使人驚歎的事情,請持續保持下去。github

本文以前已經簡要的說起了一些實用函數:map(..)filter(..)reduce(..),如今深刻了解一下它們。在 Javascript 中,這些實用函數一般被用於 Array(即 「list」 )的原型上。所以能夠很天然的將這些實用函數和數組或列表操做聯繫起來。算法

在討論具體的數組方法以前,咱們應該很清楚這些操做的做用。在這章中,弄明白爲什麼有這些列表操做和這些操做如何工做同等重要。請保持頭腦清晰,跟上節奏。編程

在本章內外,有大量常見且通俗易懂的列表操做的例子,它們描述一些細小的操做去處理一系列的值(如數組中的每個值加倍)。這樣通俗易懂。數組

可是不要停留在這些簡單示例的表面,而錯過了更深層次的點。經過對一系列任務建模來理解一些很是重要的函數式編程在列表操做中的價值 —— 一些些看起來不像列表的語句 —— 做爲列表操做,而不是單獨執行。安全

這不只僅是編寫許多簡練代碼的技巧。咱們所要作的是,從命令式轉變爲聲明式風格,使代碼模式更容易辨認,從而可讀性更好。性能優化

但這裏有一些更須要掌握的東西。在命令式代碼中,一組計算的中間結果都是經過賦值來存儲。代碼中依賴的命令模式越多,越難驗證它們不是錯誤。好比,在邏輯上,值的意外改變,或隱藏的潛在緣由/影響。session

經過與/或連接組合列表操做,中間結果被隱式地跟蹤,並在很大程度上避免了這些風險。

注意: 相比前面幾章,爲了代碼片斷更加簡練,咱們將採用 ES6 的箭頭函數。儘管第 2 章中對於箭頭函數的建議依舊廣泛適用於編碼中。

非函數式編程列表處理

做爲本章討論的快速預覽,我想調用一些操做,這些操做看上去能夠將 Javascript 數組和函數式編程列表操做相關聯,但事實上並無。咱們不會在這裏討論這些,由於它們與通常的函數式編程最佳實踐不一致:

  • forEach(..)
  • some(..)
  • every(..)

forEach(..) 是遍歷輔助函數,可是它被設計爲帶有反作用的函數來處理每次遍歷;你或許已經猜想到了它爲何不是咱們正在討論的函數式編程列表操做!

some(..) 和 every(..) 鼓勵使用純函數(具體來講,就像 filter(..) 這樣的謂詞函數),可是它們不可避免地將列表化簡爲 true 或 false 的值,本質上就像搜索和匹配。這兩個實用函數和咱們指望採用函數式編程來組織代碼相匹配,所以,這裏咱們將跳過它們。

映射

咱們將採用最基礎和最簡單的操做 map(..) 來開啓函數式編程列表操做的探索。

映射的做用就將一個值轉換爲另外一個值。例如,若是你將 2 乘以 3,你將獲得轉換的結果 6 。須要重點注意的是,咱們並非在討論映射轉換是暗示就地轉換或從新賦值,而是將一個值從一個地方映射到另外一個新的地方。

換句話說

var x = 2, y;

// 轉換/投影
y = x * 3;

// 變換/從新賦值
x = x * 3;
複製代碼

若是咱們定義了乘 3 這樣的函數,這個函數充當映射(轉換)的功能。

var multipleBy3 = v => v * 3;

var x = 2, y;

// 轉換/投影
y = multiplyBy3( x );
複製代碼

咱們能夠天然的將映射的概念從單個值擴展到值的集合。map(..) 操做將列表中全部的值轉換爲新列表中的列表項,以下圖所示:

實現 map(..) 的代碼以下:

function map(mapperFn,arr) {
	var newList = [];

	for (let idx = 0; idx < arr.length; idx++) {
		newList.push(
			mapperFn( arr[idx], idx, arr )
		);
	}

	return newList;
}
複製代碼

注意: mapperFn, arr 的參數順序,乍一看像是在倒退。可是這種方式在函數式編程類庫中很是常見。由於這樣作,可讓這些實用函數更容易被組合。

mapperFn(..) 天然地將傳入的列表項作映射/轉換,而且也傳入了 idx 和 arr。這樣作,能夠和內置的數組的 map(..) 保持一致。在某些狀況下,這些額外的參數很是有用。

可是,在一些其餘狀況中,你只但願傳遞列表項到 mapperFn(..)。由於額外的參數可能會改變它的行爲。在第三章的「共同目的( All for one )」中,咱們介紹了 unary(..),它限制函數僅僅接受一個參數,不論多少個參數被傳入。

回顧第三章關於把 parseInt() 的參數數量限制爲 1,從而使之成爲可被安全使用的 mapperFn() 的例子:

map( ["1","2","3"], unary( parseInt ) );
// [1,2,3]
複製代碼

Javascript 提供了內置的數組操做方法 map(..),這個方法使得列表中的鏈式操做更爲便利。

注意: Javascript 數組中的原型中定義的操做( map(..)filter(..)reduce(..) )的最後一個可選參數能夠被用於綁定 「this」 到當前函數。咱們在第二章中曾經討論過「什麼是 this?」,以及在函數式編程的最佳實踐中應該避免使用 this。基於這個緣由,在這章中的示例中,咱們不採用 this 綁定功能。

除了明顯的字符和數字操做外,你能夠對列表中的這些值類型進行操做。咱們能夠採用 map(..) 方法來經過函數列表轉換獲得這些函數返回的值,示例代碼以下:

var one = () => 1;
var two = () => 2;
var three = () => 3;

[one,two,three].map( fn => fn() );
// [1,2,3]
複製代碼

咱們也能夠先將函數放在列表中,而後組合列表中的每個函數,最後執行它們,代碼以下:

var increment = v => ++v;
var decrement = v => --v;
var square = v => v * v;

var double = v => v * 2;

[increment,decrement,square]
.map( fn => compose( fn, double ) )
.map( fn => fn( 3 ) );
// [7,5,36]
複製代碼

咱們注意到關於 map(..) 的一些有趣的事情:咱們一般假定列表是從左往右執行的,但 map(..) 沒有這個概念,它確實不須要這個次序。每個轉換應該獨立於其餘的轉換。

映射廣泛適用於並行處理的場景中,尤爲在處理大列表時能夠提高性能。可是在 Javascript 中,咱們並無看到這樣的場景。由於這裏不須要你傳入諸如 mapperFn(..) 這樣的純函數,即使你應當這樣作。若是傳入了非純函數,JS 在不一樣的順序中執行不一樣的方法,這將很快產生大問題。

儘管從理論上講,單個映射操做是獨立的,但 JS 須要假定它們不是。這是使人討厭的。

同步 vs 異步

這篇文章中討論的列表操做都是同步地操做一組已經存在的值組成的列表,map(..) 在這裏被看做是急切的操做。但另一種思考方式是將映射函數做爲時間處理器,該處理器會在新元素加入到列表中時執行。

想象一下這樣的場景:

var newArr = arr.map();

arr.addEventListener( "value", multiplyBy3 );
複製代碼

如今,任什麼時候候,當一個值加入到 arr 中的時候,multiplyBy3(..) 事件處理器(映射函數)將加入的值當參數執行,將轉換後的結果加入到 newArr

咱們建議,數組以及在數組上應用的數組操做都是迫切的同步的,然而,這些相同的操做也能夠應用在一直接受新值的「惰性列表」(即流)上。咱們將在第 10 章中深刻討論它。

映射 vs 遍歷

有些人提倡在迭代的時候採用 map(..) 替代 forEach(..),它本質上不會去觸碰接受到的值,但仍有可能產生反作用:

[1,2,3,4,5]
.map( function mapperFn(v){
	console.log( v );			// 反作用!
	return v;
} )
..
複製代碼

這種技術彷佛很是有用的緣由是 map(..) 返回數組,這樣你能夠在它以後繼續鏈式執行更多的操做。而 forEach(..) 返回的的值是 undefined。然而,我認爲你應當避免採用這種方式使用 map(..),由於這裏明顯的以非函數式編程的方式使用核心的函數式編程操做,將引發巨大的困惑。

你應該聽過一句老話,用合適的工具作合適的事,對嗎?錘子敲釘子,螺絲刀擰螺絲等等。這裏有些細微的不一樣:採用恰當的方式使用合適的工具。

錘子是揮動手敲的,若是你嘗試採用嘴去釘釘子,效率會大打折扣。map(..) 是用來映射值的,而不是帶來反作用。

一個詞:函子

在這本書中,咱們儘量避免使用人爲創造的函數式編程術語。咱們有時候會使用官方術語,但在大多數時候,採用平常用語來描述更加通俗易懂。

這裏我將被一個可能會引發恐慌的詞:函子來短暫地打斷這種通俗易懂的模式。這裏之因此要討論函子的緣由是咱們已經瞭解了它是幹什麼的,而且這個詞在函數式編程文獻中被大量使用。你不會被這個詞嚇到而帶來反作用。

函子是採用運算函數有效用操做的值。

若是問題中的值是複合的,意味着它是由單個值組成,就像數組中的狀況同樣。例如,函子在每一個單獨的值上執行操做函數。函子實用函數建立的新值是全部單個操做函數執行的結果的組合。

這就是用 map(..) 來描述咱們所看到東西的一種奇特方式。map(..) 函數採用關聯值(數組)和映射函數(操做函數),併爲數組中的每個獨立元素執行映射函數。最後,它返回由全部新映射值組成的新數組。

另外一個例子:字符串函子是一個字符串加上一個實用函數,這個實用函數在字符串的全部字符上執行某些函數操做,返回包含處理過的字符的字符串。參考以下很是刻意的例子:

function uppercaseLetter(c) {
	var code = c.charCodeAt( 0 );

	// 小寫字母?
	if (code >= 97 && code <= 122) {
		// 轉換爲大寫!
		code = code - 32;
	}

	return String.fromCharCode( code );
}

function stringMap(mapperFn,str) {
	return [...str].map( mapperFn ).join( "" );
}

stringMap( uppercaseLetter, "Hello World!" );
// 你好,世界!
複製代碼

stringMap(..) 容許字符串做爲函子。你能夠定義一個映射函數用於任何數據類型。只要實用函數知足這些規則,該數據結構就是一個函子。

過濾器

想象一下,我帶着空籃子去逛食品雜貨店的水果區。這裏有不少水果(蘋果、橙子和香蕉)。我真的很餓,所以我想要儘量多的水果,可是我真的更喜歡圓形的水果(蘋果和橙子)。所以我逐一篩選每個水果,而後帶着裝滿蘋果和橙子的籃子離開。

咱們將這個篩選的過程稱爲「過濾」。將此次購物描述爲從空籃子開始,而後只過濾(挑選,包含)出蘋果和橙子,或者從全部的水果中過濾掉(跳過,不包括)香蕉。你認爲哪一種方式更天然?

若是你在一鍋水裏面作意大利麪條,而後將這鍋麪條倒入濾網(過濾)中,你是過濾了意大利麪條,仍是過濾掉了水? 若是你將咖啡渣放入過濾器中,而後泡一杯咖啡,你是將咖啡過濾到了杯子裏,仍是說將咖啡渣過濾掉?

你有沒有發現過濾的結果取決於你想要把什麼保留在過濾器中,仍是說用過濾器將其過濾出去?

那麼在航空/酒店網站上如何指定過濾選項呢?你是按照你的標準過濾結果,仍是將不符合標準的過濾掉?仔細想一想,這個例子也許和前面有不相同的語意。

取決於你的想法,過濾是排除的或者保留的,這種概念上的融合,使其難以理解。

我認爲最一般的理解過濾(在編程以外)是剔除掉不須要的成員。不幸的是,在程序中咱們基本上將這個語意倒轉爲更像是過濾須要的成員。

列表的 filter(..) 操做採用一個函數肯定每一項在新數組中是保留仍是剔除。這個函數返回 true 將保留這一項,返回 false 將剔除這一項。這種返回 true/false 來作決定的函數有一個特別的稱謂:謂詞函數。

若是你認爲 true 是積極的信號,filter(..) 的定義是你是「保留」一個值,而不是「拋棄」一個值。

若是 filter(..) 被用於剔除操做,你須要轉動你的腦子,積極的返回 false 發出排除的信號,而且被動的返回 true 來讓一個值經過過濾器。

這種語意上不匹配的緣由是你會將這個函數命名爲 predicateFn(..),這對於代碼的可讀性有意義,咱們很快會討論這一點。

下圖很形象的介紹了列表間的 filter(..) 操做:

實現 filter(..) 的代碼以下:

function filter(predicateFn,arr) {
	var newList = [];

	for (let idx = 0; idx < arr.length; idx++) {
		if (predicateFn( arr[idx], idx, arr )) {
			newList.push( arr[idx] );
		}
	}

	return newList;
}
複製代碼

注意,就像以前的 mapperFn(..)predicateFn(..) 不只僅傳入了值,還傳入了 idxarr。若是有必要,也能夠採用 unary(..) 來限制它的形參。

正如 map(..)filter(..) 也是 JS 數組內置支持的實用函數。

咱們將謂詞函數定義這樣:

var whatToCallIt = v => v % 2 == 1;
複製代碼

這個函數採用 v % 2 == 1 來返回 truefalse。這裏的效果是,值爲奇數時返回 true,值爲偶數時返回 false。這樣,咱們該如何命名這個函數?一個很天然的名字多是:

var isOdd = v => v % 2 == 1;
複製代碼

考慮一下如何在你的代碼中使用 isOdd(..) 來作簡單的值檢查:

var midIdx;

if (isOdd( list.length )) {
	midIdx = (list.length + 1) / 2;
}
else {
	midIdx = list.length / 2;
}
複製代碼

有感受了,對吧?讓咱們採用內置的數組的 filter(..) 來對一組值作篩選:

[1,2,3,4,5].filter( isOdd );
// [1,3,5]
複製代碼

若是讓你描述 [1,3,5] 這個結果,你是說「我將偶數過濾掉了」,仍是說「我作了奇數的篩選」 ?我認爲前者是更天然的描述。但後者的代碼可讀性更好。閱讀代碼幾乎是逐字的,這樣咱們「過濾的每個數字都是奇數」。

我我的以爲這語意混亂。對於經驗豐富的開發者來講,這裏毫無疑問有大量的先例。可是對於一個新手來講,這個邏輯表達看上去不採用雙重否認很差表達,換句話說,採用雙重否認來表達比較好。

爲了便以理解,咱們能夠將這個函數從 isOdd(..) 重命名爲 isEven(..)

var isEven = v => v % 2 == 1;

[1,2,3,4,5].filter( isEven );
// [1,3,5]
複製代碼

耶,可是這個函數名變得無心義,下面的示例中,傳入的偶數,確返回了 false

isEven( 2 );		// false
複製代碼

呸!

回顧在第 3 章中的 "No Points",咱們定義 not(..) 操做來反轉謂詞函數,代碼以下:

var isEven = not( isOdd );

isEven( 2 );		// true
複製代碼

但在前面定義的 filter(..) 方式中,沒法使用這個 isEven(..),由於它的邏輯已經反轉了。咱們將以偶數結束,而不是奇數,咱們須要這麼作:

[1,2,3,4,5].filter( not( isEven ) );
// [1,3,5]
複製代碼

這樣徹底違背了咱們的初衷,因此咱們不要這麼作。這樣,咱們轉一圈又回來了。

過濾掉 & 過濾

爲了消除這些困惑,咱們定義 filterOut(..) 函數來執行過濾掉那些值,而實際上其內部執行否認的謂詞檢查。這樣,咱們將已經定義的 filter(..) 設置別名爲 filterIn(..)

var filterIn = filter;

function filterOut(predicateFn,arr) {
	return filterIn( not( predicateFn ), arr );
}
複製代碼

如今,咱們能夠在任意過濾操做中,使用語意化的過濾器,代碼以下所示:

isOdd( 3 );								// true
isEven( 2 );							// true

filterIn( isOdd, [1,2,3,4,5] );			// [1,3,5]
filterOut( isEven, [1,2,3,4,5] );		// [1,3,5]
複製代碼

我認爲採用 filterIn(..)filterOut(..)(在 Ramda 中稱之爲 reject(..) )會讓代碼的可讀性比僅僅採用 filter(..) 更好。

Reduce

map(..)filter(..) 都會產生新的數組,而第三種操做(reduce(..))則是典型地將列表中的值合併(或減小)到單個值(非列表),好比數字或者字符串。本章後續會探討如何採用高級的方式使用 reduce(..)reduce(..) 是函數式編程中的最重要的實用函數之一。就像瑞士軍刀同樣,具備豐富的用途。

組合或縮減被抽象的定義爲將兩個值轉換成一個值。有些函數式編程文獻將其稱爲「摺疊」,就像你將兩個值合併到一個值。我認爲這對於可視化是頗有幫助的。

就像映射和過濾,合併的方式徹底取決於你,通常取決於列表中值的類型。例如,數字一般採用算術計算合併,字符串採用拼接的方式合併,函數採用組合調用來合併。

有時候,縮減操做會指定一個 initialValue,而後將這個初始值和列表的第一個元素合併。而後逐一和列表中剩餘的元素合併。以下圖所示:

你也能夠去掉上述的 initialValue,直接將第一個列表元素當作 initialValue,而後和列表中的第二個元素合併,以下圖所示:

警告: 在 JavaScript 中,若是在縮減操做的列表中一個值都沒有(在數組中,或沒有指定 initialValue ),將會拋出異常。一個縮減操做的列表有可能爲空的時候,須要當心採用不指定 initialValue 的方式。

傳遞給 reduce(..) 執行縮減操做的函數執行通常稱爲縮減器。縮減器和以前介紹的映射和謂詞函數有不一樣的特徵。縮減器主要接受當前的縮減結果和下一個值來作縮減操做。每一步縮減的當前結果一般稱爲累加器。

例如,對 五、十、15 採用初始值爲 3 執行乘的縮減操做:

  1. 3 * 5 = 15
  2. 15 * 10 = 150
  3. 150 * 15 = 2250

在 JavaScript 中採用內置的 reduce(..) 方法來表達列表的縮減操做:

[5,10,15].reduce( (product,v) => product * v, 3 );
// 2250
複製代碼

咱們能夠採用下面的方式實現 reduce(..)

function reduce(reducerFn,initialValue,arr) {
	var acc, startIdx;

	if (arguments.length == 3) {
		acc = initialValue;
		startIdx = 0;
	}
	else if (arr.length > 0) {
		acc = arr[0];
		startIdx = 1;
	}
	else {
		throw new Error( "Must provide at least one value." );
	}

	for (let idx = startIdx; idx < arr.length; idx++) {
		acc = reducerFn( acc, arr[idx], idx, arr );
	}

	return acc;
}
複製代碼

就像 map(..)filter(..),縮減函數也傳遞不經常使用的 idxarr 形參,以防縮減操做須要。我不會常常用到它們,但我以爲保留它們是明智的。

在第 4 章中,咱們討論了 compose(..) 實用函數,和展現了用 reduce(..) 來實現的例子:

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

基於不一樣的組合,爲了說明 reduce(..),能夠認爲縮減器將函數從左到右組合(就像 pipe(..) 作的事情)。在列表中這樣使用:

var pipeReducer = (composedFn,fn) => pipe( composedFn, fn );

var fn =
	[3,17,6,4]
	.map( v => n => v * n )
	.reduce( pipeReducer );

fn( 9 );			// 11016 (9 * 3 * 17 * 6 * 4)
fn( 10 );			// 12240 (10 * 3 * 17 * 6 * 4)
複製代碼

不幸的是,pipeReducer(..) 是非點自由的(見第 3 章中的「無形參」),但咱們不能僅僅以縮減器自己來傳遞 pipe(..),由於它是可變的;傳遞給 reduce(..) 額外的參數(idxarr)會產生問題。

前面,咱們討論採用 unary(..) 來限制 mapperFn(..)predicateFn(..) 僅採用一個參數。binary(..) 作了相似的事情,但在 reducerFn(..) 中限定兩個參數:

var binary =
	fn =>
		(arg1,arg2) =>
			fn( arg1, arg2 );
複製代碼

採用 binary(..),相比以前的示例有一些簡潔:

var pipeReducer = binary( pipe );

var fn =
	[3,17,6,4]
	.map( v => n => v * n )
	.reduce( pipeReducer );

fn( 9 );			// 11016 (9 * 3 * 17 * 6 * 4)
fn( 10 );			// 12240 (10 * 3 * 17 * 6 * 4)
複製代碼

不像 map(..)filter(..),對傳入數組的次序沒有要求。reduce(..) 明確要採用從左到右的處理方式。若是你想從右到左縮減,JavaScript 提供了 reduceRight(..) 函數,它和 reduce(..) 的行爲出了次序不同外,其餘都相同。

var hyphenate = (str,char) => str + "-" + char;

["a","b","c"].reduce( hyphenate );
// "a-b-c"

["a","b","c"].reduceRight( hyphenate );
// "c-b-a"
複製代碼

reduce(..) 採用從左到右的方式工做,很天然的聯想到組合函數中的 pipe(..)reduceRight(..) 從右往左的方式能天然的執行 compose(..)。所以,咱們從新採用 reduceRight(..) 實現 compose(..)

function compose(...fns) {
	return function composed(result){
		return fns.reduceRight( function reducer(result,fn){
			return fn( result );
		}, result );
	};
}
複製代碼

這樣,咱們不須要執行 fns.reverse();咱們只須要從另外一個方向執行縮減操做!

Map 也是 Reduce

map(..) 操做本質來講是迭代,所以,它也能夠看做是(reduce(..))操做。這個技巧是將 reduce(..)initialValue 當作它自身的空數組。在這種狀況下,縮減操做的結果是另外一個列表!

var double = v => v * 2;

[1,2,3,4,5].map( double );
// [2,4,6,8,10]

[1,2,3,4,5].reduce(
	(list,v) => (
		list.push( double( v ) ),
		list
	), []
);
// [2,4,6,8,10]
複製代碼

注意: 咱們欺騙了這個縮減器,並容許採用 list.push(..) 去改變傳入的列表所帶來的反作用。通常來講,這並非一個好主意,但咱們清楚建立和傳入 [] 列表,這樣就不那麼危險了。建立一個新的列表,並將 val 合併到這個列表的最後面。這樣更有條理,而且性能開銷較小。咱們將在附錄 A 中討論這種欺騙。

經過 reduce(..) 實現 map(..),並非表面上的明顯的步驟,甚至是一種改善。然而,這種能力對於理解更高級的技術是相當重要的,如在附錄 A 中的「轉換」。

Filter 也是 Reduce

就像經過 reduce(..) 實現 map(..) 同樣,也可使用它實現 filter(..)

var isOdd = v => v % 2 == 1;

[1,2,3,4,5].filter( isOdd );
// [1,3,5]

[1,2,3,4,5].reduce(
	(list,v) => (
		isOdd( v ) ? list.push( v ) : undefined,
		list
	), []
);
// [1,3,5]
複製代碼

注意: 這裏有更加不純的縮減器欺騙。不採用 list.push(..),咱們也能夠採用 list.concat(..) 並返回合併後的新列表。咱們將在附錄 A 中繼續介紹這個欺騙。

高級列表操做

如今,咱們對這些基礎的列表操做 map(..)filter(..)reduce(..) 感到比較舒服。讓咱們看看一些更復雜的操做,這些操做在某些場合下頗有用。這些經常使用的實用函數存在於許多函數式編程的類庫中。

去重

篩選列表中的元素,僅僅保留惟一的值。基於 indexOf(..) 函數查找(它採用 === 嚴格等於表達式):

var unique =
	arr =>
		arr.filter(
			(v,idx) =>
				arr.indexOf( v ) == idx
		);
複製代碼

實現的原理是,當從左往右篩選元素時,列表項的 idx 位置和 indexOf(..) 找到的位置相等時,代表該列表項第一次出現,在這種狀況下,將列表項加入到新數組中。

另外一種實現 unique(..) 的方式是遍歷 arr,當列表項不能在新列表中找到時,將其插入到新的列表中。這樣能夠採用 reduce(..) 來實現:

var unique =
	arr =>
		arr.reduce(
			(list,v) =>
				list.indexOf( v ) == -1 ?
					( list.push( v ), list ) : list
		, [] );
複製代碼

注意: 這裏還有不少其餘的方式實現這個去重算法,好比循環,而且其中很多還更高效,實現方式更聰明。然而,這兩種方式的優勢是,它們使用了內建的列表操做,它們能更方便的和其餘列表操做鏈式/組合調用。咱們會在本章的後面進一步討論這些。

unique(..) 使人滿意地產生去重後的新列表:

unique( [1,4,7,1,3,1,7,9,2,6,4,0,5,3] );
// [1, 4, 7, 3, 9, 2, 6, 0, 5]
複製代碼

扁平化

大多數時候,你看到的數組的列表項不是扁平的,不少時候,數組嵌套了數組,例如:

[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
複製代碼

若是你想將其轉化成下面的形式:

[ 1, 2, 3, 4, 5, 6, 7, 8 ]
複製代碼

咱們尋找的這個操做一般稱爲 flatten(..)。它能夠採用如同瑞士軍刀般的 reduce(..) 實現:

var flatten =
	arr =>
		arr.reduce(
			(list,v) =>
				list.concat( Array.isArray( v ) ? flatten( v ) : v )
		, [] );
複製代碼

注意: 這種處理嵌套列表的實現方式依賴於遞歸,咱們將在後面的章節中進一步討論。

在嵌套數組(任意嵌套層次)中使用 flatten(..)

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]] );
// [0,1,2,3,4,5,6,7,8,9,10,11,12,13]
複製代碼

也許你會限制遞歸的層次到指定的層次。咱們能夠經過增長額外的 depth 形參來實現:

var flatten =
	(arr,depth = Infinity) =>
		arr.reduce(
			(list,v) =>
				list.concat(
					depth > 0 ?
						(depth > 1 && Array.isArray( v ) ?
							flatten( v, depth - 1 ) :
							v
						) :
						[v]
				)
		, [] );
複製代碼

不一樣層級扁平化的結果以下所示:

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 0 );
// [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]]

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 1 );
// [0,1,2,3,4,[5,6,7],[8,[9,[10,[11,12],13]]]]

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 2 );
// [0,1,2,3,4,5,6,7,8,[9,[10,[11,12],13]]]

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 3 );
// [0,1,2,3,4,5,6,7,8,9,[10,[11,12],13]]

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 4 );
// [0,1,2,3,4,5,6,7,8,9,10,[11,12],13]

flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 5 );
// [0,1,2,3,4,5,6,7,8,9,10,11,12,13]
複製代碼

映射,而後扁平化

flatten(..) 的經常使用用法之一是當你映射一組元素列表,而且將每一項值從原來的值轉換爲數組。例如:

var firstNames = [
	{ name: "Jonathan", variations: [ "John", "Jon", "Jonny" ] },
	{ name: "Stephanie", variations: [ "Steph", "Stephy" ] },
	{ name: "Frederick", variations: [ "Fred", "Freddy" ] }
];

firstNames
.map( entry => [entry.name].concat( entry.variations ) );
// [ ["Jonathan","John","Jon","Jonny"], ["Stephanie","Steph","Stephy"],
// ["Frederick","Fred","Freddy"] ]
複製代碼

返回的值是二維數組,這樣也許給處理帶來一些不便。若是咱們想獲得全部名字的一維數組,咱們能夠對這個結果執行 flatten(..)

flatten(
	firstNames
	.map( entry => [entry.name].concat( entry.variations ) )
);
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
// "Fred","Freddy"]
複製代碼

除了稍顯囉嗦以外,將 map(..)flatten(..) 採用獨立的步驟的最主要的缺陷是關於性能方面。它會處理列表兩次。

函數式編程的類庫中,一般會定義一個 flatMap(..)(一般命名爲 chain(..))函數。這個函數將映射和以後的扁平化的操做組合起來。爲了連貫性和組合(經過閉包)的簡易性,flatMap(..) / chain(..) 實用函數的形參 mapperFn, arr 順序一般和咱們以前看到的獨立的 map(..)filter(..)reduce(..) 一致。

flatMap( entry => [entry.name].concat( entry.variations ), firstNames );
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
// "Fred","Freddy"]
複製代碼

幼稚的採用獨立的兩步來實現 flatMap(..)

var flatMap =
	(mapperFn,arr) =>
		flatten( arr.map( mapperFn ), 1 );
複製代碼

注意: 咱們將扁平化的層級指定爲 1,由於一般 flatMap(..) 的定義是扁平化第一級。

儘管這種實現方式依舊會處理列表兩次,帶來了很差的性能。但咱們能夠將這些操做採用 reduce(..) 手動合併:

var flatMap =
	(mapperFn,arr) =>
		arr.reduce(
			(list,v) =>
				list.concat( mapperFn( v ) )
		, [] );
複製代碼

如今 flatMap(..) 方法帶來了便利性和性能。有時你可能須要其餘操做,好比和 filter(..) 混合使用。這樣的話,將 map(..)flatten(..) 獨立開來始終更加合適。

Zip

到目前爲止,咱們介紹的列表操做都是操做單個列表。可是在某些狀況下,須要操做多個列表。有一個聞名的操做:交替選擇兩個輸入的列表中的值,並將獲得的值組成子列表。這個操做被稱之爲 zip(..)

zip( [1,3,5,7,9], [2,4,6,8,10] );
// [ [1,2], [3,4], [5,6], [7,8], [9,10] ]
複製代碼

選擇值 12 到子列表 [1,2],而後選擇 34 到子列表 [3,4],而後逐一選擇。zip(..) 被定義爲將兩個列表中的值挑選出來。若是兩個列表的的元素的個數不一致,這個選擇會持續到較短的數組末尾時結束,另外一個數組中多餘的元素會被忽略。

一種 zip(..) 的實現:

function zip(arr1,arr2) {
	var zipped = [];
	arr1 = arr1.slice();
	arr2 = arr2.slice();

	while (arr1.length > 0 && arr2.length > 0) {
		zipped.push( [ arr1.shift(), arr2.shift() ] );
	}

	return zipped;
}
複製代碼

採用 arr1.slice()arr2.slice() 能夠確保 zip(..) 是純的,不會由於接受到到數組引用形成反作用。

注意: 這個實現明顯存在一些非函數式編程的思想。這裏有一個命令式的 while 循環而且採用 shift()push(..) 改變列表。在本書前面,我認爲在純函數中使用非純的行爲(一般是爲了性能)是有道理的,只要其產生的反作用徹底包含在這個函數內部。這種實現是安全純淨的。

合併

採用插入每一個列表中的值的方式合併兩個列表,以下所示:

mergeLists( [1,3,5,7,9], [2,4,6,8,10] );
// [1,2,3,4,5,6,7,8,9,10]
複製代碼

它可能不是那麼明顯,但其結果看上去和採用 flatten(..)zip(..) 組合類似,代碼以下:

zip( [1,3,5,7,9], [2,4,6,8,10] );
// [ [1,2], [3,4], [5,6], [7,8], [9,10] ]

flatten( [ [1,2], [3,4], [5,6], [7,8], [9,10] ] );
// [1,2,3,4,5,6,7,8,9,10]

// 組合後:
flatten( zip( [1,3,5,7,9], [2,4,6,8,10] ) );
// [1,2,3,4,5,6,7,8,9,10]
複製代碼

回顧 zip(..),他選擇較短列表的最後一個值,忽視掉剩餘的值; 而合併兩個數組會很天然地保留這些額外的列表值。而且 flatten(..) 採用遞歸處理嵌套列表,但你可能只指望較淺地合併列表,保留嵌套的子列表。

這樣,讓咱們定義一個更符合咱們指望的 mergeLists(..)

function mergeLists(arr1,arr2) {
	var merged = [];
	arr1 = arr1.slice();
	arr2 = arr2.slice();

	while (arr1.length > 0 || arr2.length > 0) {
		if (arr1.length > 0) {
			merged.push( arr1.shift() );
		}
		if (arr2.length > 0) {
			merged.push( arr2.shift() );
		}
	}

	return merged;
}
複製代碼

注意: 許多函數式編程類庫並不會定義 mergeLists(..),反而會定義 merge(..) 方法來合併兩個對象的屬性。這種 merge(..) 返回的結果和咱們的 mergeLists(..) 不一樣。

另外,這裏有一些選擇採用縮減器實現合併列表的方法:

// 來自 @rwaldron
var mergeReducer =
	(merged,v,idx) =>
		(merged.splice( idx * 2, 0, v ), merged);


// 來自 @WebReflection
var mergeReducer =
	(merged,v,idx) =>
		merged
			.slice( 0, idx * 2 )
			.concat( v, merged.slice( idx * 2 ) );
複製代碼

採用 mergeReducer(..)

[1,3,5,7,9]
.reduce( mergeReducer, [2,4,6,8,10] );
// [1,2,3,4,5,6,7,8,9,10]
複製代碼

提示:咱們將在本章後面使用 mergeReducer(..) 這個技巧。

方法 vs 獨立

對於函數式編程者來講,廣泛感到失望的緣由是 Javascript 採用統一的策略處理實用函數,但其中的一些也被做爲獨立函數提供了出來。想一想在前面的章節中的介紹的大量的函數式編程實用程序,以及另外一些實用函數是數組的原型方法,就像在這章中看到的那些。

當你想合併多個操做的時候,這個問題的痛苦程度更加明顯:

[1,2,3,4,5]
.filter( isOdd )
.map( double )
.reduce( sum, 0 );					// 18

// 採用獨立的方法.

reduce(
	map(
		filter( [1,2,3,4,5], isOdd ),
		double
	),
	sum,
	0
);									// 18
複製代碼

兩種方式的 API 實現了一樣的功能。但它們的風格徹底不一樣。不少函數式編程者更傾向採用後面的方式,可是前者在 Javascript 中毫無疑問的更常見。後者特別地讓人不待見之處是採用嵌套調用。人們更偏心鏈式調用 —— 一般稱爲流暢的API風格,這種風格被 jQuery 和一些工具採用 —— 這種風格緊湊/簡潔,而且能夠採用聲明式的自上而下的順序閱讀。

這種獨立風格的手動合併的視覺順序既不是嚴格的從左到右(自上而下),也不是嚴格的從右到左,而是從裏往外。

從右往左(自下而上)這兩種風格自動組成規範的閱讀順序。所以爲了探索這些風格隱藏的差別,讓咱們特別的檢查組合。他看上去應當簡潔,但這兩種狀況都有點尷尬。

鏈式組合方法

這些數組方法接收絕對的 this 形參,所以儘管從外表上看,它們不能被看成一元運算看待,這會使組合更加尷尬。爲了應對這些,我首先須要一個 partial(..) 版本的 this

var partialThis =
	(fn,...presetArgs) =>
		// 故意採用 function 來爲了 this 綁定
		function partiallyApplied(...laterArgs){
			return fn.apply( this, [...presetArgs, ...laterArgs] );
		};
複製代碼

咱們也須要一個特殊的 compose(..),它在上下文鏈中調用每個部分應用的方法。它的輸入值(即絕對的 this)由前一步傳入:

var composeChainedMethods =
	(...fns) =>
		result =>
			fns.reduceRight(
				(result,fn) =>
					fn.call( result )
				, result
			);
複製代碼

一塊兒使用這兩個 this 實用函數:

composeChainedMethods(
   partialThis( Array.prototype.reduce, sum, 0 ),
   partialThis( Array.prototype.map, double ),
   partialThis( Array.prototype.filter, isOdd )
)
( [1,2,3,4,5] );					// 18
複製代碼

注意: 那三個 Array.prototype.XXX 採用了內置的 Array.prototype.* 方法,這樣咱們能夠在數組中重複使用它們。

獨立組合實用函數

獨立的 compose(..),組合這些功能函數的風格不須要全部的這些普遍使人喜歡的 this 參數。例如,咱們能夠獨立的定義成這樣:

var filter = (arr,predicateFn) => arr.filter( predicateFn );

var map = (arr,mapperFn) => arr.map( mapperFn );

var reduce = (arr,reducerFn,initialValue) =>
	arr.reduce( reducerFn, initialValue );
複製代碼

可是,這種特別的獨立風格給自身帶來了不便。層級的數組上下文是第一個形參,而不是最後一個。所以咱們須要採用右偏應用(right-partial application)來組合它們。

compose(
	partialRight( reduce, sum, 0 ),
	partialRight( map, double ),
	partialRight( filter, isOdd )
)
( [1,2,3,4,5] );					// 18
複製代碼

這就是爲什麼函數式編程類庫一般定義 filter(..)map(..)reduce(..) 交替採用最後一個形參接收數組,而不是第一個。它們一般自動地柯理化實用函數:

var filter = curry(
	(predicateFn,arr) =>
		arr.filter( predicateFn )
);

var map = curry(
	(mapperFn,arr) =>
		arr.map( mapperFn )
);

var reduce = curry(
	(reducerFn,initialValue,arr) =>
		arr.reduce( reducerFn, initialValue );
複製代碼

採用這種方式定義實用函數,組合流程會顯得更加友好:

compose(
	reduce( sum )( 0 ),
	map( double ),
	filter( isOdd )
)
( [1,2,3,4,5] );					// 18
複製代碼

這種很整潔的實現方式,就是函數式編程者喜歡獨立的實用程序風格,而不是實例方法的緣由。但這種狀況因人而異。

方法適配獨立

在前面的 filter(..) / map(..) / reduce(..) 的定義中,你可能發現了這三個方法的共同點:它們都派發到相對應的原生數組方法。所以,咱們能採用實用函數生成這些獨立適配函數嗎?固然能夠,讓咱們定義 unboundMethod(..) 來作這些:

var unboundMethod =
	(methodName,argCount = 2) =>
		curry(
			(...args) => {
				var obj = args.pop();
				return obj[methodName]( ...args );
			},
			argCount
		);
複製代碼

使用這個實用函數:

var filter = unboundMethod( "filter", 2 );
var map = unboundMethod( "map", 2 );
var reduce = unboundMethod( "reduce", 3 );

compose(
	reduce( sum )( 0 ),
	map( double ),
	filter( isOdd )
)
( [1,2,3,4,5] );					// 18
複製代碼

注意: unboundMethod(..) 在 Ramda 中稱之爲 invoker(..)

獨立函數適配爲方法

若是你喜歡僅僅使用數組方法(流暢的鏈式風格),你有兩個選擇:

  1. 採用額外的方法擴展內建的 Array.prototype
  2. 把獨立實用函數適配成一個縮減函數,而且將其傳遞給 reduce(..) 實例方法。

不要採用第一種 擴展諸如 Array.prototype 的原生方法歷來不是一個好主意,除非定義一個 Array 的子類。可是這超出了這裏的討論範圍。爲了避免鼓勵這種很差的習慣,咱們不會進一步去探討這種方式。

讓咱們關注第二種。爲了說明這點,咱們將前面定義的遞歸實現的 flatten(..) 轉換爲獨立實用函數:

var flatten =
	arr =>
		arr.reduce(
			(list,v) =>
				list.concat( Array.isArray( v ) ? flatten( v ) : v )
		, [] );
複製代碼

讓咱們將裏面的 reducer(..) 函數抽取成獨立的實用函數(而且調整它,讓其獨立於外部的 flatten(..) 運行):

// 刻意使用具名函數用於遞歸中的調用
function flattenReducer(list,v) {
	return list.concat(
		Array.isArray( v ) ? v.reduce( flattenReducer, [] ) : v
	);
}
複製代碼

如今,咱們能夠在數組方法鏈中經過 reduce(..) 調用這個實用函數:

[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
.reduce( flattenReducer, [] )
// ..
複製代碼

查尋列表

到此爲止,大部分示例有點無聊,它們基於一列數字或者字符串,讓咱們討論一些有亮點的列表操做:聲明式地建模一些命令式語句。

看看這個基本例子:

var getSessionId = partial( prop, "sessId" );
var getUserId = partial( prop, "uId" );

var session, sessionId, user, userId, orders;

session = getCurrentSession();
if (session != null) sessionId = getSessionId( session );
if (sessionId != null) user = lookupUser( sessionId );
if (user != null) userId = getUserId( user );
if (userId != null) orders = lookupOrders( userId );
if (orders != null) processOrders( orders );
複製代碼

首先,咱們能夠注意到聲明和運行前的一系列 If 語句確保了由 getCurrentSession()getSessionId(..)lookupUser(..)getUserId(..)lookupOrders(..)processOrders(..) 這六個函數組合調用時的有效。理想地,咱們指望擺脫這些變量定義和命令式的條件。

不幸的是,在第 4 章中討論的 compose(..)pipe(..) 實用函數並無提供給一個便捷的方式來表達在這個組合中的 != null 條件。讓咱們定義一個實用函數來解決這個問題:

var guard =
	fn =>
		arg =>
			arg != null ? fn( arg ) : arg;
複製代碼

這個 guard(..) 實用函數讓咱們映射這五個條件確保函數:

[ getSessionId, lookupUser, getUserId, lookupOrders, processOrders ]
.map( guard )
複製代碼

這個映射的結果是組合的函數數組(事實上,這是個有列表順序的管道)。咱們能夠展開這個數組到 pipe(..),但因爲咱們已經作列表操做,讓咱們採用 reduce(..) 來處理。採用 getCurrentSession() 返回的會話值做爲初始值:

.reduce(
	(result,nextFn) => nextFn( result )
	, getCurrentSession()
)
複製代碼

接下來,咱們觀察到 getSessionId(..)getUserId(..) 能夠當作對應的 "sessId""uId" 的映射:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
複製代碼

可是爲了使用這些,咱們須要將另外三個函數(lookupUser(..)lookupOrders(..)processOrders(..))插入進來,用來獲取上面討論的那五個守護/組合函數。

爲了實現插入,咱們採用列表合併來模擬這些。回顧本章前面介紹的 mergeReducer(..)

var mergeReducer =
	(merged,v,idx) =>
		(merged.splice( idx * 2, 0, v ), merged);
複製代碼

咱們能夠採用 reduce(..)(咱們的瑞士軍刀,還記得嗎?)在生成的 getSessionId(..)getUserId(..) 函數之間的數組中「插入」 lookupUser(..),經過合併這兩個列表:

.reduce( mergeReducer, [ lookupUser ] )
複製代碼

而後咱們將 lookupOrders(..)processOrders(..) 加入到正在執行的函數數組末尾:

.concat( lookupOrders, processOrders )
複製代碼

總結下,生成的五個函數組成的列表表達爲:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )
複製代碼

最後,將全部函數合併到一塊兒,將這些函數數組添加到以前的守護和組合上:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )
.map( guard )
.reduce(
	(result,nextFn) => nextFn( result )
	, getCurrentSession()
);
複製代碼

全部必要的變量聲明和條件一去不復返了,取而代之的是採用整潔和聲明式的列表操做連接在一塊兒。

若是你以爲如今的這個版本比以前要難,不要擔憂。毫無疑問的,前面的命令式的形式,你可能更加熟悉。進化爲函數式編程者的一步就是開發一些具備函數式編程風格的代碼,好比這些列表操做。隨着時間推移,咱們跳出這些代碼,當你切換到聲明式風格時更容易感覺到代碼的可讀性。

在離開這個話題以前,讓咱們作一個真實的檢查:這裏的示例過於造做。不是全部的代碼片斷被簡單的採用列表操做模擬。務實的獲取方式是本能的尋找這些機會,而不是過於追求代碼的技巧;一些改進比沒有強。常常退一步,而且問本身,是提高了仍是損害了代碼的可讀性。

融合

當你更多的考慮在代碼中使用函數式列表操做,你可能會很快地開始看到鏈式組合行爲,如:

..
.filter(..)
.map(..)
.reduce(..);
複製代碼

每每,你可能會把多個相鄰的操做用鏈式來調用,好比:

someList
.filter(..)
.filter(..)
.map(..)
.map(..)
.map(..)
.reduce(..);
複製代碼

好消息是,鏈式風格是聲明式的,而且很容易看出詳盡的執行步驟和順序。它的不足之處在於每個列表操做都須要循環整個列表,意味着沒必要要的性能損失,特別是在列表很是長的時候。

採用交替獨立的風格,你可能看到的代碼以下:

map(
	fn3,
	map(
		fn2,
		map( fn1, someList )
	)
);
複製代碼

採用這種風格,這些操做自下而上列出,這依然會循環數組三遍。

融合處理了合併相鄰的操做,這樣能夠減小列表的迭代次數。這裏咱們關注於合併相鄰的 map(..),這很容易解釋。

想象一下這樣的場景:

var removeInvalidChars = str => str.replace( /[^\w]*/g, "" );

var upper = str => str.toUpperCase();

var elide = str =>
	str.length > 10 ?
		str.substr( 0, 7 ) + "..." :
		str;

var words = "Mr. Jones isn't responsible for this disaster!"
	.split( /\s/ );

words;
// ["Mr.","Jones","isn't","responsible","for","this","disaster!"]

words
.map( removeInvalidChars )
.map( upper )
.map( elide );
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
複製代碼

注意在這個轉換流程中的每個值。在 words 列表中的第一個值,開始爲 "Mr.",變爲 "Mr",而後爲 "MR",而後經過 elide(..) 不變。另外一個數據流爲:"responsible" -> "responsible" -> "RESPONSIBLE" -> "RESPONS..."

換句話說,你能夠將這些數據轉換當作這樣:

elide( upper( removeInvalidChars( "Mr." ) ) );
// "MR"

elide( upper( removeInvalidChars( "responsible" ) ) );
// "RESPONS..."
複製代碼

你抓住重點了嗎?咱們能夠將那三個獨立的相鄰的 map(..) 調用步驟當作一個轉換組合。由於它們都是一元函數,而且每個返回值都是下一個點輸入值。咱們能夠採用 compose(..) 執行映射功能,並將這個組合函數傳入到單個 map(..) 中調用:

words
.map(
	compose( elide, upper, removeInvalidChars )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
複製代碼

這是另外一個 pipe(..) 能更便利的方式處理組合的場景,這樣可讀性頗有條理:

words
.map(
	pipe( removeInvalidChars, upper, elide )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
複製代碼

如何融合兩個以上的 filter(..) 謂詞函數呢?一般視爲一元函數,它們彷佛適合組合。可是有個小問題,每個函數返回了不一樣類型的值(boolean),這些返回值並非下一個函數須要的輸入參數。融合相鄰的 reduce(..) 調用也是可能的,但縮減器並非一元的,這也會帶來不小的挑戰。咱們須要更復雜的技巧來實現這些融合。咱們將在附錄 A 的「轉換」中討論這些高級方法。

列表以外

到目前爲止,咱們討論的操做都是在列表(數組)數據結構中,這是迄今爲止你遇到的最多見的場景。可是更廣泛的意義是,這些操做能夠在任一集合執行。

就像咱們以前說過,數組的 map(..) 方法對數組中的每個值作單值操做,任何數據結構均可以採用 map(..) 操做作相似的事情。一樣的,也能夠實現 filter(..)reduce(..) 和其餘能工做於這些數據結構的值的操做。

函數式編程精神中重要的部分是這些操做必須依賴值的不變性,意味着它們必須返回一個新的值,而不是改變存在的值。

讓咱們描述那個廣爲人知的數據結構:二叉樹。二叉樹指的是一個節點(只有一個對象!)有兩個字節點(這些字節點也是二叉樹),這兩個字節點一般稱之爲子樹。樹中的每一個節點包含整體數據結構的值。

在這個插圖中,咱們將咱們的二叉樹描述爲二叉搜索樹(BST)。然而,樹的操做和其餘非二叉搜索樹沒有區別。

注意: 二叉搜索樹是特定的二叉樹,該樹中的節點值彼此之間存在特定的約束關係。每一個樹中的左子節點的值小於根節點的值,跟子節點的值也小於右子節點的值。這裏「小於」的概念是相對於樹中存儲數據的類型。它能夠是數字的數值,也能夠是字符串在詞典中的順序,等等。二叉搜索樹的價值在於在處理在樹中搜索一個值很是高效便捷,採用一個遞歸的二叉搜索算法。

讓咱們採用這個工廠函數建立二叉樹對象:

var BinaryTree =
	(value,parent,left,right) => ({ value, parent, left, right });
複製代碼

爲了方便,咱們在每一個Node中不只僅保存了 leftright 子樹節點,也保存了其自身的 parent 節點引用。

如今,咱們將一些常見的產品名(水果,蔬菜)定義爲二叉搜索樹:

var banana = BinaryTree( "banana" );
var apple = banana.left = BinaryTree( "apple", banana );
var cherry = banana.right = BinaryTree( "cherry", banana );
var apricot = apple.right = BinaryTree( "apricot", apple );
var avocado = apricot.right = BinaryTree( "avocado", apricot );
var cantelope = cherry.left = BinaryTree( "cantelope", cherry );
var cucumber = cherry.right = BinaryTree( "cucumber", cherry );
var grape = cucumber.right = BinaryTree( "grape", cucumber );
複製代碼

在這個樹形結構中,banana 是根節點,這棵樹可能採用不一樣的方式建立節點,但其依舊能夠採用二叉搜索樹同樣的方式訪問。

這棵樹以下圖所示:

這裏有多種方式來遍歷一顆二叉樹來處理它的值。若是這棵樹是二叉搜索樹,咱們還能夠有序的遍歷它。經過先訪問左側子節點,而後自身節點,最後右側子節點,這樣咱們能夠獲得升序排列的值。

如今,你不能僅僅經過像在數組中用 console.log(..) 打印出二叉樹。咱們先定義一個便利的方法,主要用來打印。定義的 forEach(..) 方法能像和數組同樣的方式來訪問二叉樹:

// 順序遍歷
BinaryTree.forEach = function forEach(visitFn,node){
	if (node) {
		if (node.left) {
			forEach( visitFn, node.left );
		}

		visitFn( node );

		if (node.right) {
			forEach( visitFn, node.right );
		}
	}
};
複製代碼

注意: 採用遞歸處理二叉樹更天然。咱們的 forEach(..) 實用函數採用遞歸調用自身來處理左右字節點。咱們將在後續的章節章深刻討論遞歸。

回顧在本章開頭描述的 forEach(..),它存在有用的反作用,一般函數式編程指望有這個反作用。在這種狀況下,咱們僅僅在 I/O 的反作用下使用 forEach(..),所以它是完美的理想的輔助函數。

採用 forEach(..) 打印那個二叉樹中的值:

BinaryTree.forEach( node => console.log( node.value ), banana );
// apple apricot avocado banana cantelope cherry cucumber grape

// 僅訪問根節點爲 `cherry` 的子樹
BinaryTree.forEach( node => console.log( node.value ), cherry );
// cantelope cherry cucumber grape
複製代碼

爲了採用函數式編程的方式操做咱們定義的那個二叉樹,咱們定義一個 map(..) 函數:

BinaryTree.map = function map(mapperFn,node){
	if (node) {
		let newNode = mapperFn( node );
		newNode.parent = node.parent;
		newNode.left = node.left ?
			map( mapperFn, node.left ) : undefined;
		newNode.right = node.right ?
			map( mapperFn, node.right ): undefined;

		if (newNode.left) {
			newNode.left.parent = newNode;
		}
		if (newNode.right) {
			newNode.right.parent = newNode;
		}

		return newNode;
	}
};
複製代碼

你可能會認爲採用 map(..) 僅僅處理節點的 value 屬性,但一般狀況下,咱們可能須要映射樹的節點自己。所以,mapperFn(..) 傳入整個訪問的節點,在應用了轉換以後,它期待返回一個全新的 BinaryTree(..) 節點回來。若是你返回了一樣的節點,這個操做會改變你的樹,而且極可能會引發意想不到的結果!

讓咱們映射咱們的那個樹,獲得一列大寫產品名:

var BANANA = BinaryTree.map(
	node => BinaryTree( node.value.toUpperCase() ),
	banana
);

BinaryTree.forEach( node => console.log( node.value ), BANANA );
// APPLE APRICOT AVOCADO BANANA CANTELOPE CHERRY CUCUMBER GRAPE
複製代碼

BANANAbanana 是一個不一樣的樹(全部的節點都不一樣),就像在列表中執行 map(..) 返回一個新的數組。就像其餘對象/數組的數組,若是 node.value 自己是某個對象/數組的引用,若是你想作深層次的轉換,那麼你就須要在映射函數中手動的對它作深拷貝。

如何處理 reduce(..)?相同的基本處理過程:有序遍歷樹的節點的方式。一種可能的用法是 reduce(..) 咱們的樹獲得它的值的數組。這對未來適配其餘典型的列表操做頗有幫助。或者,咱們能夠 reduce(..) 咱們的樹,獲得一個合併了它全部產品名的字符串。

咱們模仿數組中 reduce(..) 的行爲,它接受那個可選的 initialValue 參數。該算法有一點難度,但依舊可控:

BinaryTree.reduce = function reduce(reducerFn,initialValue,node){
	if (arguments.length < 3) {
		// 移動參數,直到 `initialValue` 被刪除
		node = initialValue;
	}

	if (node) {
		let result;

		if (arguments.length < 3) {
			if (node.left) {
				result = reduce( reducerFn, node.left );
			}
			else {
				return node.right ?
					reduce( reducerFn, node, node.right ) :
					node;
			}
		}
		else {
			result = node.left ?
				reduce( reducerFn, initialValue, node.left ) :
				initialValue;
		}

		result = reducerFn( result, node );
		result = node.right ?
			reduce( reducerFn, result, node.right ) : result;
		return result;
	}

	return initialValue;
};
複製代碼

讓咱們採用 reduce(..) 產生一個購物單(一個數組):

BinaryTree.reduce(
	(result,node) => result.concat( node.value ),
	[],
	banana
);
// ["apple","apricot","avocado","banana","cantelope"
// "cherry","cucumber","grape"]
複製代碼

最後,讓咱們考慮在樹中用 filter(..)。這個算法迄今爲止最棘手,由於它有效(實際上沒有)影響從樹上刪除節點,這須要處理幾個問題。不要被這種實現嚇到。若是你喜歡,如今跳過它,關注咱們如何使用它而不是實現。

BinaryTree.filter = function filter(predicateFn,node){
	if (node) {
		let newNode;
		let newLeft = node.left ?
			filter( predicateFn, node.left ) : undefined;
		let newRight = node.right ?
			filter( predicateFn, node.right ) : undefined;

		if (predicateFn( node )) {
			newNode = BinaryTree(
				node.value,
				node.parent,
				newLeft,
				newRight
			);
			if (newLeft) {
				newLeft.parent = newNode;
			}
			if (newRight) {
				newRight.parent = newNode;
			}
		}
		else {
			if (newLeft) {
				if (newRight) {
					newNode = BinaryTree(
						undefined,
						node.parent,
						newLeft,
						newRight
					);
					newLeft.parent = newRight.parent = newNode;

					if (newRight.left) {
						let minRightNode = newRight;
						while (minRightNode.left) {
							minRightNode = minRightNode.left;
						}

						newNode.value = minRightNode.value;

						if (minRightNode.right) {
							minRightNode.parent.left =
								minRightNode.right;
							minRightNode.right.parent =
								minRightNode.parent;
						}
						else {
							minRightNode.parent.left = undefined;
						}

						minRightNode.right =
							minRightNode.parent = undefined;
					}
					else {
						newNode.value = newRight.value;
						newNode.right = newRight.right;
						if (newRight.right) {
							newRight.right.parent = newNode;
						}
					}
				}
				else {
					return newLeft;
				}
			}
			else {
				return newRight;
			}
		}

		return newNode;
	}
};
複製代碼

這段代碼的大部分是爲了專門處理當存在重複的樹形結構中的節點被「刪除」(過濾掉)的時候,移動節點的父/子引用。

做爲一個描述使用 filter(..) 的例子,讓咱們產生僅僅包含蔬菜的樹:

var vegetables = [ "asparagus", "avocado", "brocolli", "carrot",
	"celery", "corn", "cucumber", "lettuce", "potato", "squash",
	"zucchini" ];

var whatToBuy = BinaryTree.filter(
	// 將蔬菜從農產品清單中過濾出來
	node => vegetables.indexOf( node.value ) != -1,
	banana
);

// 購物清單
BinaryTree.reduce(
	(result,node) => result.concat( node.value ),
	[],
	whatToBuy
);
// ["avocado","cucumber"]
複製代碼

你會在簡單列表中使用本章大多數的列表操做。但如今你發現這個概念適用於你可能須要的任何數據結構和操做。函數式編程能夠普遍應用在許多不一樣的場景,這是很是強大的!

總結

三個強大通用的列表操做:

  • map(..): 轉換列表項的值到新列表。
  • filter(..): 選擇或過濾掉列表項的值到新數組。
  • reduce(..): 合併列表中的值,而且產生一個其餘的值(常常但不老是非列表的值)。

其餘一些很是有用的處理列表的高級操做:unique(..)flatten(..)merge(..)

融合採用函數組合技術來合併多個相鄰的 map(..)調用。這是常見的性能優化方式,而且它也使得列表操做更加天然。

列表一般以數組展示,但它也能夠做爲任何數據結構表達/產生一個有序的值集合。所以,全部這些「列表操做」都是「數據結構操做」。

** 【上一章】翻譯連載 | JavaScript輕量級函數式編程-第7章: 閉包vs對象 |《你不知道的JS》姊妹篇 **

** 【下一章】翻譯連載 | 第 9 章:遞歸(上)-《JavaScript輕量級函數式編程》 |《你不知道的JS》姊妹篇 **

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

簽名贈書 | 滬江Web前端技術團隊撰寫的《移動Web前端高效開發實戰》免費大放送

iKcamp官網:www.ikcamp.com


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

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