Warcry mpi
  1. 1 Warcry mpi
  2. 2 Life Will Change Lyn
  3. 3 Flower Of Life 发热巫女
  4. 4 The Night We Stood Lyn
  5. 5 One Last You Jen Bird
  6. 6 Time Bomb Veela
  7. 7 Libertus Chen-U
  8. 8 Last Surprise Lyn
  9. 9 Quiet Storm Lyn
  10. 10 かかってこいよ NakamuraEmi
  11. 11 Hypocrite Nush
2018-12-02 18:38:49

函数式编程之我见——Pointfree

受React Hooks的启发,我最近愈发的感觉到函数式编程的思想可能在前端会越来越重要,毕竟小到数组的map映射,Promise中的Monad思想,大到的React和RxJS的核心理念,函数式编程思想其实已经侵入到日常编程中太久,只是使用者没有明显感知,我认为和面向对象编程同为编程范式,两者也绝对不是对立关系,关键是学习到两者思想上的精髓,在日常编码组织上带来新的思路,所以从今天起打算学习一下相关知识。

从实例入手

以往零零散散搜索过一些函数式编程知识,不过大多数文章都从一些专业概念起手,对我来说过于抽象,看不见应用场景就难以理解这些概念的好处。所以第一篇文章也不从各种繁杂的概念入手,就从最直观的能看得见的场景——Pointfree风格编码开始学习函数式编程思想。

先不讲具体概念,从一个需求入手。我们现在要实现一个“从一个数组中选择出平方为偶数的值并将其平方求和”的需求,使用常规编程方法可以很迅速的实现这样一个函数。

function calculate(arr) {
    return arr.map(item => item * item).filter(item => item % 2).reduce((a, b) => a + b);
}

可能半分钟都不到,搞定收工。不过身为程序员,一定要有应对需求变更的意识,现在可能是选出平方后偶数,明天可能就是平方后奇数了,我们总是要寻找能够多次复用的点,以便下次需求变更时能尽可能少改代码。可以看到,这个需求可以拆解为3个子流程,映射为平方,过滤出平方为偶数的值,再求和,那么我们就可以拆解为3个可以复用的函数。

function mapSquare(arr) {
    return arr.map(item => item * item);
}

function filterOdd(arr) {
    return arr.filter(item => item % 2);
}

function sum(arr) {
    return arr.reduce((a, b) => a + b);
}

function calculate(arr) {
    let result1 = mapSquare(arr);
    let result2 = filterOdd(result1);
    return sum(result2);
}

好了,现在我们已经拆分出子流程函数了,如果有需求变动,我们只要更改对应变更的子流程函数即可。实际上现实中大多数场景我们也都是这么做的,当然现实需求中的流程函数不会像这个例子这样简单,但本质都是一样的。当一个函数满足不了需求时,尽可能的拆分出可复用的部分再组合成总函数。

改进

现在我们虽然拆成了三个子流程,但需求的变动可能更加复杂,如果需要再增加一个流程,或者说打乱流程的顺序,甚至说流程的各种排列组合的情况都需要,那么在现在这种结构下,我们不得不写出一个又一个的新的总流程函数。既然子流程函数已经确定了,如果能有一个中间函数,帮我们自动按给定需求组合这些子流程函数就好了。

函数组合

好在受益于JS中函数作为一等公民的特性,我们完全可以实现一个工具组合函数,将传入的所有函数依次执行,并且将返回值作为下一个函数所需的参数。就像拼积木一样。实际上underscore中就提供了compose函数的实现方法。

function compose(...functions) {
    let start = functions.length - 1;
    return function(...args) {
        let i = start;
        let result = args[start].apply(this, args);
        while (i--) {
            result = args[i].call(this, result);
        }
        return result;
    }
}

一目了然,其实Redux的中间件组合的实现原理上也是完全相同的,写法利用了数组的reduce方法,更加简洁。

function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

利用这个compose工具函数改进一下上文中的总流程函数。

const calculate = compose(sum, filterOdd, mapSquare);

当有新的子流程插入,或者要调整子流程顺序时,我们只需要调整传参数量和顺序就可以了。

柯里化

仔细观察以上三个流程函数的话,做的事可以概括为求和,过滤奇数,映射为平方。那么如果有新的需求需要过滤偶数,求积或者映射为立方,有必要再写一个新的流程函数吗?最佳的做法当然是从原流程函数中演变而来。可以改写为如下形式,自定义映射,累加或者过滤规则作为参数传入。

function map(arr, fn) {
    return arr.map(fn);
}

function filter(arr, fn) {
    return arr.filter(fn);
}

function reduce(arr, fn) {
    return arr.reduce(fn);
}

这样改写后虽然更灵活了,但会遇到一个新的问题。那就是函数组合的子流程函数的特点是入参只能限制在一个,毕竟函数的返回值会成为下一个函数的入参,而函数的返回值不可能有多个。需要注意到的是,这一套流程中,入参是arr,经过一系列fn的子流程操作后,仍然返回arr。那么我们应该可以先传入fn生成一个设置好fn的函数,只需要把这个函数加入compose流程中,等待arr的到来再处理即可。也就是说可以实现为如下高阶函数。

const map = fn => arr => arr.map(fn);
const filter = fn => arr => arr.filter(fn);
const reduce = fn => arr => arr.reduce(fn);

再用这些高阶函数实现最开始的需求。

const mapSquare = map(item => item * item);
const filterOdd = filter(item => item % 2);
const sum = reduce((a, b) => a + b);
const calculate = compose(sum, filterOdd, mapSquare);

这种高阶函数都有一个共性,就是将原函数改造为可以分步调用的形式,只传入一部分参数去调用,当参数不完整时不返回结果,而是返回一个新的函数接受剩下的参数,直到传入参数完整时才返回真正的结果,这就是柯里化。其实现的延迟调用效果正是compose所需要的,我们把要真正操作的参数放到最后一个参数上,预加载其他参数,就可以用compose组合绝大部分流程函数。当然每次都将流程函数柯里化的过程也是很繁琐的,同样可以用一个工具函数来完成。

function curry(fn) {  
    return function curried(...args) {
        return args.length >= fn.length ?
            fn.call(this, ...args) :
            (...rest) => {
            return curried.call(this, ...args, ...rest);
        };
    };
}

现在再来回顾两版总流程函数的实现,看看有什么不同。

// 第一版
function calculate(arr) {
    let result1 = mapSquare(arr);
    let result2 = filterOdd(result1);
    return sum(result2);
}
// 第二版
const calculate = compose(sum, filterOdd, mapSquare);

第一版在各个子流程处理数据的过程中,必须将数据赋予到arr这个临时变量才能进行后续操作,而改进后的第二版只是专注于对子流程的合成运算,不关心所要处理的值。这种编码风格其实也就是这篇文章的标题——Pointfree。意思就是说函数无须提及自己将要操作的数据,只合成运算过程。

小结

通过该模式,能够帮助我们减少大量对数据的不必要命名,因为当一个数据需要经过大量流程得到最终结果时,理论上中间的一些为了传递给子流程的中转变量都是不需要的。这种模式可以让流程代码更简洁,更容易复用和测试,不过也需要注意,并非所有代码都是适合pointfree的,水平有限,我现在的感受就是能明显感觉到副作用小,流程多,边界明显的一些需求适合用pointfree的风格实现。简单来说,这种风格编码算是函数式编程带来的一种赠品,因为一等公民的函数、柯里化以及组合协作起来就很容易实现这种模式,这些特性也是函数式编程的关键特性,所以通过pointfree来引出函数式编程的思想也很合适,但在日常使用中也不要强求,还是重点在于理解这种将数据和运算隔离,专注于处理子运算组合的复用思想。

-- EOF --

添加在分类「 前端开发 」下,并被添加 「JavaScript」「Functional Programming」 标签。