编程|前端开发函数式编程入门( 二 )


学习函数式编程的方法和误区 如果在网上搜“如何学习函数式编程” , 十有八九会找到要学习函数式编程最好从学习Haskell开始的观点 。
然后很可能你就了解到那句著名的话”A monad is just a monoid in the category of endofunctors what's the problem?“ 。
翻译过来可能跟没翻译差不多:”一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已“ 。
别被这些术语吓到 , 就像React在纯函数式世界外给我们提供了useState useEffect这些Hooks , 就是帮我们解决产生副作用操作的工具 。 而函子Functor单子Monad也是这样的工具 , 或者可以认为是设计模式 。
Monad在Haskell中的重要性在于 , 对于IO这样虽然基础但是有副作用的操作 , 纯函数的Haskell是无法用函数式方法来处理掉的 , 所以需要借助IO Monad 。 大部分其它语言没有这么纯 , 可以用非函数式的方法来处理IO之类的副作用操作 , 所以上面那句话被笑称是Haskell用户群的接头暗号 。
有范畴论和类型论等知识做为背景 , 当然会有助于从更高层次理解函数式编程 。 但是对于大部分前端开发同学来讲 , 这笔技术债可以先欠着 , 先学会怎么写代码去使用可能是更好的办法 。 前端开发的计划比较短 , 较难有大块时间学习 , 但是我们可以迭代式的进步 , 最终是会殊途同归的 。
先把架式练好 , 用于代码中解决实际业务问题 , 比被困难吓住还停留在命令式的思想上还是要强的 。
函数式编程的精髓:无副作用
前端同学学习函数式编程的优势是React Hooks已经将副作用摆在我们面前了 , 不用再解释为什么要写无副用的代码了 。
无副作用的函数应该符合下面的特点:
【编程|前端开发函数式编程入门】
要有输入参数 。 如果没有输入参数 , 这个函数拿不到任意外部信息 , 也就不用运行了 。要有返回值 。 如果有输入没有返回值 , 又没有副作用 , 那么这个函数白调了 。对于确定的输入 , 有确定的输出 做到这一点 , 说简单也简单 , 只要保持功能足够简单就可以做到;说困难也困难 , 需要改变写惯了命令行代码的思路 。比如数学函数一般就是这样的好例子 , 比如我们写一个算平方的函数: let sqr2 = function(x){ return x * x; console.log(sqr2(200)); 无副作用函数拥有三个巨大的好处: 可以进行缓存 。 我们就可以采用动态规划的方法保存中间值 , 用来代替实际函数的执行结果 , 大大提升效率 。可以进行高并发 。 因为不依赖于环境 , 可以调度到另一个线程、worker甚至其它机器上 , 反正也没有环境依赖 。容易测试 , 容易证明正确性 。 不容易产生偶现问题 , 也跟环境无关 , 非常利于测试 。即使是跟有副作用的代码一起工作 , 我们也可以在副作用代码中缓存无副作用函数的值 , 可以将无副作用函数并发执行 。 测试时也可以更重点关注有副作用的代码以更有效地利用资源 。用函数的组合来代替命令的组合 会写无副作用的函数之后 , 我们要学习的新问题就是如何将这些函数组合起来 。比如上面的sqr2函数有个问题 , 如果不是number类型 , 计算就会出错 。 按照命令式的思路 , 我们可能就直接去修改sqr2的代码 , 比如改成这样: let sqr2 = function(x){ if (typeof x === 'number'){ return x * x; else{ return 0;但是 , sqr2的代码已经测好了 , 我们能不能不改它 , 只在它外面进行判断? 是的 , 我们可以这样写: let isNum = function(x){ if (typeof x === 'number'){ return x; else{ return 0; console.log(sqr2(isNum(\"20\"))); 或者是我们在设计sqr2的时候就先预留出来一个预处理函数的位置 , 将来要升级就换这个预处理函数 , 主体逻辑不变: let sqr2_v3 = function(fn x){ let y = fn(x); return y * y; console.log((sqr2_v3(isNum1.1))); 嫌每次都写isNum烦 , 可以定义个新函数 , 把isNum给写死进去: let sqr2_v4 = function(x){ return sqr2_v3(isNumx);console.log((sqr2_v4(2.2))); 用容器封装函数能力 现在 , 我们想重用这个isNum的能力 , 不光是给sqr2用 , 我们想给其它数学函数也增加这个能力 。比如 , 如果给Math.sin计算undefined会得到一个NaN: console.log(Math.sin(undefined)); 这时候我们需要用面向对象的思维了 , 将isNum的能力封装到一个类中: class MayBeNumber{ constructor(x){ this.x = x;map(fn){ return new MayBeNumber(fn(isNum(this.x)));getValue(){ return this.x;这样 , 我们不管拿到一个什么对象 , 用其构造一个MayBeNumber对象出来 , 再调用这个对象的map方法去调用数学函数 , 就自带了isNum的能力 。我们先看调用sqr2的例子: let num1 = new MayBeNumber(3.3).map(sqr2).getValue();console.log(num1);let notnum1 = new MayBeNumber(undefined).map(sqr2).getValue();console.log(notnum1); 我们可以将sqr2换成Math.sin: let notnum2 = new MayBeNumber(undefined).map(Math.sin).getValue();console.log(notnum2); 可以发现 , 输出值从NaN变成了0. 封装到对象中的另一个好处是我们可以用\".\"多次调用了 , 比如我们想调两次算4次方 , 只要在.map(sqr2)之后再来一个.map(sqr2) let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();console.log(num3); 使用对象封装之后的另一个好处是 , 函数嵌套调用跟命令式是相反的顺序 , 而用map则与命令式一致 。如果不理解的话我们来举个例子 , 比如我们想求sin(1)的平方 , 用函数调用应该先写后执行的sqr2 , 后写先执行的Math.sin: console.log(sqr2(Math.sin(1))); 而调用map就跟命令式一样了: let num4 = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();console.log(num4); 用 of 来封装 new 封装到对象中 , 看起来还不错 , 但是函数式编程还搞出来new对象再map , 为什么不能构造对象时也用个函数呢? 这好办 , 我们给它定义个of方法吧: MayBeNumber.of = function(x){ return new MayBeNumber(x); 下面我们就可以用of来构造MayBeNumber对象啦: let num5 = MayBeNumber.of(1).map(Math.cos).getValue();console.log(num5);let num6 = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();console.log(num6); 有了of之后 , 我们也可以给map函数升升级 。之前的isNum有个问题 , 如果是非数字的话 , 其实没必要赋给个0再去调用函数 , 直接返回个0就好了 。之前我们一直没写过箭头函数 , 顺手写一写: isNum2 = x =typeof x === 'number'; map用isNum2和of改写下: map(fn){ if (isNum2(this.x)){ return MayBeNumber.of(fn(this.x)); else{ return MayBeNumber.of(0);我们再来看下另一种情况 , 我们处理返回值的时候 , 如果有Error , 就不处理Ok的返回值 , 可以这么写: class Result{ constructor(Ok Err){ this.Ok = Ok; this.Err = Err;isOk(){ return this.Err === null || this.Err === undefined;map(fn){ return this.isOk() ? Result.of(fn(this.Ok)this.Err) : Result.of(this.Ok fn(this.Err)); Result.of = function(Ok Err){ return new Result(Ok Err);console.log(Result.of(1.2undefined).map(sqr2)); 输出结果为: Result { Ok: 1.44 Err: undefined我们来总结下前面这种容器的设计模式: 有一个用于存储值的容器 这个容器提供一个map函数 , 作用是map函数使其调用的函数可以跟容器中的值进行计算 , 最终返回的还是容器的对象 我们可以把这个设计模式叫做Functor函子 。如果这个容器还提供一个of函数将值转换成容器 , 那么它叫做Pointed Functor. 比如我们看下js中的Array类型: let aa1 = Array.of(1);console.log(aa1);console.log(aa1.map(Math.sin)); 它支持of函数 , 它还支持map函数调用Math.sin对Array中的值进行计算 , map的结果仍然是一个Array 。那么我们可以说 , Array是一个Pointed Functor. 简化对象层级 有了上面的Result结构了之后 , 我们的函数也跟着一起升级 。 如果是数值的话 , Ok是数值 , Err是undefined 。 如果非数值的话 , Ok是undefined , Err是0: let sqr2_Result = function(x){ if (isNum2(x)){ return Result.of(x*x undefined); else{ return Result.of(undefined0);我们调用这个新的sqr2_Result函数: console.log(Result.of(4.3undefined).map(sqr2_Result)); 返回的是一个嵌套的结果: Result { Ok: Result { Ok: 18.49 Err: undefinedErr: undefined我们需要给Result对象新加一个join函数 , 用来获取子Result的值给父Result: join(){ if (this.isOk()) { return this.Ok; else{ return this.Err;我们调用的时候最后加上调用这个join: console.log(Result.of(4.5undefined).map(sqr2_Result).join()); 嵌套的结果变成了一层的: Result { Ok: 20.25 Err: undefined每次调用map(fn).join()两个写起来麻烦 , 我们定义一个flatMap函数一次性处理掉: flatMap(fn){ return this.map(fn).join();调用方法如下: console.log(Result.of(4.7undefined).flatMap(sqr2_Result)); 结果如下: Result { Ok: 22.090000000000003 Err: undefined我们最后完整回顾下这个Result: class Result{ constructor(Ok Err){ this.Ok = Ok; this.Err = Err;isOk(){ return this.Err === null || this.Err === undefined;map(fn){ return this.isOk() ? Result.of(fn(this.Ok)this.Err) : Result.of(this.Ok fn(this.Err));join(){ if (this.isOk()) { return this.Ok; else{ return this.Err;flatMap(fn){ return this.map(fn).join(); Result.of = function(Ok Err){ return new Result(Ok Err); 不严格地讲 , 像Result这种实现了flatMap功能的Pointed Functor , 就是传说中的Monad. 偏函数和高阶函数 在前面各种函数式编程模式中对函数的用法熟悉了之后 , 回来我们总结下函数式编程与命令行编程体感上的最大区别: 函数是一等公式 , 我们应该熟悉变量中保存函数再对其进行调用 函数可以出现在返回值里 , 最重要的用法就是把输入是n(n2)个参数的函数转换成n个1个参数的串联调用 , 这就是传说中的柯里化 。 这种减少了参数的新函数 , 我们称之为偏函数 函数可以用做函数的参数 , 这样的函数称为高阶函数 偏函数可以当作是更灵活的参数默认值 。比如我们有个结构叫spm , 由spm_a和spm_b组成 。 但是一个模块中spm_a是固定的 , 大部分时候只需要指定spm_b就可以了 , 我们就可以写一个偏函数: const getSpm = function(spm_a spm_b){ return [spm_a spm_b