龙空技术网

翻译连载-JavaScript轻量级函数式编程-第8章:列表操作(下)

iKcamp 69

前言:

现在咱们对“jquery当当图书导航”大体比较看重,你们都需要剖析一些“jquery当当图书导航”的相关资讯。那么小编在网络上收集了一些关于“jquery当当图书导航””的相关文章,希望各位老铁们能喜欢,姐妹们一起来学习一下吧!

方法 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.*方法,这样我们可以在数组中重复使用它们。

独立组合实用函数

独立的 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(..)。

独立函数适配为方法

如果你喜欢仅仅使用数组方法(流畅的链式风格),你有两个选择:

采用额外的方法扩展内建的 Array.prototype

把独立实用函数适配成一个缩减函数,并且将其传递给 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中不仅仅保存了 left 和 right 子树节点,也保存了其自身的 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

BANANA 和 banana 是一个不同的树(所有的节点都不同),就像在列表中执行 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(..)调用。这是常见的性能优化方式,并且它也使得列表操作更加自然。

列表通常以数组展现,但它也可以作为任何数据结构表达/产生一个有序的值集合。因此,所有这些“列表操作”都是“数据结构操作”。

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

沪江Web前端上海团队招聘【Web前端架构师】,有意者简历至:zhouyao@hujiang.com

标签: #jquery当当图书导航