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

iKcamp發表於2017-09-15

關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。

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

第 8 章:列表操作

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

如果你能做一些令人驚歎的事情,請持續保持下去。

本文之前已經簡要的提及了一些實用函式:map(..)filter(..)reduce(..),現在深入瞭解一下它們。在 Javascript 中,這些實用函式通常被用於 Array(即 “list” )的原型上。因此可以很自然的將這些實用函式和陣列或列表操作聯絡起來。

在討論具體的陣列方法之前,我們應該很清楚這些操作的作用。在這章中,弄明白為何有這些列表操作和這些操作如何工作同等重要。請保持頭腦清晰,跟上節奏。

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

但是不要停留在這些簡單示例的表面,而錯過了更深層次的點。通過對一系列任務建模來理解一些非常重要的函數語言程式設計在列表操作中的價值 —— 一些些看起來不像列表的語句 —— 作為列表操作,而不是單獨執行。

這不僅僅是編寫許多簡練程式碼的技巧。我們所要做的是,從命令式轉變為宣告式風格,使程式碼模式更容易辨認,從而可讀性更好。

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

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

注意: 相比前面幾章,為了程式碼片段更加簡練,我們將採用 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(..) 操作將列表中所有的值轉換為新列表中的列表項,如下圖所示:

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

實現 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(..) 操作:

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

實現 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,然後將這個初始值和列表的第一個元素合併。然後逐一和列表中剩餘的元素合併。如下圖所示:

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

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

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

警告: 在 JavaScript 中,如果在縮減操作的列表中一個值都沒有(在陣列中,或沒有指定 initialValue ),將會丟擲異常。一個縮減操作的列表有可能為空的時候,需要小心採用不指定 initialValue 的方式。

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

例如,對 5、10、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(..) 和其他能工作於這些資料結構的值的操作。

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

讓我們描述那個廣為人知的資料結構:二叉樹。二叉樹指的是一個節點(只有一個物件!)有兩個位元組點(這些位元組點也是二叉樹),這兩個位元組點通常稱之為子樹。樹中的每個節點包含總體資料結構的值。

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

在這個插圖中,我們將我們的二叉樹描述為二叉搜尋樹(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 是根節點,這棵樹可能採用不同的方式建立節點,但其依舊可以採用二叉搜尋樹一樣的方式訪問。

這棵樹如下圖所示:

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

這裡有多種方式來遍歷一顆二叉樹來處理它的值。如果這棵樹是二叉搜尋樹,我們還可以有序的遍歷它。通過先訪問左側子節點,然後自身節點,最後右側子節點,這樣我們可以得到升序排列的值。

現在,你不能僅僅通過像在陣列中用 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》姊妹篇 **

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

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

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

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

iKcamp官網:www.ikcamp.com


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

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

相關文章