learn-tech/专栏/JavaScript进阶实战课/04如何通过组合、管道和reducer让函数抽象化?.md
2024-10-16 06:37:41 +08:00

12 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        04 如何通过组合、管道和reducer让函数抽象化
                        你好,我是石川。

上节课我们讲到,通过部分应用和柯里化,我们做到了从抽象到具象化。那么,今天我们要讲的组合和管道,就是反过来帮助我们把函数从具象化变到抽象化的过程。它相当于是系统化地把不同的组件函数,封装在了只有一个入口和出口的函数当中。

其实我们在上节课讲处理函数输入问题的时候在介绍unary的相关例子中已经看到了组合的雏形。在函数式编程里组合Composition的概念就是把组件函数组合起来形成一个新的函数。

我们可以先来看个简单的组合函数例子比如要创建一个“判断一个数是否为奇数”的isOdd函数可以先写一个“计算目标数值除以2的余数”的函数然后再写一个“看结果是不是等于1”的函数。这样isOdd函数就是建立在两个组件函数的基础上。

var isOdd = compose(equalsToOne, remainderOfTwo);

不过你会看到这个组合的顺序是反直觉的因为如果按照正常的顺序应该是先把remainderByTwo放在前面来计算余数然后再执行后面的equalsToOne 看结果是不是等于1。

那么这里为什么会有一个反直觉的设计呢今天这节课我们就通过回答这个问题来看看组合和管道要如何做到抽象化而reducer又是如何在一系列的操作中提高针对值的处理性能的。

组合Compose

在讲组合前我想先带你来看看Point-Free和函数组件。这里我们还是用刚刚提到的“判断一个值是不是奇数”的isOdd函数来一步步看下它的实现。

Point-Free

那么首先什么是Point-Free呢实际上Point-Pree是函数式编程中的一种编程风格其中的Point是指参数free是指没有。加在一起Point-Free的意思就是没有参数的函数。

而这样做的目的是什么呢其实通过这种方式就可以将一个函数和另外一个函数结合起来形成一个新函数。比如为了要创建isOdd函数通过这种方式我们就可以把这两个函数“组合”在一起得到isOdd。

var isOdd = (x) => equalsToOne(remainderOfTwo(x));

函数组件

接着,我们再来看函数组件。

在以下的代码示例当中我们先定义了两个函数第一个是dividedBy它的作用是计算x除以y的余数第二个是equalsTo它是用来看余数是否等于1。

这两个函数其实就是我们用到的组件函数。你可以发现,这两个组件的特点都是努力专注做好一件小事。

var dividedBy = (y) => { return function forX(x) { return x % y; } } var equalsTo = (y) => { return function forX(x) { return x === y; } }

然后在dividedBy和equalsToOne的基础上我们就可以创建两个Point-Free的函数remainderOfTwo和equalsToOne。

var remainderOfTwo = dividedBy(2); var equalsToOne = equalsTo(1);

最后,我们只需要传入参数 x就可以计算相应的isOdd的结果了。

var isOdd = (x) => equalsToOne(remainderOfTwo(x));

好了现在我们知道了函数是可以通过写成组件来应用的。这里其实就是用到了函数式编程声明式的思想equalsToOne和remainderByTwo不仅把过程进行了封装而且把参数也去掉了暴露给使用者的就是功能本身。所以我们只需要把这两个函数组件的功能结合起来就可以实现isOdd函数了。

独立的组合函数

下面我们再来看看独立的组合函数。

其实从上面的例子里,我们已经看到了组合的影子。那么更进一步地,我们就可以把组合抽象成一个独立的函数,如下所示:

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

也就是说基于这里抽象出来的compose功能我们可以把之前的组件函数组合起来。

var isOdd = compose(equalsToOne, remainderOfTwo);

所以,回到课程一开始提到的问题:为什么组合是反直觉的?因为它是按照传参顺序来排列的。

前面讲的这个组合,其实就是 equalsToOne(remainderOfTwo(x))。在数学中,组合写成 fog意思就是一个函数接收一个参数x并返回成一个 f(g(x))。

好,不过看到这里,你可能还是觉得,即使自己理解了它的概念,但是仍然觉得它反直觉,因此想要一种更直观的顺序来完成一系列操作。这个也有相应的解决方案,那就是用函数式编程中的管道。

管道Pipeline

函数式编程中的管道,是另外一种函数的创建方式。这样创建出来的函数的特点是:一个函数的输出会作为下一个函数的输入,然后按顺序执行。

所以,管道就是以组合反过来的方式来处理的。

Unix/Linux中的管道

其实管道的概念最早是源于Unix/Linux这个概念的创始人道格拉斯·麦克罗伊Douglas McIlroy在贝尔实验室的文章中曾经提到过两个很重要的点

一是让每个程序只专注做好一件事。如果有其它新的任务,那么应该重新构建,而不是通过添加新功能使旧程序复杂化。 二是让每个程序的输出,可以成为另一个程序的输入。

感兴趣的话你也可以读一下这篇杂志文章虽然这是1978年的文章但是它的设计思想到现在都不算过时。

那么现在我们就来看一个简单的管道例子在这个例子里我们可以找到当前目录下面所有的JavaScript文件。

$ ls -1 | grep "js$" | wc -l

你能发现,这个管道有竖线“ | ”隔开的三个部分。第一个部分 ls -1列出并返回了当前目录下所有的文件这个结果作为了第二步 grep "js$" 的输入;第二个部分会过滤出所有的以 js 结尾的文件;然后第二步的结果会作为第三部分的输入,在第三步,我们会看到最后计算的结果。

JavaScript中的管道

回到JavaScript中我们也可以用isOdd的例子来看看同样的功能要如何通过管道来实现。

其实也很简单我们只需要通过一个reverseArgs函数将compose中接收参数的顺序反过来即可。

你可能会想到我们在上节课讲unary的时候是把函数的输入参数减少到1而这里是把参数做倒序处理生成一个新的函数。在函数式编程中这算是一个比较经典的高阶函数的例子。

function reverseArgs(fn) { return function argsReversed(...args){ return fn( ...args.reverse() ); }; }

var pipe = reverseArgs( compose );

然后我们可以测试下管道是否“畅通”。这次我们把remainderOfTwo和equalsToOne按照比较直观的方式进行排序。

可以看到isOdd(1)返回的结果是trueisOdd(2)返回的结果是false和我们预期的结果是一样的。

const isOdd = pipe(remainderOfTwo, equalsToOne);

isOdd(1); // 返回 true isOdd(2); // 返回 false

Transduction

讲完了组合和管道之后,还有一个地方想再跟你强调下。

我一再说过函数式编程中的很多概念都来自于对复杂、动力系统研究与控制等领域。而通过组合和管道我们可以再延伸来看一下转导transducing

转导主要用于控制系统Control System比如声波作为输入通过麦克风进入到一个功放然后功放进行能量转换最后通过喇叭传出声音的这样一个系统就可以成为转导。

当然单独看这个词你或许并没有什么印象但是如果说React.js你应该知道这是一个很著名的前端框架。在这里面的reducer的概念就用到了transducing。

在后面的课程中我们讲到响应式编程和观察者模式的时候还会更深入了解reducer。这里我们就先来看看transduce和reducer的作用以及原理。

那么reducer是做什么用的呢它最主要的作用其实是解决在使用多个map、filter、reduce操作大型数组时可能会发生的性能问题。

而通过使用transducer和reducer我们就可以优化一系列map、filter、reduce操作使得输入数组只被处理一次并直接产生输出结果而不需要创建任何中间数组。

可能我这么讲你还是不太好理解这里我们先来举一个不用tansducer或reducer例子吧。

var oldArray = [36, 29, 18, 7, 46, 53]; var newArray = oldArray .filter(isEven) .map(double) .filter(passSixty) .map(addFive);

console.log (newArray); // 返回:[77,97]

在这个例子里,我们对一组数组进行了一系列的操作,先是筛选出奇数,再乘以二,之后筛出大于六十的值,最后加上五。在这个过程中,会不断生成中间数组。

这个实际发生的过程如下图左半部分所示。

而如果使用reducer的话我们对每个值只需要操作一次就可产出最终的结果。如上图的右半部分所示。

那么它是如何实现的呢在这里我们是先将一个函数比如isEven作为输入放到了一个transducer里然后作为输出我们得到的是一个isEvenR的reducer函数。

是的这里的transducer其实也是一个经典的高阶函数即输入一个函数得到一个新的函数的例子

实际上,像 double和addFive都具有映射类的功能所以我们可以通过一个类似mapReducer这样的一个transducer来把它们转换成reducer。而像 isEven和passSixty都是筛选类的功能所以我们可以通过一个类似filterReducer这样的一个transducer来把它们转换成 reducer。

如果我们抽象化来看,其代码大致如下。它的具体实现这里我卖个关子,你可以先自己思考下,我们下节课再探讨。

var oldArray = [36, 29, 18, 7, 46, 53];

var newArray = composeReducer(oldArray, [ filterTR(isEven), mapTR(double), filterTR(passSixty), mapTR(addFive), ]);

console.log (newArray); // 返回:[77,97]

总而言之从上面的例子中我们可以看出来composeReducer用的就是一个类似组合的功能。

总结

这节课通过对组合和管道的了解,相信你可以看出来,它们和上节课我们讲到的部分应用和柯里化正好相反,一个是从具象走向抽象,一个是从抽象走向具象。

不过,虽然说它们的方向是相反的,但有一条原则是一致的,那就是每个函数尽量有一个单一职责,只专注做好一件事。

值得注意的是,这里的方向不同,并不是指我们要用抽象取代具象,或者是用具象取代抽象。而是说它们都是为了单一职责函数的原则,相辅相成地去具象化或抽象化。

另外通过reducer的例子我们也知道了如何通过reducer的组合做到普通的组合达不到的性能提升。

在这节课里我们是先从一个抽象层面理解了reducer不过你可能仍然对map、filter、reduce等概念和具体实现感到有些陌生。不用担心下节课我就带你来进一步了解这一系列针对值的操作工具的机制以及functor和monad。

思考题

我们讲到reduce可以用来实现map和filter ,那么你知道这背后的原理吗?欢迎在留言区分享你的答案,或者你如果对此并不十分了解,也希望你能找找资料,作为下节课的预习内容。

当然,你也可以在评论区交流下自己的疑问,我们一起讨论、共同进步。