章节 10: 函数的异步

现在,您已经掌握了FP基础的所有原始概念,我称之为“轻量函数编程”。在本章中,我们将把这些概念应用到不同的环境中,但我们不会真正提出特别新的想法。

到目前为止,我们所做的几乎所有工作都是同步的,这意味着我们调用带有即时输入的函数并立即返回输出值。通过这种方式可以完成很多工作,但是对于一个现代JS应用程序来说,这还远远不够。为了在JS的现实世界中真正为FP做好准备,我们需要理解异步的FP。

在本章中,我们的目标是扩展我们关于用FP管理价值的想法,随着时间的推移扩展这些操作。我们将看到Promises与Observables是实现这一点的一个很好的方法。

时间状态

整个应用程序中最复杂的状态是时间。也就是说,当从一种状态到另一种状态的转换在您的控制范围内,并且是直接的,肯定的时候,管理状态要容易得多。当应用程序的状态随着时间的推移而隐式地响应扩展的事件时,管理就会变得非常困难。

我们在本文中介绍FP的每一个部分都是关于通过使代码更可靠和更可预测来使代码更易于阅读。当您在程序中引入异步时,这些工作将受到很大的影响。

但是让我们更明确一点:这不仅仅是一些操作不能同步完成的事实;触发异步行为很容易。这是对这些动作的响应的协调,每个动作都有可能改变应用程序的状态,这需要很多额外的工作。

那么,对于作者来说,这样做更好吗?还是应该让代码的读者来判断如果A在B之前完成,程序的状态会是什么,或者反之亦然?这是一个反问句,但从我的角度来看,它有一个相当具体的答案:要想让这样复杂的代码更具可读性,作者必须比平时更加小心。

缩短时间

异步编程模式最重要的结果之一是通过将时间从我们关注的范围中抽象出来,从而简化状态更改管理。为了说明这一点,我们首先来看一个场景,其中存在一个竞态条件(即时间复杂度),并且必须手动管理:

  1. var customerId = 42;
  2. var customer;
  3. lookupCustomer( customerId, function onCustomer(customerRecord){
  4. var orders = customer ? customer.orders : null;
  5. customer = customerRecord;
  6. if (orders) {
  7. customer.orders = orders;
  8. }
  9. } );
  10. lookupOrders( customerId, function onOrders(customerOrders){
  11. if (!customer) {
  12. customer = {};
  13. }
  14. customer.orders = customerOrders;
  15. } );

onCustomer(..)onOrders(..)回调处于二进制竞争状态。假设它们都在运行,其中一个可能会先运行,而预测哪个会发生是不可能的。

因此,为了规范化这种基于时间的状态复杂性,我们在各自的回调中使用了一对if语句检查,并在变量customer上使用外部词法封闭。每次回调运行时,它都会检查customer的状态,从而确定自己的相对顺序;如果customer未设置为回调,则它是第一个运行的回调,否则它是第二个。

这段代码可以工作,但是在可读性方面还远远不够理想。时间复杂度使代码更难阅读。相反,让我们使用JS的Promise将时间状态从描述中剔除:

  1. var customerId = 42;
  2. var customerPromise = lookupCustomer( customerId );
  3. var ordersPromise = lookupOrders( customerId );
  4. customerPromise.then( function onCustomer(customer){
  5. ordersPromise.then( function onOrders(orders){
  6. customer.orders = orders;
  7. } );
  8. } );

onOrders(..)回调现在位于onCustomer(..)回调中,因此保证了它们的相对顺序。查找的并发性是通过在指定响应处理then(..)之前分别调用lookupCustomer(..)lookupOrders(..)来实现的。

这可能不太明显,但是如果不是因为Promises是如何定义的,那么这个代码片段中本来就存在一个竞争条件。如果orders的查找在ordersPromise.then(..)之前完成,那么将被调用来提供onOrders(..)回调,something需要足够聪明,以便在可以调用onOrders(..)之前保留orders列表。事实上,同样的关注也适用于在指定 onCustomer(..)接收之前出现的customer

That something is the same kind of time complexity logic we discussed with the previous snippet. But we don’t have to worry about any of that complexity, either in the writing of this code or — more importantly — in the reading of it, because the promises take care of that time normalization for us.

A Promise represents a single (future) value in a time-independent manner. Moreover, extracting the value from a promise is the asynchronous form of the synchronous assignment (via =) of an immediate value. In other words, a promise spreads an = assignment operation out over time, but in a trustable (time-independent) fashion.

We’ll now explore how we similarly can spread various synchronous FP operations from earlier in this book asynchronously over time.

Eager vs. Lazy

Eager and lazy in the realm of computer science aren’t compliments or insults, but rather ways to describe whether an operation will finish right away or progress over time.

The FP operations that we’ve seen in this text can be characterized as eager because they operate synchronously (right now) on a discrete immediate value or list/structure of values.

Recall:

  1. var a = [1,2,3]
  2. var b = a.map( v => v * 2 );
  3. b; // [2,4,6]

This mapping from a to b is eager because it operates on all the values in the a array at that moment, and produces a new b array. If you later modify a (for example, by adding a new value to the end of it) nothing will change about the contents of b. That’s eager FP.

But what would it look like to have a lazy FP operation? Consider something like this:

  1. var a = [];
  2. var b = mapLazy( a, v => v * 2 );
  3. a.push( 1 );
  4. a[0]; // 1
  5. b[0]; // 2
  6. a.push( 2 );
  7. a[1]; // 2
  8. b[1]; // 4

The mapLazy(..) we’ve imagined here essentially “listens” to the a array, and every time a new value is added to the end of it (with push(..)), it runs the v => v * 2 mapping function and pushes the transformed value to the b array.

Note: The implementation of mapLazy(..) has not been shown because this is a fictional illustration, not a real operation. To accomplish this kind of lazy operation pairing between a and b, we’ll need something smarter than basic arrays.

Consider the benefits of being able to pair an a and b together, where any time (even asynchronously!) you put a value into a, it’s transformed and projected to b. That’s the same kind of declarative FP power from of a map(..) operation, but now it can be stretched over time; you don’t have to know all the values of a right now to set up the mapping from a to b.

Reactive FP

To understand how we could create and use a lazy mapping between two sets of values, we need to abstract our idea of list (array) a bit. Let’s imagine a smarter kind of array, not one which simply holds values but one which lazily receives and responds (aka “reacts”) to values. Consider:

  1. var a = new LazyArray();
  2. var b = a.map( function double(v){
  3. return v * 2;
  4. } );
  5. setInterval( function everySecond(){
  6. a.push( Math.random() );
  7. }, 1000 );

So far, this snippet doesn’t look any different than a normal array. The only unusual thing is that we’re used to the map(..) running eagerly and immediately producing a b array with all the currently mapped values from a. The timer pushing random values into a is strange, since all those values are coming after the map(..) call.

But this fictional LazyArray is different; it assumes that values will come one at a time, over time; just push(..) values in whenever you want. b will be a lazy mapping of whatever values eventually end up in a.

Also, we don’t really need to keep values in a or b once they’ve been handled; this special kind of array only holds a value as long as it’s needed. So these arrays don’t strictly grow in memory usage over time, an important characteristic of lazy data structures and operations. In fact, it’s less like an array and more like a buffer.

A normal array is eager in that it holds all of its values right now. A “lazy array” is an array where the values will come in over time.

Since we won’t necessarily know when a new value has arrived in a, another key thing we need is to be able to listen to b to be notified when new values are made available. We could imagine a listener like this:

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

b is reactive in that it’s set up to react to values as they come into a. There’s an FP operation map(..) that describes how each value transfers from the origin a to the target b. Each discrete mapping operation is exactly how we modeled single-value operations with normal synchronous FP, but here we’re spreading out the sourcing of values over time.

Note: The term most commonly applied to these concepts is Functional Reactive Programming (FRP). I’m deliberately avoiding that term because there’s some debate as to whether FP + Reactive genuinely constitutes FRP. We’re not going to fully dive into all the implications of FRP here, so I’ll just keep calling it reactive FP. Alternatively, you could call it evented-FP if that feels less confusing.

We can think of a as producing values and b as consuming them. So for readability, let’s reorganize this snippet to separate the concerns into producer and consumer roles:

  1. // producer:
  2. var a = new LazyArray();
  3. setInterval( function everySecond(){
  4. a.push( Math.random() );
  5. }, 1000 );
  6. // **************************
  7. // consumer:
  8. var b = a.map( function double(v){
  9. return v * 2;
  10. } );
  11. b.listen( function onValue(v){
  12. console.log( v );
  13. } );

a is the producer, which acts essentially like a stream of values. We can think of each value arriving in a as an event. The map(..) operation then triggers a corresponding event on b, which we listen(..) to so we can consume the new value.

The reason we separate the producer and consumer concerns is so that different parts of our application can be responsible for each concern. This code organization can drastically improve both code readability and maintenance.

Declarative Time

We’re being very careful about how we introduce time into the discussion. Specifically, just as promises abstract time away from our concern for a single asynchronous operation, reactive FP abstracts (separates) time away from a series of values/operations.

From the perspective of a (the producer), the only evident time concern is our manual setInterval(..) loop. But that’s only for demonstration purposes.

Imagine a could actually be attached to some other event source, like the user’s mouse clicks or keystrokes, websocket messages from a server, etc. In that scenario, a doesn’t actually have to concern itself with time. It’s merely a time-independent conduit for values, whenever they are ready.

From the perspective of b (the consumer), we do not know or care when/where the values in a come from. As a matter of fact, all the values could already be present. All we care about is that we want those values, whenever they are ready. Again, this is a time-independent (aka lazy) modeling of the map(..) transformation operation.

The time relationship between a and b is declarative (and implicit!), not imperative (or explicit).

The value of organizing such operations-over-time this way may not feel particularly effective yet. Let’s compare to how this same sort of functionality could have been expressed imperatively:

  1. // producer:
  2. var a = {
  3. onValue(v){
  4. b.onValue( v );
  5. }
  6. };
  7. setInterval( function everySecond(){
  8. a.onValue( Math.random() );
  9. }, 1000 );
  10. // **************************
  11. // consumer:
  12. var b = {
  13. map(v){
  14. return v * 2;
  15. },
  16. onValue(v){
  17. v = this.map( v );
  18. console.log( v );
  19. }
  20. };

It may seem rather subtle, but there’s an important difference between this more-imperative version of the code and the previous more-declarative version, aside from just b.onValue(..) needing to call this.map(..) itself. In the former snippet, b pulls from a, but in the latter snippet, a pushes to b. In other words, compare b = a.map(..) to b.onValue(v).

In the latter imperative snippet, it’s not clear (readability wise) from the consumer’s perspective where the v values are coming from. Moreover, the imperative hard coding of b.onValue(..) in the middle of producer a‘s logic is a violation of separation-of-concerns. That can make it harder to reason about producer and consumer independently.

By contrast, in the former snippet, b = a.map(..) declares that b‘s values are sourced from a, and treats a as an abstract event stream data source that we don’t have to concern ourselves with at that moment. We declare that any value that comes from a into b will go through the map(..) operation as specified.

More Than Map

For convenience, we’ve illustrated this notion of pairing a and b together over time via a one-to-one map(..)ing. But many of our other FP operations could be modeled over time as well.

Consider:

  1. var b = a.filter( function isOdd(v) {
  2. return v % 2 == 1;
  3. } );
  4. b.listen( function onlyOdds(v){
  5. console.log( "Odd:", v );
  6. } );

Here, a value from a only comes into b if it passes the isOdd(..) predicate.

Even reduce(..) can be modeled over time:

  1. var b = a.reduce( function sum(total,v){
  2. return total + v;
  3. } );
  4. b.listen( function runningTotal(v){
  5. console.log( "New current total:", v );
  6. } );

Since we don’t specify an initialValue to the reduce(..) call, neither the sum(..) reducer nor the runningTotal(..) event callback will be invoked until at least two values have come through from a.

This snippet implies that the reduction has a memory of sorts, in that each time a future value comes in, the sum(..) reducer will be invoked with whatever the previous total was as well as the new next value v.

Other FP operations extended over time could even involve an internal buffer, like for example unique(..) keeping track of every value it’s seen so far.

Observables

Hopefully by now you can see the importance of a reactive, evented, array-like data structure like the fictional LazyArray we’ve conjured. The good news is, this kind of data structure already exists, and it’s called an Observable.

Note: Just to set some expectation: the following discussion is only a brief intro to the world of Observables. This is a far more in-depth topic than we have space to fully explore. But if you’ve understood Functional-Light Programming in this text, and now grasped how asynchronous-time can be modeled via FP principles, Observables should follow very naturally for your continued learning.

Observables have been implemented by a variety of userland libraries, most notably RxJS and Most. At the time of this writing, there’s an in-progress proposal to add Observables natively to JS, just like Promises were added in ES6. For the sake of demonstration, we’ll use RxJS-flavored Observables for these next examples.

Here’s our earlier reactive example, expressed with observables instead of LazyArray:

  1. // producer:
  2. var a = new Rx.Subject();
  3. setInterval( function everySecond(){
  4. a.next( Math.random() );
  5. }, 1000 );
  6. // **************************
  7. // consumer:
  8. var b = a.map( function double(v){
  9. return v * 2;
  10. } );
  11. b.subscribe( function onValue(v){
  12. console.log( v );
  13. } );

In the RxJS universe, an Observer subscribes to an Observable. If you combine the functionality of an Observer and an Observable, you get a Subject. So, to keep our snippet simpler, we construct a as a Subject, so that we can call next(..) on it to push values (events) into its stream.

If we want to keep the Observer and Observable separate:

  1. // producer:
  2. var a = Rx.Observable.create( function onObserve(observer){
  3. setInterval( function everySecond(){
  4. observer.next( Math.random() );
  5. }, 1000 );
  6. } );

In this snippet, a is the observable, and unsurprisingly, the separate observer is called observer; it’s able to “observe” some events (like our setInterval(..) loop); we use its next(..) method to feed events into the a observable stream.

In addition to map(..), RxJS defines well over a hundred operators that are invoked lazily as each new value comes in. Just like with arrays, each operator on an Observable returns a new Observable, meaning they are chainable. If an invocation of operator function determines a value should be passed along from the input Observable, it will be fired on the output Observable; otherwise it’s discarded.

Example of a declarative observable chain:

  1. var b =
  2. a
  3. .filter( v => v % 2 == 1 ) // only odd numbers
  4. .distinctUntilChanged() // only consecutive-distinct
  5. .throttle( 100 ) // slow it down a bit
  6. .map( v = v * 2 ); // double them
  7. b.subscribe( function onValue(v){
  8. console.log( "Next:", v );
  9. } );

Note: It’s not necessary to assign the observable to b and then call b.subscribe(..) separately from the chain; that’s done here to reinforce that each operator returns a new observable from the previous one. In many coding examples you’ll find, the subscribe(..) call is just the final method in the chain. Because subscribe(..) is technically mutating the internal state of the observable, FPers generally prefer these two steps separated, to mark the side effect more obviously.

Summary

This book has detailed a wide variety of FP operations that take a single value (or an immediate list of values) and transform them into another value/values.

For operations that will be proceed over time, all of these foundational FP principles can be applied time-independently. Exactly like promises model single future values, we can model eager lists of values instead as lazy Observable (event) streams of values that may come in one-at-a-time.

A map(..) on an array runs its mapping function once for each value currently in the array, putting all the mapped values in the outcome array. A map(..) on an Observable runs its mapping function once for each value, whenever it comes in, and pushes all the mapped values to the output Observable.

In other words, if an array is an eager data structure for FP operations, an Observable is its lazy-over-time counterpart.

Note: For a different twist on asynchronous FP, check out a library called fasy, which is discussed in Appendix C.