learn-tech/专栏/JavaScript进阶实战课/05map、reduce和monad如何围绕值进行操作?.md
2024-10-16 06:37:41 +08:00

255 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
05 map、reduce和monad如何围绕值进行操作
你好,我是石川。
上节课里我们在学习组合和管道的工作机制的时候第一次认识了reducer同时在讲到transduce的时候也接触到了map、filter和reduce这些概念。那么今天这节课我们就通过JS中数组自带的功能方法来进一步了解下transduce的原理做到既知其然又知其所以然。
另外我们也看看由map作为functor可以引申出的monad的概念看看它是如何让函数间更好地进行交互的。
数据的核心操作
那在正式开始之前我先来说明下这节课你要关注的重点。课程中我会先带你通过JavaScript本身自带的映射map、过滤filter和reduce方法来了解下这几种方法对值的核心操作。同时呢我也给你解答下上节课提出的问题即如何通过映射和过滤来做到reduce。作为我们后面讲到functor和monad的基础。
下面我们就从map开始讲起。
map映射和函子
我们经常听说array.map就是一个函子functor那什么是一个函子呢
实际上函子是一个带运算工具的数据类型或数据结构值。举个常用的例子在JavaScript中字符串string就是一个数据类型而数组array既是一个数据类型也是一个数据结构我们可以用字符串来表达一个单词或句子。而如果我们想让下图中的每个字母都变成大写那么就是一个转换和映射的过程。
我们再用一段抽象的代码来表示一个字符串的映射函子stringMap。可以看到stringMap可以把字符串Hello World!作为输入然后通过一个uppercaseLetter工具函数的转换对应返回大写的HELLO WORLD!。
stringMap( uppercaseLetter, "Hello World!" ); // HELLO WORLD!
类似地如果我们有一个数组的映射函子arrayMap也可以把数组 [“1”,“2”,“3”] 中每个字符串的元素转化成整数,然后再对应输出一个整数数组 [1, 2, 3]。
["1","2","3","4","5"].map( unary( parseInt ) ); // [1,2,3,4,5]
filter过滤和筛选
说完了函子我们再来看看过滤器filter和断言predicate
filter顾名思义就是过滤的意思。但要注意一点filter可以是双向的我们可以过滤掉filter out不想要的东西也可以筛选出filter in出想要的东西。
然后再来看断言。我们上节课在说到处理输入参数的工具的时候也接触过断言比如identity就可以看作是断言。在函数式编程中断言就是一个个的筛选条件所以在过滤器中我们经常会使用断言函数。
举个例子假如有一个用来判断“一个值是不是奇数”的isOdd函数它是一个断言而它的筛选条件就是筛选出数组中的单数。所以如果用它来筛选 [1,2,3,4,5],得到的结果就是 [1,3,5]。
[1,2,3,4,5].filter( isOdd ); // [1,3,5]
在Javascript中也有自带的 some() 和 every() 断言方法。它们的作用就是可以判断数组中的一组元素是不是都符合判断条件。
比如在下面一列包含了 [1,2,3,4,5] 这几个数字的数组中如果我们要判断它的每一个元素是不是都小于6结果就是true。如果我们要判断它们是不是都是奇数结果就是false因为这里面既有奇数也有偶数。
let arr = [1,2,3,4,5];
arr.every(x => x < 6) // => true所有的值都小于6
arr.every(x => x % 2 === 1) // => false不是所有的数都是奇数
类似地some()可以帮助我们判断这组数组中有没有一些小于6的数字或者奇数。这时这两个判断返回的结果都是true。
let arr = [1,2,3,4,5];
arr.some(x => x < 6) // => truea里面有小于6的数字
arr.some(x => x % 2 === 1) // => true数组a里面有一些奇数
虽然some() 和 every() 都是 JavaScript自带的断言方法但是对比 filter() ,它们就显得没有那么“函数式”了,因为它们的返回值只是一个 true 或 false而没有像 filter 一样返回一组数据作为输出,继续用来进行后续一系列的函数式的操作。
reduce和缩减器
最后我们再来说说reduce。实际上缩减reduce主要的作用就是把列表中的值合成一个值。如下图所示
在reduce当中有一个缩减器reducer函数和一个初始值。比如在下面的例子中初始值是3reducer函数会计算3乘以5的结果再乘以10得出的结果再乘以15最后归为一个结果2250。
[5,10,15].reduce( (arr,val) => arr * val, 3 ); // 2250
而缩减reduce除了能独立来实现以外也可以用映射map和过滤filter的方法来实现。这是因为 reduce的初始值可以是一个空数组[],这样我们就可以把迭代的结果当成另一个数组了。
我们来看一个例子:
var half = v => v / 2;
[2,4,6,8,10].map( half ); // [1,2,3,4,5]
[2,4,6,8,10].reduce(
(list,v) => (
list.push( half( v ) ),
list
), []
); // [1,2,3,4,5]
var isEven = v => v % 2 == 0;
[1,2,3,4,5].filter( isEven ); // [2,4]
[1,2,3,4,5].reduce(
(list,v) => (
isEven( v ) ? list.push( v ) : undefined,
list
), []
); // [2,4]
可以发现这里我故意利用了一个副作用。通过第一节课的学习我们知道array.push是一个非纯函数的方法它改变了原数组而不是复制后修改。而如果我们想完全避免副作用可以用concat。但是我们也知道concat虽然遵循的是纯函数、不可变的原则但是有一点是我们需要注意的就是它在面对大量的复制和修改时会产生性能上的问题。所以估计到这里你也猜到了在上节课中我们提到的transducer的原理了。
是的,这里我们就是故意利用了副作用来提高性能!
你或许会认为,这样是不是就违背了纯函数和不可变的原则?实际上是也不是,因为在原则上,我们做的这些变化都是在函数内部的,而我在前面说过,需要注意的副作用一般多是来自外部。
所以在这个例子中我们没有必要为了几乎没有负面影响的副作用而牺牲性能。而transducer正是利用了副作用才做到的性能提升。
单子monad
现在让我们回到课程一开始提到的问题monad和functor有什么区别呢
在开篇词我们也提到过函子functor其实就是一个值和围绕值的一些功能。所以我们知道array.map可以被看做是一个functor它有一组值而如map这样的方法可以作用于数组里面的每一个值提供了一个映射的功能。而monad就是在functor的基础上又增加了一些特殊功能其中最常见的就是 chain和应用函子applicative)。下面我就带你具体看看。
array作为functor
前面我们说过array.map就是一个函子它有一个自带的包装对象这个对象有类似map这样的映射功能。那么同样地我们也可以自己写一个带有映射方法的Just Monad用它来包装一个值val。这个时候monad相当于是一个基于值形成的新的数据结构这个数据结构里有map的方法函数。
function Just(val) {
return { map };
function map(fn) { return Just( fn( val ) ); }
}
可见它的使用方式就类似于我们之前看到的array.map映射。比如在下面的例子里我们用map将一个函数 v => v * 2 运用到了Just monad封装的值10上它返回的就是20。
var A = Just( 10 );
var B = A.map( v => v * 2 ); // 20
chain作为bind、flatMap
再来说说chain。
chain通常又叫做flatMap或bind它的作用是flatten或unwrap也就是说它可以展开被Just封装的值val。你可以使用chain将一个函数作用到一个包装的值上返回一个结果值。如下代码所示
function Just(val) {
return { map, chain };
function map(fn) { return Just( fn( val ) ); }
// aka: bind, flatMap
function chain(fn) { return fn( val ); }
}
我再举个例子我们用chain方法函数把一个加一的函数作为参数运用到monad A上得到了一个 15+1=16 的结果那么之后返回的就是一个flatten或unwrap展开的16了。
var A = Just( 15 );
var B = A.chain( v => v + 1 );
B; // 16
typeof B; // "number"
monoid
OK既然说到了chain我们也可以看一下monoid。
在上节课我们说过函数组合compostion。而在组合里有一个概念就是签名一致性的问题。举个例子如果前一个函数返回了一个字符串后一个函数接收的输入是数字那么它们是没办法组合的。所以compose函数接收的函数都要符合一致性的 fn :: v -> v 函数签名,也就是说函数接收的参数和返回的值类型要一样。
那么,满足这些类型签名的函数就组成了 monoid。看到这个公式你是不是觉得很眼熟没错它的概念就是基于我们之前说到过的 identity函数。在TypeScript中identity也是泛型使用中的一个例子。比如在C#和Java这样的语言中,泛型可以用来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。它的基本原理也是基于这样的一个identity函数。
function identity<T>(arg: T): T {
return arg;
}
identity在monad中有一个用处就是如果把identity作为一个参数可以起到观察inspect的作用。比如我们先用Just来封装 15 这个值然后调用chain的方法时把identity作为参数返回的就是一个flatten或unwrap展开的15。所以我们可以看出它也这里也起到了一个log的作用。
var A = Just( 15 );
A.chain (identity) // 返回 15
applicative
最后我们再来看应用函子applicative简称ap。
ap的作用其实也很简单。应用函子顾名思义它的作用是可以把一个封装过的函数应用到一个包装过的值上。
function Just(val) {
return { map, ap };
function map(fn) { return Just( fn( val ) ); }
function ap(anotherMonad) { return anotherMonad.map( val ); }
}
再来看一个例子可以看到ap把monad B里的值取出来通过monad A的映射把它应用到了monad A上。因为映射接受的值类型是函数所以这里我们传入的是柯里化的add函数它先通过闭包的记忆功能记住第一个参数6之后再加上传入的10最后输出的结果就是16。
var A = Just( 6 );
var B = Just( 10 );
function add(x,y) { return x + y; }
var C = A.map( curry( add ) ).ap( B );
C.chain(identity); // 返回 16
如果我们把上面几个功能加在一起,其大致实现就如下所示:
function Just(val) {
return { map, chain, ap, log };
// *********************
function map(fn) { return Just( fn( val ) ); }
// aka: bind, flatMap
function chain(fn) { return fn( val ); }
function ap(anotherMonad) { return anotherMonad.map( val ); }
function log() {
return `simpleMonad(${ val })`;
}
}
说到函子和应用函子我们也可以看一下在数组中有一个array.of的工厂方法它的作用是接收一组参数形成一个新数组。
var arr = Array.of(1,2,3,4,5); // 返回:[1,2,3,4,5]
在函数式编程中我们称实现了of工厂方法的函子是pointed函子。通过pointed函子我们可以把一组值放到了一个数组的容器中之后还可以通过映射函子对每个值做映射。而应用函子applicative functor就是实现了应用方法的pointed函子。
总结
今天这节课我们学习了函数式编程中针对数组的几个核心操作解答了上节课中的如何通过映射和过滤做到reduce的问题同时也更深入地理解了reducer和transduce的原理。
并且现在我们知道array.map其实就是一个functor它包含了map功能可以围绕一个数组中的每个值进行操作返回一个新的数组。而monad可以说是基于函子增加了一些特殊的功能。当然了不同的monad也可以相互组合比如just加上nothing也就是一个空值单子可以组成maybe monad来处理空值的异常。
另外说到了函子和单子在函数式编程当中其实还有either、IO之类的概念。其中either是用来代替比如if else或者是try catch的条件运算它的value里保存的是一个值而IO可以用来延迟函数的执行它的value里面存储的是一个函数。这里我就不多说了感兴趣的话你也可以去深入了解下。
思考题
从函数式编程的思维视角来看你觉得JavaScript中的promise算是monad吗
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。