JavaScript 函数式编程(三)

1,225 阅读9分钟

这是完结篇了,前两篇文章在这里:

JavaScript函数式编程(一)

JavaScript函数式编程(二)

在第二篇文章里,我们介绍了 MaybeEitherIO 等几种常见的 Functor,或许很多看完第二篇文章的人都会有疑惑:

『这些东西有什么卵用?』

事实上,如果只是为了学习编写函数式、副作用小的代码的话,看完第一篇文章就足够了。第二篇文章和这里的第三篇着重于的是一些函数式理论的实践,是的,这些很难(但并非不可能)应用到实际的生产中,因为很多轮子都已经造好了并且很好用了。比如现在在前端大规模使用的 Promise 这种异步调用规范,其实就是一种 Monad(等下会讲到);现在日趋成熟的 Redux 作为一种 FLUX 的变种实现,核心理念也是状态机和函数式编程。

一、Monad

关于 Monad 的介绍和教程在网络上已经层出不穷了,很多文章都写得比我下面的更好,所以我在这里只是用一种更简单易懂的方式介绍 Monad,当然简单易懂带来的坏处就是不严谨,所以见谅/w\

如果你对 Promise 这种规范有了解的话,应该记得 Promise 里一个很惊艳的特性:

doSomething()
    .then(result => {
        // 你可以return一个Promise链!
        return fetch('url').then(result => parseBody(result));
    })
    .then(result => {
        // 这里的result是上面那个Promise的终值
    })
    
doSomething()
    .then(result => {
        // 也可以直接return一个具体的值!
        return 123;
    })
    .then(result => {
        // result === 123
    })

对于 Promise 的一个回调函数来说,它既可以直接返回一个值,也可以返回一个新的 Promise,但对于他们后续的回调函数来说,这二者都是等价的,这就很巧妙地解决了 nodejs 里被诟病已久的嵌套地狱。

事实上,Promise 就是一种 Monad,是的,可能你天天要写一大堆 Promise,可直到现在才知道天天用的这个东西竟然是个听起来很高大上的函数式概念。

下面我们来实际实现一个 Monad,如果你不想看的话,只要记住 『Promise 就是一种 Monad』 这句话然后直接跳过这一章就好了。

我们来写一个函数 cat,这个函数的作用和 Linux 命令行下的 cat 一样,读取一个文件,然后打出这个文件的内容,这里 IO 的实现请参考上一篇文章:

import fs from 'fs';
import _ from 'lodash';

var map = _.curry((f, x) => x.map(f));
var compose = _.flowRight;

var readFile = function(filename) {
    return new IO(_ => fs.readFileSync(filename, 'utf-8'));
};

var print = function(x) {
    return new IO(_ => {
        console.log(x);
        return x;
    });
}

var cat = compose(map(print), readFile);

cat("file")
//=> IO(IO("file的内容"))

由于这里涉及到两个 IO:读取文件和打印,所以最后结果就是我们得到了两层 IO,想要运行它,只能调用:

cat("file").__value().__value();
//=> 读取文件并打印到控制台

很尴尬对吧,如果我们涉及到 100 个 IO 操作,那么难道要连续写 100 个 __value() 吗?

当然不能这样不优雅,我们来实现一个 join 方法,它的作用就是剥开一层 Functor,把里面的东西暴露给我们:

var join = x => x.join();
IO.prototype.join = function() {
  return this.__value ? IO.of(null) : this.__value();
}

// 试试看
var foo = IO.of(IO.of('123'));

foo.join();
//=> IO('123')

有了 join 方法之后,就稍微优雅那么一点儿了:

var cat = compose(join, map(print), readFile);
cat("file").__value();
//=> 读取文件并打印到控制台

join 方法可以把 Functor 拍平(flatten),我们一般把具有这种能力的 Functor 称之为 Monad。

这里只是非常简单地移除了一层 Functor 的包装,但作为优雅的程序员,我们不可能总是在 map 之后手动调用 join 来剥离多余的包装,否则代码会长得像这样:

var doSomething = compose(join, map(f), join, map(g), join, map(h));

所以我们需要一个叫 chain 的方法来实现我们期望的链式调用,它会在调用 map 之后自动调用 join 来去除多余的包装,这也是 Monad 的一大特性:

var chain = _.curry((f, functor) => functor.chain(f));
IO.prototype.chain = function(f) {
  return this.map(f).join();
}

// 现在可以这样调用了
var doSomething = compose(chain(f), chain(g), chain(h));

// 当然,也可以这样
someMonad.chain(f).chain(g).chain(h)

// 写成这样是不是很熟悉呢?
readFile('file')
    .chain(x => new IO(_ => {
        console.log(x);
        return x;
    }))
    .chain(x => new IO(_ => {
        // 对x做一些事情,然后返回
    }))

哈哈,你可能看出来了,chain 不就类似 Promise 中的 then 吗?是的,它们行为上确实是一致的(then 会稍微多一些逻辑,它会记录嵌套的层数以及区别 Promise 和普通返回值),Promise 也确实是一种函数式的思想。

(我本来想在下面用 Promise 为例写一些例子,但估计能看到这里的人应该都能熟练地写各种 Promise 链了,所以就不写了0w0)

总之就是,Monad 让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其它异步任务。


二、函数式编程的应用

好了,关于函数式编程的一些基础理论的介绍就到此为止了,如果想了解更多的话其实建议去学习 Haskell 或者 Lisp 这样比较正统的函数式语言。下面我们来回答一个问题:函数式编程在实际应用中到底有啥用咧?

1、React

React 现在已经随处可见了,要问它为什么流行,可能有人会说它『性能好』、『酷炫』、『第三方组件丰富』、『新颖』等等,但这些都不是最关键的,最关键是 React 给前端开发带来了全新的理念:函数式和状态机。

我们来看看 React 怎么写一个『纯组件』吧:

var Text = props => (
    
{props.text}
)

咦这不就是纯函数吗?对于任意的 text 输入,都会产生唯一的固定输出,只不过这个输出是一个 virtual DOM 的元素罢了。配合状态机,就大大简化了前端开发的复杂度:

state => virtual DOM => 真实 DOM

在 Redux 中更是可以把核心逻辑抽象成一个纯函数 reducer:

reducer(currentState, action) => newState

关于 React+Redux(或者其它FLUX架构)就不在这里介绍太多了,有兴趣的可以参考相关的教程。

2、Rxjs

Rxjs 从诞生以来一直都不温不火,但它函数响应式编程(Functional Reactive Programming,FRP)的理念非常先进,虽然或许对于大部分应用环境来说,外部输入事件并不是太频繁,并不需要引入一个如此庞大的 FRP 体系,但我们也可以了解一下它有哪些优秀的特性。

在 Rxjs 中,所有的外部输入(用户输入、网络请求等等)都被视作一种 『事件流』:

--- 用户点击了按钮 --> 网络请求成功 --> 用户键盘输入 --> 某个定时事件发生 --> ......

举个最简单的例子,下面这段代码会监听点击事件,每 2 次点击事件产生一次事件响应:

var clicks = Rx.Observable
    .fromEvent(document, 'click')
    .bufferCount(2)
    .subscribe(x => console.log(x)); // 打印出前2次点击事件

其中 bufferCount 对于事件流的作用是这样的:

是不是很神奇呢?Rxjs 非常适合游戏、编辑器这种外部输入极多的应用,比如有的游戏可能有『搓大招』这个功能,即监听用户一系列连续的键盘、鼠标输入,比如上上下下左右左右BABA,不用事件流的思想的话,实现会非常困难且不优雅,但用 Rxjs 的话,就只是维护一个定长队列的问题而已:

var inputs = [];
var clicks = Rx.Observable
    .fromEvent(document, 'keydown')
    .scan((acc, cur) => {
        acc.push(cur.keyCode);
        var start = acc.length - 12 < 0 ? 0 : acc.length - 12;
        return acc.slice(start);
    }, inputs)
    .filter(x => x.join(',') == [38, 38, 40, 40, 37, 39, 37, 39, 66, 65, 66, 65].join(','))// 上上下下左右左右BABA,这里用了比较奇技淫巧的数组对比方法
    .subscribe(x => console.log('!!!!!!ACE!!!!!!'));

当然,Rxjs 的作用远不止于此,但可以从这个范例里看出函数响应式编程的一些优良的特性。

3、Cycle.js

Cycle.js 是一个基于 Rxjs 的框架,它是一个彻彻底底的 FRP 理念的框架,和 React 一样支持 virtual DOM、JSX 语法,但现在似乎还没有看到大型的应用经验。

本质的讲,它就是在 Rxjs 的基础上加入了对 virtual DOM、容器和组件的支持,比如下面就是一个简单的『开关』按钮:

import xs from 'xstream';
import {run} from '@cycle/xstream-run';
import {makeDOMDriver} from '@cycle/dom';
import {html} from 'snabbdom-jsx';

function main(sources) {
  const sinks = {
    DOM: sources.DOM.select('input').events('click')
      .map(ev => ev.target.checked)
      .startWith(false)
      .map(toggled =>
        
Toggle me

{toggled ? 'ON' : 'off'}

) }; return sinks; } const drivers = { DOM: makeDOMDriver('#app') }; run(main, drivers);

当然,Cycle.js 这种『侵入式』的框架适用性不是太广,因为使用它就意味着应用中必须全部或者大部分都要围绕它的理念设计,这对于大规模应用来说反而是负担。

三、总结

既然是完结篇,那我们来总结一下这三篇文章究竟讲了些啥?

第一篇文章里,介绍了纯函数、柯里化、Point Free、声明式代码和命令式代码的区别,你可能忘记得差不多了,但只要记住『函数对于外部状态的依赖是造成系统复杂性大大提高的主要原因』以及『让函数尽可能地纯净』就行了。

第二篇文章,或许是最没有也或许是最有干货的一篇,里面介绍了『容器』的概念和 MaybeEitherIO 这三个强大的 Functor。是的,大多数人或许都没有机会在生产环境中自己去实现这样的玩具级 Functor,但通过了解它们的特性会让你产生对于函数式编程的意识。

软件工程上讲『没有银弹』,函数式编程同样也不是万能的,它与烂大街的 OOP 一样,只是一种编程范式而已。很多实际应用中是很难用函数式去表达的,选择 OOP 亦或是其它编程范式或许会更简单。但我们要注意到函数式编程的核心理念,如果说 OOP 降低复杂度是靠良好的封装、继承、多态以及接口定义的话,那么函数式编程就是通过纯函数以及它们的组合、柯里化、Functor 等技术来降低系统复杂度,而 React、Rxjs、Cycle.js 正是这种理念的代言人,这可能是大势所趋,也或许是昙花一现,但不妨碍我们去多掌握一种编程范式嘛0w0