JavaScript 轻量级函数式编程

第 10 章:异步的函数式

阅读到这里,你已经学习了我所说的所有轻量级函数式编程的基础概念,在本章节中,我们将把这些概念应有到不同的情景当中,但绝对不会有新的知识点。

到目前为止,我们所说的一切都是同步的,意味着我们调用函数,传入参数后马上就会得到返回值。大部分的情况下是没问题的,但这几乎满足不了现有的 JS 应用。为了能在当前的 JS 环境里使用上函数式编程,我们需要去了解异步的函数式编程。

本章的目的是拓展我们对用函数式编程管理数据的思维,以便之后我们在更多的业务上应用。

时间状态

在你所有的应用里,最复杂的状态就是时间。当你操作的数据状态改变过程比较直观的时候,是很容易管理的。但是,如果状态随着时间因为响应事件而隐晦的变化,管理这些状态的难度将会成几何级增长。

我们在本文中介绍的函数式编程可以让代码变得更可读,从而增强了可靠性和可预见性。但是当你添加异步操作到你的项目里的时候,这些优势将会大打折扣。

必须明确的一点是:并不是说一些操作不能用同步来完成,或者触发异步行为很容易。协调那些可能会改变应用程序的状态的响应,这需要大量额外的工作。

所以,作为作者的你最好付出一些努力,或者只是留给阅读你代码的人一个难题,去弄清楚如果 A 在 B 之前完成,项目中状态是什么,还有相反的情况是什么?这是一个浮夸的问题,但以我的观点来看,这有一个确切的答案:如果可以把复杂的代码变得更容易理解,作者就必须花费更多心思。

减少时间状态

异步编程最为重要的一点是通过抽象时间来简化状态变化的管理。

为说明这一点,让我们先来看下一种有竞争状态(又称,时间复杂度)的糟糕情况,且必须手动去管理里面的状态:

var customerId = 42;
var customer;

lookupCustomer( customerId, function onCustomer(customerRecord){
    var orders = customer ? customer.orders : null;
    customer = customerRecord;
    if (orders) {
        customer.orders = orders;
    }
} );

lookupOrders( customerId, function onOrders(customerOrders){
    if (!customer) {
        customer = {};
    }
    customer.orders = customerOrders;
} );

回调函数 onCustomer(..)onOrders(..) 之间是互为竞争关系。假设他们都在运行,两者都有可能先运行,那将无法预测到会发生什么。

如果我们可以把 lookupOrders(..) 写到 onCustomer(..) 里面,那我们就可以确认 onOrders(..) 会在 onCustomer(..) 之后运行,但我们不能这么做,因为我们需要让 2 个查询同时执行。

所以,为了让这个基于时间的复杂状态正常化,我们用相应的 if-声明在各自的回调函数里来检查外部作用域的变量 customer。当各自的回调函数被执行,将会去检测 customer 的状态,从而确定各自的执行顺序,如果 customer 在回调函数里还没被定义,那他就是先运行的,否则则是第二个运行的。

这些代码可以运行,但是他违背了可读性的原则。时间复杂度让这个代码变得难以阅读。

让我们改用 JS promise 来把时间因素抽离出来:

var customerId = 42;

var customerPromise = lookupCustomer( customerId );
var ordersPromise = lookupOrders( customerId );

customerPromise.then( function onCustomer(customer){
    ordersPromise.then( function onOrders(orders){
        customer.orders = orders;
    } );
} );

现在 onOrders(..) 回调函数存在 onCustomer(..) 回调函数里,所以他们各自的执行顺序是可以保证的。在各自的 then(..) 运行之前 lookupCustomer(..)lookupOrders(..) 被分别的调用,两个查询就已经并行的执行完了。

这可能不太明显,但是这个代码里还有其他内在的竞争状态,那就是 promise 的定义没有被体现出来。如果 orders 的查询在把 onOrders(..) 回调函数被 ordersPromise.then(..) 调用前完成,那么就需要一些比较智能的 东西 来保存 orders 直到 onOrders(..) 能被调用。 同理,record (或者说customer)对象是否能在 onCustomer(..) 执行时被接收到。

这里的 东西 和我们之前讨论过的时间复杂度类似。但我们不必去担心这些复杂性,无论是编码或者是读(更为重要)这些代码的时候,因为对我们来说,promise 所处理的就是时间复杂度上的问题。

promise 以时间无关的方式来作为一个单一的值。此外,获取 promise 的返回值是异步的,但却是通过同步的方法来赋值。或者说, promise 给 = 操作符扩展随时间动态赋值的功能,通过可靠的(时间无关)方式。

接下来我们将探索如何以相同的方式,在时间上异步地拓展本书之前同步的函数式编程操作。

积极的 vs 惰性的

积极的和惰性的在计算机科学的领域并不是表扬或者批评的意思,而是描述一个操作是否立即执行或者是延时执行。

我们在本例子中看到的函数式编程操作可以被称为积极的,因为它们同步(即时)地操作着离散的即时值或值的列表/结构上的值。

回忆下:

var a = [1,2,3]

var b = a.map( v => v * 2 );

b;            // [2,4,6]

这里 ab 的映射就是积极的,因为它在执行的那一刻映射了数组 a 里的所有的值,然后生成了一个新的数组 b 。即使之后你去修改 a ,比如说添加一个新的值到数组的最后一位,也不会影响到 b 的内容。这就是积极的函数式编程。

但是如果是一个惰性的函数式编程操作呢?思考如下情况:

var a = [];

var b = mapLazy( a, v => v * 2 );

a.push( 1 );

a[0];        // 1
b[0];        // 2

a.push( 2 );

a[1];        // 2
b[1];        // 4

我们可以想象下 mapLazy(..) 本质上 “监听” 了数组 a,只要一个新的值添加到数组的末端(使用 push(..)),它都会运行映射函数 v => v * 2 并把改变后的值添加到数组 b 里。

注意: mapLazy(..) 的实现没有被写出来,是因为它是虚构的方法,是不存在的。如果要实现 ab 之间的惰性的操作,那么简单的数组就需要变得更加聪明。

考虑下把 ab 关联到一起的好处,无论何时何地,你添加一个值进 a 里,它都将改变且映射到 b 里。它比同为声明式函数式编程的 map(..) 更强大,但现在它可以随时地变化,进行映射时你不用知道 a 里面所有的值。

响应式函数式编程

为了理解如何在2个值之间创建和使用惰性的映射,我们需要去抽象我们对列表(数组)的想法。

让我们来想象一个智能的数组,不只是简单地获得值,还是一个懒惰地接受和响应(也就是“反应”)值的数组。考虑下:

var a = new LazyArray();

var b = a.map( function double(v){
    return v * 2;
} );

setInterval( function everySecond(){
    a.push( Math.random() );
}, 1000 );

至此,这段代码的数组和普通的没有什么区别。唯一不同的是在我们执行 map(..) 来映射数组 a 生成数组 b 之后,定时器在 a 里面添加随机的值。

但是这个虚构的 LazyArray 有点不同,它假设了值可以随时的一个一个添加进去。就像随时可以 push(..) 你想要的值一样。可以说 b 就是一个惰性映射 a 最终值的数组。

此外,当 a 或者 b 改变时,我们不需要确切地保存里面的值,这个特殊的数组将会保存它所需的值。所以这些数组不会随着时间而占用更多的内存,这是 惰性数据结构和懒操作的重要特点。事实上,它看起来不像数组,更像是buffer(缓冲区)。

普通的数组是积极的,所以它会立马保存所有它的值。"惰性数组" 的值则会延迟保存。

由于我们不一定要知道 a 什么时候添加了新的值,所以另一个关键就是我们需要有去监听 b 并在有新值的时候通知它的能力。我们可以想象下监听器是这样的:

b.listen( function onValue(v){
    console.log( v );
} );

b 是反应性的,因为它被设置为当 a 有值添加时进行反应。函数式编程操作当中的 map(..) 是把数据源 a 里面的所有值转移到目标 b 里。每次映射操作都是我们使用同步函数式编程进行单值建模的过程,但是接下来我们将让这种操作变得可以响应式执行。

注意: 最常用到这些函数式编程的是响应式函数式编程(FRP)。我故意避开这个术语是因为一个有关于 FP + Reactive 是否真的构成 FRP 的辩论。我们不会全面深入了解 FRP 的所有含义,所以我会继续称之为响应式函数式编程。或者,如果你不会感觉那么困惑,也可以称之为事件机制函数式编程。

我们可以认为 a 是生成值的而 b 则是去消费这些值的。所以为了可读性,我们得重新整理下这段代码,让问题归结于 生产者消费者

// 生产者:

var a = new LazyArray();

setInterval( function everySecond(){
    a.push( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = a.map( function double(v){
    return v * 2;
} );

b.listen( function onValue(v){
    console.log( v );
} );

a 是一个行为本质上很像数据流的生产者。我们可以把每个值赋给 a 当作一个事件map(..) 操作会触发 b 上面的 listen(..) 事件来消费新的值。

我们分离 生产者消费者 的相关代码,是因为我们的代码应该各司其职。这样的代码组织可以很大程度上提高代码的可读性和维护性。

声明式的时间

我们应该非常谨慎地讨论如何介绍时间状态。具体来说,正如 promise 从单个异步操作中抽离出我们所担心的时间状态,响应式函数式编程从一系列的值/操作中抽离(分割)了时间状态。

a (生产者)的角度来说,唯一与时间相关的就是我们手动调用的 setInterval(..) 循环。但它只是为了示范。

想象下 a 可以被绑定上一些其他的事件源,比如说用户的鼠标点击事件和键盘按键事件,服务端来的 websocket 消息等。在这些情况下,a 没必要关注自己的时间状态。每当值准备好,它就只是一个与值连接的无时态管道。

b (消费者)的角度来说,我们不用知道或者关注 a 里面的值在何时何地来的。事实上,所有的值都已经存在。我们只关注是否无论何时都能取到那些值。或者说,map(..) 的转换操作是一个无时态(惰性)的建模过程。

时间ab 之间的关系是声明式的,不是命令式的。

以 operations-over-time 这种方式来组织值可能不是很有效。让我们来对比下相同的功能如何用命令式来表示:

// 生产者:

var a = {
    onValue(v){
        b.onValue( v );
    }
};

setInterval( function everySecond(){
    a.onValue( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = {
    map(v){
        return v * 2;
    },
    onValue(v){
        v = this.map( v );
        console.log( v );
    }
};

这似乎很微妙,但这就是存在于命令式版本的代码和之前声明式的版本之间一个很重要的不同点,除了 b.onValue(..) 需要自己去调用 this.map(..) 之外。在之前的代码中, ba 当中去拉取,但是在这个代码中,a 推送给 b。换句话说,把 b = a.map(..) 替换成 b.onValue(v)

在上面的命令式代码中,以消费者的角度来说它并不清楚 v 从哪里来。此外命令式强硬的把代码 b.onValue(..) 夹杂在生产者 a 的逻辑里,这有点违反了关注点分离原则。这将会让分离生产者和消费者变得困难。

相比之下,在之前的代码中,b = a.map(..) 表示了 b 的值来源于 a ,对于如同抽象事件流的数据源 a,我们不需要关心。我们可以 确信 任何来自于 ab 里的值都会通过 map(..) 操作。

映射之外的东西

为了方便,我们已经说明了通过随着时间一次一次的用 map(..) 来绑定 ab 的概念。其实我们许多其他的函数式编程操作也可以做到这种效果。

思考下:

var b = a.filter( function isOdd(v) {
    return v % 2 == 1;
} );

b.listen( function onlyOdds(v){
    console.log( "Odd:", v );
} );

这里可以看到 a 的值肯定会通过 isOdd(..) 赋值给 b

即使是 reduce(..) 也可以持续的运行:

var b = a.reduce( function sum(total,v){
    return total + v;
} );

b.listen( function runningTotal(v){
    console.log( "New current total:", v );
} );

因为我们调用 reduce(..) 是没有给具体 initialValue 的值,无论是 sum(..) 或者 runningTotal(..) 都会等到有 2 个来自 a 的参数时才会被调用。

这段代码暗示了在 reduction 里面有一个 内存空间, 每当有新的值进来的时候,sum(..) 才会带上第一个参数 total 和第二个参数 v被调用。

其他的函数式编程操作会在内部作用域请求一个缓存区,比如说 unique(..) 可以追踪每一个它访问过的值。

Observables

希望现在你可以察觉到响应式,事件式,类数组结构的数据的重要性,就像我们虚构出来的 LazyArray 一样。值得高兴的是,这类的数据结构已经存在的了,它就叫 observable。

注意: 只是做些假设(希望):接下来的讨论只是简要的介绍 observables。这是一个需要我们花时间去探究的深层次话题。但是如果你理解本文中的轻量级函数式编程,并且知道如何通过函数式编程的原理来构建异步的话,那么接着学习 observables 将会变得得心应手。

现在已经有各种各样的 Observables 的库类, 最出名的是 RxJS 和 Most。在写这篇文章的时候,正好有一个直接向 JS 里添加 observables 的建议,就像 promise。为了演示,我们将用 RxJS 风格的 Observables 来完成下面的例子。

这是我们一个较早的响应式的例子,但是用 Observables 来代替 LazyArray

// 生产者:

var a = new Rx.Subject();

setInterval( function everySecond(){
    a.next( Math.random() );
}, 1000 );


// **************************
// 消费者:

var b = a.map( function double(v){
    return v * 2;
} );

b.subscribe( function onValue(v){
    console.log( v );
} );

在 RxJS 中,一个 Observer 订阅一个 Observable。如果你把 Observer 和 Observable 的功能结合到一起,那就会得到一个 Subject。因此,为了保持代码的简洁,我们把 a 构建成一个 Subject,所以我们可以调用它的 next(..) 方法来添加值(事件)到他的数据流里。

如果我们要让 Observer 和 Observable 保持分离:

// 生产者:

var a = Rx.Observable.create( function onObserve(observer){
    setInterval( function everySecond(){
        observer.next( Math.random() );
    }, 1000 );
} );

在这个代码里,a 是 Observable,毫无疑问,observer 就是独立的 observer,它可以去“观察”一些事件(比如我们的setInterval(..)循环),然后我们使用它的 next(..) 方法来发送一些事件到 observable a 的流里。

除了 map(..),RxJS 还定义了超过 100 个可以在有新值添加时才触发的方法。就像数组一样。每个 Observable 的方法都会返回一个新的 Observable,意味着他们是链式的。如果一个方法被调用,则它的返回值应该由输入的 Observable 去返回,然后触发到输出的 Observable里,否则抛弃。

一个链式的声明式 observable 的例子:

var b =
    a
    .filter( v => v % 2 == 1 )        // 过滤掉偶数
    .distinctUntilChanged()            // 过滤连续相同的流
    .throttle( 100 )                // 函数节流(合并100毫秒内的流)
    .map( v = v * 2 );                // 变2倍

b.subscribe( function onValue(v){
    console.log( "Next:", v );
} );

注意: 这里的链式写法不是一定要把 observable 赋值给 b 和调用 b.subscribe(..) 分开写,这样做只是为了让每个方法都会得到一个新的返回值。通常,subscribe(..) 方法都会在链式写法的最后被调用。

总结

这本书详细的介绍了各种各样的函数式编程操作,例如:把单个值(或者说是一个即时列表的值)转换到另一个值里。

对于那些有时态的操作,所有基础的函数式编程原理都可以无时态的应用。就像 promise 创建了一个单一的未来值,我们可以创建一个积极的列表的值来代替像惰性的observable(事件)流的值。

数组的 map(..) 方法会用当前数组中的每一个值运行一次映射函数,然后放到返回的数组里。而 observable 数组里则是为每一个值运行一次映射函数,无论这个值何时加入,然后把它返回到 observable 里。

或者说,如果数组对函数式编程操作是一个积极的数据结构,那么 observable 相当于持续惰性的。

results matching ""

    No results matching ""