章节 9: 列表的操作

如果你能做一些很棒的事情,那就不断地去做。

我们已经在前面的文本中看到了对一些实用程序的简短引用,现在我们想仔细看看这些实用程序,即map(..), filter(..)reduce(..)。在JavaScript中,这些实用程序通常用作数组(又名“列表”)原型上的方法,因此我们自然会将它们称为数组或列表操作。

在讨论特定的数组方法之前,我们希望从概念上研究这些操作的用途。在本章中,理解为什么列表操作是重要的,就像理解如何列表操作一样重要。确保你在阅读这一章的时候记住了这些细节。

这些操作的绝大多数常见插图,无论是在本书之外还是在本章中,都描述了在值列表上执行的琐碎任务(比如将数组中的每个数字加倍);这是一种既便宜又简单的方式。

但是不要忽略了这些简单的例子而忽略了更深层的问题。理解列表操作中一些最重要的FP值来自于能够将一系列任务建模为列表操作,而不是单独执行它们。

这不仅仅是编写更简洁代码的技巧。我们所追求的是将命令式样式转换为声明式样式,使代码模式更易于识别,从而更具可读性。

但还有更重要的东西需要掌握。使用命令式代码,一组计算中的每个中间结果都通过赋值存储在变量中。您的代码所依赖的命令式模式越多,就越难以验证是否存在错误——在逻辑、值的意外突变或隐藏的原因/结果方面。

通过链接和/或组合列表操作,中间结果被隐式地跟踪,并且在很大程度上避免了这些危险。

注:比前几章要多,为了使下面的代码片段尽可能简短,我们将主要使用ES6 =>形式。但是,我在第2章中关于=>的建议仍然适用于一般的编码。

没有FP的列表处理

作为本章讨论的一个快速序言,我想指出一些操作,它们可能与JavaScript数组和FP列表操作相关,但实际上并不是。这里将不讨论这些操作,因为它们与FP的一般最佳实践不一致:

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

forEach(..)是一个迭代助手,但它是为每个函数调用操作的副作用;你可能会猜到为什么这不是我们讨论的FP列表操作!

some(..)every(..)确实鼓励使用纯函数(具体地说,谓词函数如filter(..)那样),但它们不可避免地将列表简化为true/false结果,本质上类似于搜索或匹配。这两个实用程序实际上并不符合我们想要用FP建模代码的模式,所以我们将在这里跳过覆盖它们。

Map 函数

我们将从最基本的操作之一map(..)开始探索FP列表操作。

映射是从一个值到另一个值的转换。例如,如果你从数字2开始,然后乘以3,你就把它映射成了6。需要注意的是,我们讨论的映射转换并不是指“就地”突变或重新分配;相反,我们正在研究如何将转换项目中的新值从一个位置映射到另一个位置。

也就是说,可以:

  1. var x = 2, y;
  2. // 转换/映射
  3. y = x * 3;
  4. // 突变/重新分配
  5. x = x * 3;

如果我们为这个乘以3定义一个函数,这个函数充当一个映射(transformer)函数:

  1. var multipleBy3 = v => v * 3;
  2. var x = 2, y;
  3. // 转换/映射
  4. y = multiplyBy3( x );

我们可以自然地将映射从单个值转换扩展到一个值集合。map(..)是一个操作,转换一个列表的所有值,因为它把它们投影到一个新的列表:

章节 9: 列表的操作 - 图1

实现 map(..):

  1. function map(mapperFn,arr) {
  2. var newList = [];
  3. for (let [idx,v] of arr.entries()) {
  4. newList.push(
  5. mapperFn( v, idx, arr )
  6. );
  7. }
  8. return newList;
  9. }

注:参数顺序mapperFn, arr一开始可能感觉有点倒序,但这种惯例在FP库中更常见,因为它使这些实用程序更容易组合(使用curry)。

mapperFn(..)自然会被传递给列表项来映射/转换,但也会传递一个idxarr。我们这样做是为了保持与内置数组map(..)的一致性。这些额外的信息在某些情况下非常有用。

但在其他情况下,您可能希望使用mapperFn(..),只将列表项传递给它,因为额外的参数可能会改变它的行为。在章节3,”多对一”中,我们介绍了unary(..),它限制一个函数只能接受一个参数(不管传递了多少参数)。

回想一下第3章的例子,关于将parseInt(..)限制为一个参数来安全地用作mapperFn(..),我们也可以在独立的map(..)中使用这个参数:

  1. map( ["1","2","3"], unary( parseInt ) );
  2. // [1,2,3]

注: JavaScript数组原型操作(map(..), filter(..), 和 reduce(..))都接受一个可选的最后一个参数,用于函数的this绑定。正如我们在第2章,this指向什么中所讨论的,为了与FP的最佳实践保持一致,基于this的编码通常应该尽量避免。因此,本章中的示例实现不支持这种this绑定特性。

除了可以对这些值类型的列表执行明显的数字或字符串操作外,这里还有一些映射操作的示例。我们可以使用map(..)将函数列表转换为函数返回值列表:

  1. var one = () => 1;
  2. var two = () => 2;
  3. var three = () => 3;
  4. [one,two,three].map( fn => fn() );
  5. // [1,2,3]

或者我们可以先转换一个函数列表,将它们与另一个函数组合,然后执行它们:

  1. var increment = v => ++v;
  2. var decrement = v => --v;
  3. var square = v => v * v;
  4. var double = v => v * 2;
  5. [increment,decrement,square]
  6. .map( fn => compose( fn, double ) )
  7. .map( fn => fn( 3 ) );
  8. // [7,5,36]

关于map(..)需要注意的一些有趣的事情:我们通常会假设列表是从左到右处理的,但是关于map(..)的概念却没有真正需要这样做。每个变换都是独立于其它变换的。

一般意义上的映射甚至可以在支持这种方式的环境中并行化,这对于大型列表来说可以极大地提高性能。我们没有看到JavaScript真正这样做,因为它不需要您传递一个纯粹的函数mapperFn(..),即使您确实应该。如果您要传递一个非纯函数,而JS要以不同的顺序运行不同的调用,这将很快造成混乱。

尽管从理论上讲,各个映射操作是独立的,但JS必须假设它们不是独立的。

同步 VS 异步

我们在本章讨论的列表操作都是对一个已经存在的值列表进行同步操作;map(..)在这里是一个迫切的行动。但是另一种考虑映射函数的方法是将其作为一个事件处理程序,它将针对列表中遇到的每个新值调用。

想象一下这样的虚构场景:

  1. var newArr = arr.map();
  2. arr.addEventListener( "value", multiplyBy3 );

现在,每当一个值被添加到arr中,multiplyBy3(..)事件处理程序——mapper函数——就会被该值调用,它的转换也会被添加到newArr中。

我们所暗示的是,数组和我们对其执行的数组操作是即时同步版本,而这些相同的操作也可以在“延迟列表”(即流)上建模,该列表会随着时间的推移接收其值。我们将在第10章中深入探讨这个主题。

map vs. each

有些倡导者使用map(..)作为forEach(..)迭代的一般形式,在本质上,所接收的值是通过非接触传递的,但是可以执行一些中间操作:

  1. [1,2,3,4,5]
  2. .map( function mapperFn(v){
  3. console.log( v ); // 操作!
  4. return v;
  5. } )
  6. ..

这种技术看起来很有用的原因是map(..)返回数组,因此您可以在它之后继续链接更多的操作;forEach(..)的返回值是undefined。但是,我认为您应该避免以这种方式使用map(..),因为以一种决然非FP的方式使用核心FP操作纯粹是混淆。

你应该听过这句老话:正确的工作需要正确的工具,对吧?锤子当钉子,螺丝刀当螺丝钉……这有点不同:它以正确的方式使用了正确的工具。

锤子是用来拿在手里的;如果你把它放在嘴里,试着用锤子敲钉子,你的效果不会很好。map(..)的目的是映射值,而不是创建其他操作。

函数变量/函子(functor)

在这本书中,我们尽量避免使用FP中杜撰的术语。我们有时会使用官方术语,但大多数情况下,我们可以从日常对话中获得一些意义。

我将非常简短地打破这种模式,使用一个可能有点吓人的单词:函数变量(functor)。我想在这里讨论函子的原因是因为我们现在已经知道它们的作用,因为这个术语在FP的其余文献中被大量使用;的确,函子是FP中的基本概念,它直接来自于数学原理(范畴理论)。你至少要熟悉这个术语,不要害怕,这将是有益的。

函数变量是一个值,它具有对该值使用操作符函数的实用程序,该操作符函数保留组合。

如果所讨论的值是复合的,这意味着它是由单个值组成的——例如,数组就是这种情况!——函子对每个单独的值使用运算符函数。此外,函子实用程序创建一个新的复合值,该值包含所有操作符函数调用的结果。

这是描述我们刚刚看到的map(..)的一种奇特的方式。map(..)函数接受其关联值(数组)和映射函数(操作符函数),并对数组中的每个单独值执行映射函数。最后,它返回一个新数组,其中包含所有新映射的值。

另一个例子:一个字符串函子是一个字符串加上一个实用程序,该实用程序跨字符串中的所有字符执行某个操作符函数,返回一个带有处理过的字母的新字符串。想想这个精心设计的例子:

  1. function uppercaseLetter(c) {
  2. var code = c.charCodeAt( 0 );
  3. // 小写?
  4. if (code >= 97 && code <= 122) {
  5. // 大写!
  6. code = code - 32;
  7. }
  8. return String.fromCharCode( code );
  9. }
  10. function stringMap(mapperFn,str) {
  11. return [...str].map( mapperFn ).join( "" );
  12. }
  13. stringMap( uppercaseLetter, "Hello World!" );
  14. // HELLO WORLD!

stringMap(..)允许字符串为函函数。您可以为任何数据结构定义映射函数;只要实用程序遵循这些规则,数据结构就是一个函子。

Filter 函数

想象一下,我带着一个空篮子去杂货店参观水果区;有一个很大的水果展(苹果、桔子和香蕉)。我真的很饿,所以我想要尽可能多的水果,但我真的只喜欢圆形的水果(苹果和桔子)。所以我一个一个地筛选每一个水果,然后拿着满满一篮子的苹果和桔子走了。

我们称这个过程为“过滤”。你会更自然地把我的购物描述为从一个空的篮子开始,然后过滤掉(选择,包括),只有苹果和桔子,还是从全部展示水果开始,然后过滤掉(跳过,不包括)香蕉,因为我的篮子里装满了水果?

如果你在一壶水里煮意大利面,然后把它倒入水槽上方的过滤器,你是过滤意大利面还是过滤水?如果你把咖啡渣放进过滤器里,然后煮一杯咖啡,你是把咖啡渣过滤进杯子里,还是过滤掉咖啡渣?

您的过滤是否取决于您想要的东西是“保留”在过滤器中还是通过过滤器?

如果你在航空公司/酒店网站上指定了“过滤结果”选项,你会怎么做?你是在过滤符合你标准的结果,还是过滤掉所有不符合的结果?请仔细考虑:此示例可能与前面的示例具有不同的语义。

过滤的混淆

根据你的观点,过滤要么是排外的,要么是包容的。这种概念上的合并是不幸的。

我认为对于过滤最常见的解释——无论如何,在编程之外——是你过滤掉不需要的东西。不幸的是,在编程中,我们本质上已经将这种语义转换为更像是过滤需要的东西。

filter(..)列表操作使用一个函数来决定原始数组中的每个值是否应该在新数组中。如果一个值应该返回true,那么这个函数需要返回true;如果跳过它,则需要返回false。为这种决策返回true/false的函数有一个特殊的名称:谓语函数。

如果您认为true表示一个积极的信号,那么filter(..)的定义就是您说“保留”(过滤)一个值,而不是说“丢弃”(过滤)一个值。

要使用filter(..)作为排除动作,你必须扭转你的大脑,通过返回false来积极地暗示排除,通过返回true来被动地让一个值通过。

这种语义上的不匹配之所以重要,是因为您可能会将函数命名为predicateFn(..),以及这对代码的可读性意味着什么。我们稍后会回到这一点。

下面是如何可视化一个filter(..)操作列表:

章节 9: 列表的操作 - 图2

实现 filter(..):

  1. function filter(predicateFn,arr) {
  2. var newList = [];
  3. for (let [idx,v] of arr.entries()) {
  4. if (predicateFn( v, idx, arr )) {
  5. newList.push( v );
  6. }
  7. }
  8. return newList;
  9. }

注意,就像之前的mapperFn(..)一样,predicateFn(..)不仅传递了值,还传递了idxarr。必要时使用unary(..)来限制它的参数。

map(..)一样, filter(..)是作为JS数组的内置实用程序提供的。

让我们考虑这样一个谓词函数:

  1. var whatToCallIt = v => v % 2 == 1;

这个函数使用v % 2 == 1来返回truefalse。这里的效果是,奇数将返回true,偶数将返回false。这个函数应该叫什么?一个自然的名字可能是:

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

考虑如何使用 isOdd(..)与一个简单的值检查在您的代码某处:

  1. var midIdx;
  2. if (isOdd( list.length )) {
  3. midIdx = (list.length + 1) / 2;
  4. }
  5. else {
  6. midIdx = list.length / 2;
  7. }

很有道理,对吧?但是,让我们考虑使用内置数组filter(..)来过滤一个列表的值:

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

如果你来描述[1,3,5]的结果,你会说“我过滤掉了偶数”,还是说“我过滤掉了奇数”?我认为前者是一种更自然的描述方式。但是代码读取的结果正好相反。从字面上看,这些代码表示我们“过滤了每个奇数”。

我个人觉得这种语义上的混淆。毫无疑问,有经验的开发人员有很多先例。但如果你只是重新开始,这种逻辑表达似乎有点像不带双重否定就不说话——也就是说,带着双重否定说话。

我们可以通过将isOdd(..)重命名为isEven(..)来简化这个过程:

  1. var isEven = v => v % 2 == 1;
  2. [1,2,3,4,5].filter( isEven );
  3. // [1,3,5]

耶!但是这个函数的名称没有任何意义,因为当它是偶数时,它返回false :

  1. isEven( 2 ); // false

回想一下,在章节 3, “无参数风格”中,我们定义了一个not(..)操作符,它否定谓词函数。考虑:

  1. var isEven = not( isOdd );
  2. isEven( 2 ); // true

但是我们不能将这个isEven(..)与当前定义的filter(..)一起使用,因为我们的逻辑将被颠倒;我们最终会打成平手,而不是赔率。我们需要做的是:

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

这完全违背了目的,所以我们不要这样做。我们只是在兜圈子。

过滤外 & 过滤里

为了消除所有这些混淆,让我们定义一个filterOut(..),它通过内部否定谓词检查来过滤值。同时,我们将别名filterIn(..)到现有的filter(..):

  1. var filterIn = filter;
  2. function filterOut(predicateFn,arr) {
  3. return filterIn( not( predicateFn ), arr );
  4. }

现在我们可以在代码的任何地方选用最合理的过滤器:

  1. isOdd( 3 ); // true
  2. isEven( 2 ); // true
  3. filterIn( isOdd, [1,2,3,4,5] ); // [1,3,5]
  4. filterOut( isEven, [1,2,3,4,5] ); // [1,3,5]

我认为使用 filterIn(..)filterOut(..) (在Ramda中称为 reject(..) )将使您的代码比仅仅使用 filter(..) 更具有可读性,并避免使读者混淆语义。

Reduce 函数

map(..)filter(..) 生成新列表时,通常第三个操作函数(reduce(..))将列表的值合并(也称为”reduce “)为单个有限(非列表)值,如数字或字符串。但是,在本章的后面,我们将看到如何推动reduce(..)以更高级的方式使用它。reduce(..)是最重要的FP工具之一;它就像一把瑞士军刀,功能齐全。

组合/约简被抽象地定义为取两个值并使它们成为一个值。有些FP上下文将其称为“折叠”,就好像您将两个值折叠成一个值一样。我认为这是一个很有帮助的形象化。

就像映射和过滤一样,组合的方式完全取决于您,通常取决于列表中值的类型。例如,数字通常通过算术组合,字符串通过连接,函数通过组合。

有时,缩减会指定一个 initialValue,并通过将它与列表中的第一个值组合在一起来开始工作,然后通过列表中的其余每个值向下层叠。它是这样的:

章节 9: 列表的操作 - 图3

或者,您可以省略initialValue,在这种情况下,列表的第一个值将代替initialValue,组合将从列表中的第二个值开始,如下所示:

章节 9: 列表的操作 - 图4

警告:在JavaScript中,如果reduction中没有至少一个值(要么在数组中,要么指定为initialValue),则抛出一个错误。如果在任何情况下减少的列表可能是空的,注意不要忽略initialValue

传递给 reduce(..) 以执行还原的函数通常称为减速器。与我们前面看到的映射器和谓词函数不同,减速器具有不同的签名。还原器主要接收当前还原结果以及下一个要还原它的值。每一步还原的当前结果通常称为累加器。(注:reducer 翻译为减速器)

例如,考虑使用’ 3 ‘的 initialValue 来减少数字’ 5 ‘、’ 10 ‘和’ 15 ‘所涉及的步骤:

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

在JavaScript中表达使用内置的 reduce(..) 方法对数组:

  1. [5,10,15].reduce( (product,v) => product * v, 3 );
  2. // 2250

但是 reduce(..) 的独立实现可能是这样的:

  1. function reduce(reducerFn,initialValue,arr) {
  2. var acc, startIdx;
  3. if (arguments.length == 3) {
  4. acc = initialValue;
  5. startIdx = 0;
  6. }
  7. else if (arr.length > 0) {
  8. acc = arr[0];
  9. startIdx = 1;
  10. }
  11. else {
  12. throw new Error( "Must provide at least one value." );
  13. }
  14. for (let idx = startIdx; idx < arr.length; idx++) {
  15. acc = reducerFn( acc, arr[idx], idx, arr );
  16. }
  17. return acc;
  18. }

Just as with map(..) and filter(..), the reducer function is also passed the lesser-common idx and arr arguments in case that’s useful to the reduction. I would say I don’t typically use these, but I guess it’s nice to have them available.

Recall in Chapter 4, we discussed the compose(..) utility and showed an implementation with reduce(..):

  1. function compose(...fns) {
  2. return function composed(result){
  3. return [...fns].reverse().reduce( function reducer(result,fn){
  4. return fn( result );
  5. }, result );
  6. };
  7. }

To illustrate reduce(..)-based composition differently, consider a reducer that will compose functions left-to-right (like pipe(..) does), to use in an array chain:

  1. var pipeReducer = (composedFn,fn) => pipe( composedFn, fn );
  2. var fn =
  3. [3,17,6,4]
  4. .map( v => n => v * n )
  5. .reduce( pipeReducer );
  6. fn( 9 ); // 11016 (9 * 3 * 17 * 6 * 4)
  7. fn( 10 ); // 12240 (10 * 3 * 17 * 6 * 4)

pipeReducer(..) is unfortunately not point-free (see Chapter 3, “No Points”), but we can’t just pass pipe(..) as the reducer itself, because it’s variadic; the extra arguments (idx and arr) that reduce(..) passes to its reducer function would be problematic.

Earlier we talked about using unary(..) to limit a mapperFn(..) or predicateFn(..) to just a single argument. It might be handy to have a binary(..) that does something similar but limits to two arguments, for a reducerFn(..) function:

  1. var binary =
  2. fn =>
  3. (arg1,arg2) =>
  4. fn( arg1, arg2 );

Using binary(..), our previous example is a little cleaner:

  1. var pipeReducer = binary( pipe );
  2. var fn =
  3. [3,17,6,4]
  4. .map( v => n => v * n )
  5. .reduce( pipeReducer );
  6. fn( 9 ); // 11016 (9 * 3 * 17 * 6 * 4)
  7. fn( 10 ); // 12240 (10 * 3 * 17 * 6 * 4)

Unlike map(..) and filter(..) whose order of passing through the array wouldn’t actually matter, reduce(..) definitely uses left-to-right processing. If you want to reduce right-to-left, JavaScript provides a reduceRight(..), with all other behaviors the same as reduce(..):

  1. var hyphenate = (str,char) => `${str}-${char}`;
  2. ["a","b","c"].reduce( hyphenate );
  3. // "a-b-c"
  4. ["a","b","c"].reduceRight( hyphenate );
  5. // "c-b-a"

Where reduce(..) works left-to-right and thus acts naturally like pipe(..) in composing functions, reduceRight(..)‘s right-to-left ordering is natural for performing a compose(..)-like operation. So, let’s revisit compose(..) from Chapter 4, but implement it using reduceRight(..):

  1. function compose(...fns) {
  2. return function composed(result){
  3. return fns.reduceRight( function reducer(result,fn){
  4. return fn( result );
  5. }, result );
  6. };
  7. }

Now, we don’t need to do [...fns].reverse(); we just reduce from the other direction!

Map as Reduce

The map(..) operation is iterative in its nature, so it can also be represented as a reduction (reduce(..)). The trick is to realize that the initialValue of reduce(..) can be itself an (empty) array, in which case the result of a reduction can be another list!

  1. var double = v => v * 2;
  2. [1,2,3,4,5].map( double );
  3. // [2,4,6,8,10]
  4. [1,2,3,4,5].reduce(
  5. (list,v) => (
  6. list.push( double( v ) ),
  7. list
  8. ), []
  9. );
  10. // [2,4,6,8,10]

Note: We’re cheating with this reducer: using a side effect by allowing list.push(..) to mutate the list that was passed in. In general, that’s not a good idea, obviously, but since we know the [] list is being created and passed in, it’s less dangerous. You could be more formal — yet less performant! — by creating a new list with the val concat(..)d onto the end. We’ll come back to this cheat in Appendix A.

Implementing map(..) with reduce(..) is not on its surface an obvious step or even an improvement. However, this ability will be a crucial recognition for more advanced techniques like those we’ll cover in Appendix A.

Filter as Reduce

Just as map(..) can be done with reduce(..), so can filter(..):

  1. var isOdd = v => v % 2 == 1;
  2. [1,2,3,4,5].filter( isOdd );
  3. // [1,3,5]
  4. [1,2,3,4,5].reduce(
  5. (list,v) => (
  6. isOdd( v ) ? list.push( v ) : undefined,
  7. list
  8. ), []
  9. );
  10. // [1,3,5]

Note: More impure reducer cheating here. Instead of list.push(..), we could have done list.concat(..) and returned the new list. We’ll come back to this cheat in Appendix A.

Advanced List Operations

Now that we feel somewhat comfortable with the foundational list operations map(..), filter(..), and reduce(..), let’s look at a few more-sophisticated operations you may find useful in various situations. These are generally utilities you’ll find in various FP libraries.

Unique

Filtering a list to include only unique values, based on indexOf(..) searching (which uses === strict equality comparison):

  1. var unique =
  2. arr =>
  3. arr.filter(
  4. (v,idx) =>
  5. arr.indexOf( v ) == idx
  6. );

This technique works by observing that we should only include the first occurrence of an item from arr into the new list; when running left-to-right, this will only be true if its idx position is the same as the indexOf(..) found position.

Another way to implement unique(..) is to run through arr and include an item into a new (initially empty) list if that item cannot already be found in the new list. For that processing, we use reduce(..):

  1. var unique =
  2. arr =>
  3. arr.reduce(
  4. (list,v) =>
  5. list.indexOf( v ) == -1 ?
  6. ( list.push( v ), list ) : list
  7. , [] );

Note: There are many other ways to implement this algorithm using more imperative approaches like loops, and many of them are likely “more efficient” performance-wise. However, the advantage of either of these presented approaches is that they use existing built-in list operations, which makes them easier to chain/compose alongside other list operations. We’ll talk more about those concerns later in this chapter.

unique(..) nicely produces a new list with no duplicates:

  1. unique( [1,4,7,1,3,1,7,9,2,6,4,0,5,3] );
  2. // [1, 4, 7, 3, 9, 2, 6, 0, 5]

Flatten

From time to time, you may have (or produce through some other operations) an array that’s not just a flat list of values — for instance, it might include nested arrays, as shown here:

  1. [ [1, 2, 3], 4, 5, [6, [7, 8]] ]

What if you’d like to transform it as follows?

  1. [ 1, 2, 3, 4, 5, 6, 7, 8 ]

The operation we’re looking for is typically called flatten(..), and it could be implemented like this using our Swiss Army knife reduce(..):

  1. var flatten =
  2. arr =>
  3. arr.reduce(
  4. (list,v) =>
  5. list.concat( Array.isArray( v ) ? flatten( v ) : v )
  6. , [] );

Note: This implementation choice relies on recursion as we saw in Chapter 8.

To use flatten(..) with an array of arrays (of any nested depth):

  1. 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]

You might like to limit the recursive flattening to a certain depth. We can handle this by adding an optional depth limit argument to the implementation:

  1. var flatten =
  2. (arr,depth = Infinity) =>
  3. arr.reduce(
  4. (list,v) =>
  5. list.concat(
  6. depth > 0 ?
  7. (depth > 1 && Array.isArray( v ) ?
  8. flatten( v, depth - 1 ) :
  9. v
  10. ) :
  11. [v]
  12. )
  13. , [] );

Illustrating the results with different flattening depths:

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

Mapping, Then Flattening

One of the most common usages of flatten(..) behavior is when you’ve mapped a list of elements where each transformed value from the original list is now itself a list of values. For example:

  1. var firstNames = [
  2. { name: "Jonathan", variations: [ "John", "Jon", "Jonny" ] },
  3. { name: "Stephanie", variations: [ "Steph", "Stephy" ] },
  4. { name: "Frederick", variations: [ "Fred", "Freddy" ] }
  5. ];
  6. firstNames
  7. .map( entry => [ entry.name, ...entry.variations ] );
  8. // [ ["Jonathan","John","Jon","Jonny"], ["Stephanie","Steph","Stephy"],
  9. // ["Frederick","Fred","Freddy"] ]

The return value is an array of arrays, which might be more awkward to work with. If we want a single dimension list with all the names, we can then flatten(..) that result:

  1. flatten(
  2. firstNames
  3. .map( entry => [ entry.name, ...entry.variations ] )
  4. );
  5. // ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy",
  6. // "Frederick","Fred","Freddy"]

Besides being slightly more verbose, the disadvantage of doing the map(..) and flatten(..) as separate steps is primarily around performance; this approach processes the list twice, and creates an intermediate list that’s then thrown away.

FP libraries typically define a flatMap(..) (often also called chain(..)) that does the mapping-then-flattening combined. For consistency and ease of composition (via currying), the flatMap(..) (aka chain(..)) utility typically matches the mapperFn, arr parameter order that we saw earlier with the standalone map(..), filter(..), and reduce(..) utilities:

  1. flatMap( entry => [ entry.name, ...entry.variations ], firstNames );
  2. // ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy",
  3. // "Frederick","Fred","Freddy"]

The naive implementation of flatMap(..) with both steps done separately:

  1. var flatMap =
  2. (mapperFn,arr) =>
  3. flatten( arr.map( mapperFn ), 1 );

Note: We use 1 for the flattening-depth because the typical definition of flatMap(..) is that the flattening is shallow on just the first level.

Since this approach still processes the list twice resulting in worse performance, we can combine the operations manually, using reduce(..):

  1. var flatMap =
  2. (mapperFn,arr) =>
  3. arr.reduce(
  4. (list,v) =>
  5. // note: concat(..) used here since it automatically
  6. // flattens an array into the concatenation
  7. list.concat( mapperFn( v ) )
  8. , [] );

While there’s some convenience and performance gained with a flatMap(..) utility, there may very well be times when you need other operations like filter(..)ing mixed in. If that’s the case, doing the map(..) and flatten(..) separately might still be more appropriate.

Zip

So far, the list operations we’ve examined have operated on a single list. But some cases will need to process multiple lists. One well-known operation alternates selection of values from each of two input lists into sub-lists, called zip(..):

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

Values 1 and 2 were selected into the sub-list [1,2], then 3 and 4 into [3,4], and so on. The definition of zip(..) requires a value from each of the two lists. If the two lists are of different lengths, the selection of values will continue until the shorter list has been exhausted, with the extra values in the other list ignored.

An implementation of zip(..):

  1. function zip(arr1,arr2) {
  2. var zipped = [];
  3. arr1 = [...arr1];
  4. arr2 = [...arr2];
  5. while (arr1.length > 0 && arr2.length > 0) {
  6. zipped.push( [ arr1.shift(), arr2.shift() ] );
  7. }
  8. return zipped;
  9. }

The [...arr1] and [...arr2] copies ensure zip(..) is pure by not causing side effects on the received array references.

Note: There are some decidedly un-FP things going on in this implementation. There’s an imperative while-loop and mutations of lists with both shift() and push(..). Earlier in the book, I asserted that it’s reasonable for pure functions to use impure behavior inside them (usually for performance), as long as the effects are fully self-contained. This implementation is safely pure.

Merge

Merging two lists by interleaving values from each source looks like this:

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

It may not be obvious, but this result seems similar to what we get if we compose flatten(..) and zip(..):

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

However, recall that zip(..) only selects values until the shorter of two lists is exhausted, ignoring the leftover values; merging two lists would most naturally retain those extra values. Also, flatten(..) works recursively on nested lists, but you might expect list-merging to only work shallowly, keeping nested lists.

So, let’s define a mergeLists(..) that works more like we’d expect:

  1. function mergeLists(arr1,arr2) {
  2. var merged = [];
  3. arr1 = [...arr1];
  4. arr2 = [...arr2];
  5. while (arr1.length > 0 || arr2.length > 0) {
  6. if (arr1.length > 0) {
  7. merged.push( arr1.shift() );
  8. }
  9. if (arr2.length > 0) {
  10. merged.push( arr2.shift() );
  11. }
  12. }
  13. return merged;
  14. }

Note: Various FP libraries don’t define a mergeLists(..) but instead define a merge(..) that merges properties of two objects; the results of such a merge(..) will differ from our mergeLists(..).

Alternatively, here are a couple of options to implement the list merging as a reducer:

  1. // via @rwaldron
  2. var mergeReducer =
  3. (merged,v,idx) =>
  4. (merged.splice( idx * 2, 0, v ), merged);
  5. // via @WebReflection
  6. var mergeReducer =
  7. (merged,v,idx) =>
  8. merged
  9. .slice( 0, idx * 2 )
  10. .concat( v, merged.slice( idx * 2 ) );

And using a mergeReducer(..):

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

Tip: We’ll use the mergeReducer(..) trick later in the chapter.

Method vs. Standalone

A common source of frustration for FPers in JavaScript is unifying their strategy for working with utilities when some of them are provided as standalone functions (think about the various FP utilities we’ve derived in previous chapters) and others are methods of the array prototype (like the ones we’ve seen in this chapter).

The pain of this problem becomes more evident when you consider combining multiple operations:

  1. [1,2,3,4,5]
  2. .filter( isOdd )
  3. .map( double )
  4. .reduce( sum, 0 ); // 18
  5. // vs.
  6. reduce(
  7. map(
  8. filter( [1,2,3,4,5], isOdd ),
  9. double
  10. ),
  11. sum,
  12. 0
  13. ); // 18

Both API styles accomplish the same task, but they have very different ergonomics. Many FPers will prefer the latter to the former, but the former is unquestionably more common in JavaScript. One thing specifically that’s disliked about the latter is the nesting of the calls. The preference for the method chain style — typically called a fluent API style, as in jQuery and other tools — is that it’s compact/concise and it reads in declarative top-down order.

The visual order for that manual composition of the standalone style is neither strictly left-to-right (top-to-bottom) nor right-to-left (bottom-to-top); it’s inner-to-outer, which harms the readability.

Automatic composition normalizes the reading order as right-to-left (bottom-to-top) for both styles. So, to explore the implications of the style differences, let’s examine composition specifically; it seems like it should be straightforward, but it’s a little awkward in both cases.

Composing Method Chains

The array methods receive the implicit this argument, so despite their appearance, they can’t be treated as unary; that makes composition more awkward. To cope, we’ll first need a this-aware version of partial(..):

  1. var partialThis =
  2. (fn,...presetArgs) =>
  3. // intentionally `function` to allow `this`-binding
  4. function partiallyApplied(...laterArgs){
  5. return fn.apply( this, [...presetArgs, ...laterArgs] );
  6. };

We’ll also need a version of compose(..) that calls each of the partially applied methods in the context of the chain — the input value it’s being “passed” (via implicit this) from the previous step:

  1. var composeChainedMethods =
  2. (...fns) =>
  3. result =>
  4. fns.reduceRight(
  5. (result,fn) =>
  6. fn.call( result )
  7. , result
  8. );

And using these two this-aware utilities together:

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

Note: The three Array.prototype.XXX-style references are grabbing references to the built-in Array.prototype.* methods so that we can reuse them with our own arrays.

Composing Standalone Utilities

Standalone compose(..)-style composition of these utilities doesn’t need all the this contortions, which is its most favorable argument. For example, we could define standalones as:

  1. var filter = (arr,predicateFn) => arr.filter( predicateFn );
  2. var map = (arr,mapperFn) => arr.map( mapperFn );
  3. var reduce = (arr,reducerFn,initialValue) =>
  4. arr.reduce( reducerFn, initialValue );

But this particular standalone approach, with the arr as the first parameter, suffers from its own awkwardness; the cascading array context is the first argument rather than the last, so we have to use right-partial application to compose them:

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

That’s why FP libraries typically define filter(..), map(..), and reduce(..) to instead receive the array last, not first. They also typically automatically curry the utilities:

  1. var filter = curry(
  2. (predicateFn,arr) =>
  3. arr.filter( predicateFn )
  4. );
  5. var map = curry(
  6. (mapperFn,arr) =>
  7. arr.map( mapperFn )
  8. );
  9. var reduce = curry(
  10. (reducerFn,initialValue,arr) =>
  11. arr.reduce( reducerFn, initialValue )
  12. );

Working with the utilities defined in this way, the composition flow is a bit nicer:

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

The cleanliness of this approach is in part why FPers prefer the standalone utility style instead of instance methods. But your mileage may vary.

Adapting Methods to Standalones

In the previous definition of filter(..)/map(..)/reduce(..), you might have spotted the common pattern across all three: they all dispatch to the corresponding native array method. So, can we generate these standalone adaptations with a utility? Yes! Let’s make a utility called unboundMethod(..) to do just that:

  1. var unboundMethod =
  2. (methodName,argCount = 2) =>
  3. curry(
  4. (...args) => {
  5. var obj = args.pop();
  6. return obj[methodName]( ...args );
  7. },
  8. argCount
  9. );

And to use this utility:

  1. var filter = unboundMethod( "filter", 2 );
  2. var map = unboundMethod( "map", 2 );
  3. var reduce = unboundMethod( "reduce", 3 );
  4. compose(
  5. reduce( sum )( 0 ),
  6. map( double ),
  7. filter( isOdd )
  8. )
  9. ( [1,2,3,4,5] ); // 18

Note: unboundMethod(..) is called invoker(..) in Ramda.

Adapting Standalones to Methods

If you prefer to work with only array methods (fluent chain style), you have two choices. You can:

  1. Extend the built-in Array.prototype with additional methods.
  2. Adapt a standalone utility to work as a reducer function and pass it to the reduce(..) instance method.

Don’t do (1). It’s never a good idea to extend built-in natives like Array.prototype — unless you define a subclass of Array, but that’s beyond our discussion scope here. In an effort to discourage bad practices, we won’t go any further into this approach.

Let’s focus on (2) instead. To illustrate this point, we’ll convert the recursive flatten(..) standalone utility from earlier:

  1. var flatten =
  2. arr =>
  3. arr.reduce(
  4. (list,v) =>
  5. // note: concat(..) used here since it automatically
  6. // flattens an array into the concatenation
  7. list.concat( Array.isArray( v ) ? flatten( v ) : v )
  8. , [] );

Let’s pull out the inner reducer(..) function as the standalone utility (and adapt it to work without the outer flatten(..)):

  1. // intentionally a function to allow recursion by name
  2. function flattenReducer(list,v) {
  3. // note: concat(..) used here since it automatically
  4. // flattens an array into the concatenation
  5. return list.concat(
  6. Array.isArray( v ) ? v.reduce( flattenReducer, [] ) : v
  7. );
  8. }

Now, we can use this utility in an array method chain via reduce(..):

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

Looking for Lists

So far, most of the examples have been rather trivial, based on simple lists of numbers or strings. Let’s now talk about where list operations can start to shine: modeling an imperative series of statements declaratively.

Consider this base example:

  1. var getSessionId = partial( prop, "sessId" );
  2. var getUserId = partial( prop, "uId" );
  3. var session, sessionId, user, userId, orders;
  4. session = getCurrentSession();
  5. if (session != null) sessionId = getSessionId( session );
  6. if (sessionId != null) user = lookupUser( sessionId );
  7. if (user != null) userId = getUserId( user );
  8. if (userId != null) orders = lookupOrders( userId );
  9. if (orders != null) processOrders( orders );

First, let’s observe that the five variable declarations and the running series of if conditionals guarding the function calls are effectively one big composition of these six calls getCurrentSession(), getSessionId(..), lookupUser(..), getUserId(..), lookupOrders(..), and processOrders(..). Ideally, we’d like to get rid of all these variable declarations and imperative conditionals.

Unfortunately, the compose(..)/pipe(..) utilities we explored in Chapter 4 don’t by themselves offer a convenient way to express the != null conditionals in the composition. Let’s define a utility to help:

  1. var guard =
  2. fn =>
  3. arg =>
  4. arg != null ? fn( arg ) : arg;

This guard(..) utility lets us map the five conditional-guarded functions:

  1. [ getSessionId, lookupUser, getUserId, lookupOrders, processOrders ]
  2. .map( guard )

The result of this mapping is an array of functions that are ready to compose (actually, pipe, in this listed order). We could spread this array to pipe(..), but because we’re already doing list operations, let’s do it with a reduce(..), using the session value from getCurrentSession() as the initial value:

  1. .reduce(
  2. (result,nextFn) => nextFn( result )
  3. , getCurrentSession()
  4. )

Next, let’s observe that getSessionId(..) and getUserId(..) can be expressed as a mapping from the respective values "sessId" and "uId":

  1. [ "sessId", "uId" ].map( propName => partial( prop, propName ) )

But to use these, we’ll need to interleave them with the other three functions (lookupUser(..), lookupOrders(..), and processOrders(..)) to get the array of five functions to guard/compose as discussed before.

To do the interleaving, we can model this as list merging. Recall mergeReducer(..) from earlier in the chapter:

  1. var mergeReducer =
  2. (merged,v,idx) =>
  3. (merged.splice( idx * 2, 0, v ), merged);

We can use reduce(..) (our Swiss Army knife, remember!?) to “insert” lookupUser(..) in the array between the generated functions getSessionId(..) and getUserId(..), by merging two lists:

  1. .reduce( mergeReducer, [ lookupUser ] )

Then we’ll concatenate lookupOrders(..) and processOrders(..) onto the end of the running functions array:

  1. .concat( lookupOrders, processOrders )

To review, the generated list of five functions is expressed as:

  1. [ "sessId", "uId" ].map( propName => partial( prop, propName ) )
  2. .reduce( mergeReducer, [ lookupUser ] )
  3. .concat( lookupOrders, processOrders )

Finally, to put it all together, take this list of functions and tack on the guarding and composition from earlier:

  1. [ "sessId", "uId" ].map( propName => partial( prop, propName ) )
  2. .reduce( mergeReducer, [ lookupUser ] )
  3. .concat( lookupOrders, processOrders )
  4. .map( guard )
  5. .reduce(
  6. (result,nextFn) => nextFn( result )
  7. , getCurrentSession()
  8. );

Gone are all the imperative variable declarations and conditionals, and in their place we have clean and declarative list operations chained together.

I know this version is likely harder for most readers to understand right now than the original. Don’t worry, that’s natural. The original imperative form is one you’re probably much more familiar with.

Part of your evolution to become a functional programmer is to develop a recognition of FP patterns such as list operations, and that takes lots of exposure and practice. Over time, these will jump out of the code more readily as your sense of code readability shifts to declarative style.

Before we move on from this topic, let’s take a reality check: the example here is heavily contrived. Not all code segments will be straightforwardly modeled as list operations. The pragmatic take-away is to develop the instinct to look for these opportunities, but not get too hung up on code acrobatics; some improvement is better than none. Always step back and ask if you’re improving or harming code readability.

Fusion

As FP list operations permeate the way you think about code, you’ll very likely start recognizing chains of combined behavior, like:

  1. ..
  2. .filter(..)
  3. .map(..)
  4. .reduce(..);

And more often than not, you’re also probably going to end up with chains with multiple adjacent instances of each operation, like:

  1. someList
  2. .filter(..)
  3. .filter(..)
  4. .map(..)
  5. .map(..)
  6. .map(..)
  7. .reduce(..);

The good news is the chain-style is declarative and it’s easy to read the specific steps that will happen, in order. The downside is that each of these operations loops over the entire list, meaning performance can suffer unnecessarily, especially if the list is longer.

With the alternative standalone style, you might see code like this:

  1. map(
  2. fn3,
  3. map(
  4. fn2,
  5. map( fn1, someList )
  6. )
  7. );

With this style, the operations are listed from bottom-to-top, and we still loop over the list three times.

Fusion deals with combining adjacent operators to reduce the number of times the list is iterated over. We’ll focus here on collapsing adjacent map(..)s as it’s the most straightforward to explain.

Imagine this scenario:

  1. var removeInvalidChars = str => str.replace( /[^\w]*/g, "" );
  2. var upper = str => str.toUpperCase();
  3. var elide = str =>
  4. str.length > 10 ?
  5. str.substr( 0, 7 ) + "..." :
  6. str;
  7. var words = "Mr. Jones isn't responsible for this disaster!"
  8. .split( /\s/ );
  9. words;
  10. // ["Mr.","Jones","isn't","responsible","for","this","disaster!"]
  11. words
  12. .map( removeInvalidChars )
  13. .map( upper )
  14. .map( elide );
  15. // ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]

Think about each value that goes through this flow of transformations. The first value in the words list starts out as "Mr.", becomes "Mr", then "MR", and then passes through elide(..) unchanged. Another piece of data flows: "responsible" -> "responsible" -> "RESPONSIBLE" -> "RESPONS...".

In other words, you could think of these data transformations like this:

  1. elide( upper( removeInvalidChars( "Mr." ) ) );
  2. // "MR"
  3. elide( upper( removeInvalidChars( "responsible" ) ) );
  4. // "RESPONS..."

Did you catch the point? We can express the three separate steps of the adjacent map(..) calls as a composition of the transformers, since they are all unary functions and each returns the value that’s suitable as input to the next. We can fuse the mapper functions using compose(..), and then pass the composed function to a single map(..) call:

  1. words
  2. .map(
  3. compose( elide, upper, removeInvalidChars )
  4. );
  5. // ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]

This is another case where pipe(..) can be a more convenient form of composition, for its ordering readability:

  1. words
  2. .map(
  3. pipe( removeInvalidChars, upper, elide )
  4. );
  5. // ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]

What about fusing two or more filter(..) predicate functions? Typically treated as unary functions, they seem suitable for composition. But the wrinkle is they each return a different kind of value (boolean) than the next one would want as input. Fusing adjacent reduce(..) calls is also possible, but reducers are not unary so that’s a bit more challenging; we need more sophisticated tricks to pull this kind of fusion off. We’ll cover these advanced techniques in Appendix A.

Beyond Lists

So far we’ve been discussing operations in the context of the list (array) data structure; it’s by far the most common scenario where you’ll encounter them. But in a more general sense, these operations can be performed against any collection of values.

Just as we said earlier that array’s map(..) adapts a single-value operation to all its values, any data structure can provide a map(..) operation to do the same. Likewise, it can implement filter(..), reduce(..), or any other operation that makes sense for working with the data structure’s values.

The important part to maintain in the spirit of FP is that these operators must behave according to value immutability, meaning that they must return a new data structure rather than mutating the existing one.

章节 9: 列表的操作 - 图5

Let’s illustrate with a well-known data structure: the binary tree. A binary tree is a node (just an object!) that has at most two references to other nodes (themselves binary trees), typically referred to as left and right child trees. Each node in the tree holds one value of the overall data structure.

For ease of illustration, we’ll make our binary tree a binary search tree (BST). However, the operations we’ll identify work the same for any regular non-BST binary tree.

Note: A binary search tree is a general binary tree with a special constraint on the relationship of values in the tree to each other. Each value of nodes on the left side of a tree is less than the value of the node at the root of that tree, which in turn is less than each value of nodes in the right side of the tree. The notion of “less than” is relative to the kind of data stored; it can be numerical for numbers, lexicographic for strings, and so on. BSTs by definition must remain balanced, which makes searching for a value in the tree more efficient, using a recursive binary search algorithm.

To make a binary tree node object, let’s use this factory function:

  1. var BinaryTree =
  2. (value,parent,left,right) => ({ value, parent, left, right });

For convenience, we make each node store the left and right child trees as well as a reference to its own parent node.

Let’s now define a BST of names of common produce (fruits, vegetables):

  1. var banana = BinaryTree( "banana" );
  2. var apple = banana.left = BinaryTree( "apple", banana );
  3. var cherry = banana.right = BinaryTree( "cherry", banana );
  4. var apricot = apple.right = BinaryTree( "apricot", apple );
  5. var avocado = apricot.right = BinaryTree( "avocado", apricot );
  6. var cantaloupe = cherry.left = BinaryTree( "cantaloupe", cherry );
  7. var cucumber = cherry.right = BinaryTree( "cucumber", cherry );
  8. var grape = cucumber.right = BinaryTree( "grape", cucumber );

In this particular tree structure, banana is the root node; this tree could have been set up with nodes in different locations, but still had a BST with the same traversal.

Our tree looks like:

章节 9: 列表的操作 - 图6

There are multiple ways to traverse a binary tree to process its values. If it’s a BST (ours is!) and we do an in-order traversal — always visit the left child tree first, then the node itself, then the right child tree — we’ll visit the values in ascending (sorted) order.

Because you can’t just easily console.log(..) a binary tree like you can with an array, let’s first define a convenience method, mostly to use for printing. forEach(..) will visit the nodes of a binary tree in the same manner as an array:

  1. // in-order traversal
  2. BinaryTree.forEach = function forEach(visitFn,node){
  3. if (node) {
  4. if (node.left) {
  5. forEach( visitFn, node.left );
  6. }
  7. visitFn( node );
  8. if (node.right) {
  9. forEach( visitFn, node.right );
  10. }
  11. }
  12. };

Note: Working with binary trees lends itself most naturally to recursive processing. Our forEach(..) utility recursively calls itself to process both the left and right child trees. We already discussed recursion in Chapter 8, where we covered recursion in the chapter on recursion.

Recall forEach(..) was described at the beginning of this chapter as only being useful for side effects, which is not very typically desired in FP. In this case, we’ll use forEach(..) only for the side effect of I/O, so it’s perfectly reasonable as a helper.

Use forEach(..) to print out values from the tree:

  1. BinaryTree.forEach( node => console.log( node.value ), banana );
  2. // apple apricot avocado banana cantaloupe cherry cucumber grape
  3. // visit only the `cherry`-rooted subtree
  4. BinaryTree.forEach( node => console.log( node.value ), cherry );
  5. // cantaloupe cherry cucumber grape

To operate on our binary tree data structure using FP patterns, let’s start by defining a map(..):

  1. BinaryTree.map = function map(mapperFn,node){
  2. if (node) {
  3. let newNode = mapperFn( node );
  4. newNode.parent = node.parent;
  5. newNode.left = node.left ?
  6. map( mapperFn, node.left ) : undefined;
  7. newNode.right = node.right ?
  8. map( mapperFn, node.right ): undefined;
  9. if (newNode.left) {
  10. newNode.left.parent = newNode;
  11. }
  12. if (newNode.right) {
  13. newNode.right.parent = newNode;
  14. }
  15. return newNode;
  16. }
  17. };

You might have assumed we’d map(..) only the node value properties, but in general we might actually want to map the tree nodes themselves. So, the mapperFn(..) is passed the whole node being visited, and it expects to receive a new BinaryTree(..) node back, with the transformation applied. If you just return the same node, this operation will mutate your tree and quite possibly cause unexpected results!

Let’s map our tree to a list of produce with all uppercase names:

  1. var BANANA = BinaryTree.map(
  2. node => BinaryTree( node.value.toUpperCase() ),
  3. banana
  4. );
  5. BinaryTree.forEach( node => console.log( node.value ), BANANA );
  6. // APPLE APRICOT AVOCADO BANANA CANTALOUPE CHERRY CUCUMBER GRAPE

BANANA is a different tree (with all different nodes) than banana, just like calling map(..) on an array returns a new array. Just like arrays of other objects/arrays, if node.value itself references some object/array, you’ll also need to handle manually copying it in the mapper function if you want deeper immutability.

How about reduce(..)? Same basic process: do an in-order traversal of the tree nodes. One usage would be to reduce(..) our tree to an array of its values, which would be useful in further adapting other typical list operations. Or we can reduce(..) our tree to a string concatenation of all its produce names.

We’ll mimic the behavior of the array reduce(..), which makes passing the initialValue argument optional. This algorithm is a little trickier, but still manageable:

  1. BinaryTree.reduce = function reduce(reducerFn,initialValue,node){
  2. if (arguments.length < 3) {
  3. // shift the parameters since `initialValue` was omitted
  4. node = initialValue;
  5. }
  6. if (node) {
  7. let result;
  8. if (arguments.length < 3) {
  9. if (node.left) {
  10. result = reduce( reducerFn, node.left );
  11. }
  12. else {
  13. return node.right ?
  14. reduce( reducerFn, node, node.right ) :
  15. node;
  16. }
  17. }
  18. else {
  19. result = node.left ?
  20. reduce( reducerFn, initialValue, node.left ) :
  21. initialValue;
  22. }
  23. result = reducerFn( result, node );
  24. result = node.right ?
  25. reduce( reducerFn, result, node.right ) : result;
  26. return result;
  27. }
  28. return initialValue;
  29. };

Let’s use reduce(..) to make our shopping list (an array):

  1. BinaryTree.reduce(
  2. (result,node) => [ ...result, node.value ],
  3. [],
  4. banana
  5. );
  6. // ["apple","apricot","avocado","banana","cantaloupe"
  7. // "cherry","cucumber","grape"]

Finally, let’s consider filter(..) for our tree. This algorithm is trickiest so far because it effectively (not actually) involves removing nodes from the tree, which requires handling several corner cases. Don’t get intimidated by the implementation, though. Just skip over it for now, if you prefer, and focus on how we use it instead.

  1. BinaryTree.filter = function filter(predicateFn,node){
  2. if (node) {
  3. let newNode;
  4. let newLeft = node.left ?
  5. filter( predicateFn, node.left ) : undefined;
  6. let newRight = node.right ?
  7. filter( predicateFn, node.right ) : undefined;
  8. if (predicateFn( node )) {
  9. newNode = BinaryTree(
  10. node.value,
  11. node.parent,
  12. newLeft,
  13. newRight
  14. );
  15. if (newLeft) {
  16. newLeft.parent = newNode;
  17. }
  18. if (newRight) {
  19. newRight.parent = newNode;
  20. }
  21. }
  22. else {
  23. if (newLeft) {
  24. if (newRight) {
  25. newNode = BinaryTree(
  26. undefined,
  27. node.parent,
  28. newLeft,
  29. newRight
  30. );
  31. newLeft.parent = newRight.parent = newNode;
  32. if (newRight.left) {
  33. let minRightNode = newRight;
  34. while (minRightNode.left) {
  35. minRightNode = minRightNode.left;
  36. }
  37. newNode.value = minRightNode.value;
  38. if (minRightNode.right) {
  39. minRightNode.parent.left =
  40. minRightNode.right;
  41. minRightNode.right.parent =
  42. minRightNode.parent;
  43. }
  44. else {
  45. minRightNode.parent.left = undefined;
  46. }
  47. minRightNode.right =
  48. minRightNode.parent = undefined;
  49. }
  50. else {
  51. newNode.value = newRight.value;
  52. newNode.right = newRight.right;
  53. if (newRight.right) {
  54. newRight.right.parent = newNode;
  55. }
  56. }
  57. }
  58. else {
  59. return newLeft;
  60. }
  61. }
  62. else {
  63. return newRight;
  64. }
  65. }
  66. return newNode;
  67. }
  68. };

The majority of this code listing is dedicated to handling the shifting of a node’s parent/child references if it’s “removed” (filtered out) of the duplicated tree structure.

As an example to illustrate using filter(..), let’s narrow our produce tree down to only vegetables:

  1. var vegetables = [ "asparagus", "avocado", "broccoli", "carrot",
  2. "celery", "corn", "cucumber", "lettuce", "potato", "squash",
  3. "zucchini" ];
  4. var whatToBuy = BinaryTree.filter(
  5. // filter the produce list only for vegetables
  6. node => vegetables.indexOf( node.value ) != -1,
  7. banana
  8. );
  9. // shopping list
  10. BinaryTree.reduce(
  11. (result,node) => [ ...result, node.value ],
  12. [],
  13. whatToBuy
  14. );
  15. // ["avocado","cucumber"]

Note: We aren’t making any effort to rebalance a tree after any of the map/reduce/filter operations on BSTs. Technically, this means the results are not themselves binary search trees. Most JS values have a reasonable less-than comparison operation (<) by which we could rebalance such a tree, but some values (like promises) wouldn’t have any such definition. For the sake of keeping this chapter practical in length, we’ll punt on handling this complication.

You will likely use most of the list operations from this chapter in the context of simple arrays. But now we’ve seen that the concepts apply to whatever data structures and operations you might need. That’s a powerful expression of how FP can be widely applied to many different application scenarios!

Summary

Three common and powerful list operations we looked at:

  • map(..): Transforms values as it projects them to a new list.
  • filter(..): Selects or excludes values as it projects them to a new list.
  • reduce(..): Combines values in a list to produce some other (usually but not always non-list) value.

Other more advanced operations that are useful in processing lists: unique(..), flatten(..), and merge(..).

Fusion uses function composition to consolidate multiple adjacent map(..) calls. This is mostly a performance optimization, but it also improves the declarative nature of your list operations.

Lists are typically visualized as arrays, but can be generalized as any data structure that represents/produces an ordered collection of values. As such, all these “list operations” are actually “data structure operations”.