最近看到SICP 3.5: Stream,里面介绍了惰性求值,以及如何通过组合与抽象来操作惰性数据流。让我感受最深的几点:
- 基于流的编程方式能够将数据处理的代码组织成非常模块化的方式,能够通过组合与抽象实现极其复杂的行为,最大程度地控制项目代码的复杂度。
- 在JavaScript的世界中,ES6的iterable和generator的背后的思想其实就是惰性数据流。对比现在人们对这个思想的诠释(迭代器模式)和40年前的诠释,感觉非常奇妙,加深了我对iterable和generator的理解。
- 对比惰性数据流和rxjs事件流,感觉就像是来到了镜中世界,概念几乎能一一对应,却又完全不同。
接下来,让我们用JavaScript来自己实现惰性求值,在这个过程中我会解释上面的几条领悟。
从急切求值到惰性求值
假设原始的数据是[1, 1000]
的整数:
Array.from(Array(1000)).map((x,i)=>i+1)
假设我们现在想找出其中的偶数:
Array.from(Array(1000)).map((x,i)=>i+1)
.filter(x=>x%2===0)
假设我们现在想找出其中的3的倍数:
Array.from(Array(1000)).map((x,i)=>i+1)
.filter(x=>x%2===0)
.filter(x=>x%3===0)
假设我们只需要找出第10个这样的数:
Array.from(Array(1000)).map((x,i)=>i+1)
.filter(x=>x%2===0)
.filter(x=>x%3===0)
[9]
这是在js中非常常见的数组管道式处理,借鉴了函数式的思想,将多个处理步骤串联组合,让代码非常清晰简洁。
但是你应该发现了一个很严重的问题:有大量的计算被浪费。我们只需要第9个数,但是前面的处理链路实际上会把整个大数组都处理一遍。在现实编程中,数据源更大、数据处理步骤更多,计算浪费的问题会更严重。虽然管道式处理能够大大简化代码,但是常常有计算浪费的通病。
为了不做多余计算,我们往往只能抛弃管道式处理,对于每个数据,先让它走完整个处理流程,然后循环,将下一个数据推入同样的处理流程:
function compute() {
let count = 0;
for (let i = 1; i <= 1000; i++) {
if (i % 2 == 0 && i % 3 == 0) {
count++;
if (count === 10) return i;
}
}
}
这样确实能够避免计算浪费,但是失去了管道式处理的模块化和简洁性。在循环的方案中:循环代码、处理代码、判断结束的代码糅杂在一起了。而在前面的管道式处理中,每种处理都是界线清晰的,很容易做到模块化。
假设现在要实现一个UI界面,让用户通过鼠标拖拽的方式(而不是编写代码)来编排数据处理流程,你一定会选择管道模型,而不是迭代模型。因为管道模型非常容易做到模块化,你只要将不同的处理功能都实现成模块,用户只需要给这些模块排个顺序就好了。而迭代模型,你需要让用户能够创建迭代,然后在迭代里面加入处理逻辑、跳出逻辑……
那么,有没有办法能够保持管道模型的简洁性,同时能够避免计算浪费呢?有,那就是惰性数据流。惰性数据流是一种特殊的数据序列,只有在真正需要的时候才会计算出下一项的值。下面是一个例子,getRangeStream
可以返回一个惰性数据流,它的数据就是依次返回[min,max]
的整数。
function getRangeStream(min, max) {
return () => { // 用一个函数来表示惰性数据流
const done = min > max;
if (done) return { done: true };
return {
done: false,
val: min,
next: getRangeStream(min + 1, max)
};
};
}
在这里我们用一个函数来表示惰性数据流。当需要拿到下一个值的时候,就调用这个函数,计算得到值,并获得下一个惰性值的计算函数。
惰性数据流更像是链表,而不是数组。因为惰性数据流不能够随机访问,只能顺序访问。
如果你不适应这种函数式的编程,可以通过显式维护一个状态变量来记录数据流状态:
function getRangeStream2(min, max) {
let current = min;
return function next() {
const done = current > max;
if (done) return { done: true };
return {
done: false,
val: current++
};
};
}
下面的文章都会使用函数式的风格,不会使用带副作用的赋值。你可以尝试实现过程式风格的版本,对比哪种编程风格更加简洁。
为了测试我们的惰性数据流,我们写一个工具函数来打印数据流中的所有数据:
function iteratorAll(it, cb) {
const { done, val, next } = it(); // 计算下一个数据
if (done) return;
cb(val);
iteratorAll(next, cb);
}
iteratorAll(getRangeStream(20, 30), console.log);
// 会依次打印出20~30中的所有数字
使用惰性数据流来进行文章最开始的数据处理,完整代码:
// 搭建数据流处理管道
const it = takeItUntil( // 第三道处理(停止逻辑)
filterIt( // 第二道处理
filterIt( // 第一道处理
getRangeStream(1, 1000), // 数据源
x => x % 2 === 0
),
x => x % 3 === 0
),
(x, idx) => idx >= 10
);
// 打印数据流的最后一项
console.log(takeLast(it)); // 60
// 生成顺序数字流的方法
function getRangeStream(min, max) {
return () => {
const done = min > max;
if (done) return { done: true };
return {
done: false,
val: min,
next: getRangeStream(min + 1, max)
};
};
}
// 流处理方法,输入是流,输出也是流
function filterIt(it, cb) {
return () => {
const { done, val, next } = it();
if (done)
return {
done: true
};
if (cb(val))
return {
done: false,
val,
next: filterIt(next, cb)
};
// 当前值被过滤掉了,应该继续过滤,直到得到一个不被过滤的值
return filterIt(next, cb)();
};
}
function takeItUntil(it, cb, currentIdx = 0) {
return () => {
const { done, val, next } = it();
if (done || cb(val, currentIdx)) return { done: true };
return {
done: false,
val,
next: takeItUntil(next, cb, currentIdx + 1)
};
};
}
// 这个不是流处理,而是消费流的方法,因为它输出的是数字而不是流
function takeLast(it) {
const { done, val, next } = it();
if (done) throw new Error("can't takeLast from empty stream");
return helper(next, val);
function helper(it, pre) {
const { done, val, next } = it();
if (done) return pre;
return helper(next, val);
}
}
下半部分定义的那些方法,全部都是可以复用的流处理工具。
上半部分定义的流处理管道,实际是结构非常线性的,一层套一层,从内到外的处理管道。不过嵌套的方式不够直观,因此下面我们来实现链式调用。
链式调用
我们可以优化一下流处理管道的组合语法,把嵌套的组合方式改成链式调用的组合方式,更符合人的阅读习惯:
// 搭建数据流处理管道
const it = chain(getRangeStream(1, 1000)) // 数据源
.pipe(filter(x => x % 2 === 0)) // 第一道处理
.pipe(filter(x => x % 3 === 0)) // 第二道处理
.pipe(takeUntil((x, idx) => idx >= 10)) // 第三道处理
.unWrap();
// 打印数据流的最后一项
console.log(takeLast(it)); // 60
// 生成顺序数字流的方法
function getRangeStream(min, max) {
return () => {
const done = min > max;
if (done) return { done: true };
return {
done: false,
val: min,
next: getRangeStream(min + 1, max)
};
};
}
// 流处理方法配置方法
function filter(cb) {
// 返回流处理函数
return function _filter(it) {
// 流处理函数输入一个流,返回一个流
return () => {
const { done, val, next } = it();
if (done)
return {
done: true
};
if (cb(val))
return {
done: false,
val,
next: _filter(next)
};
// 当前值被过滤掉了,应该继续过滤,直到得到一个不被过滤的值
return _filter(next)();
};
};
}
function takeUntil(cb) {
return it => _takeUntil(it, 0);
function _takeUntil(it, currentIdx) {
return () => {
const { done, val, next } = it();
if (done || cb(val, currentIdx)) return { done: true };
return {
done: false,
val,
next: _takeUntil(next, currentIdx + 1)
};
};
}
}
// takeLast不是流处理,而是消费流的方法(类似于前面的iteratorAll),因为它输出的不是流
function takeLast(it) {
const { done, val, next } = it();
if (done) throw new Error("can't takeLast from empty stream");
return helper(next, val);
function helper(it, pre) {
const { done, val, next } = it();
if (done) return pre;
return helper(next, val);
}
}
// 超级简单的链式调用实现
function chain(it) {
return {
pipe: transformer => chain(transformer(it)), // 将流转换以后再用chain包起来,以便链式调用
unWrap: () => it
};
}
可以看到,链式调用的惰性数据流处理,与文章最前面的数组链式调用一样简洁。
为了实现链式调用,我们的流处理方法的签名改变了。我们将流处理器的配置传给最外层的函数,然后这个函数返回真正的流处理器,流处理器的函数签名是(stream)=>stream
。
特性一:计算关系的建立与实际计算工作分离
【计算关系的建立】与【实际计算工作】的时机分离,这个是惰性数据流与普通数组处理的最大不同:
// 搭建数据流处理管道
const it = chain(getRangeStream(1, 1000)) // 数据源
.pipe(filter(x => x % 2 === 0)) // 第一道处理
.pipe(filter(x => x % 3 === 0)) // 第二道处理
.pipe(takeUntil((x, idx) => idx >= 10)) // 第三道处理
.unWrap();
// 这个时候实际还没有任何数据拉取、处理,只是建立了计算关系
// 消费下游数据的时候,才会把上游数据抽出来
console.log(takeLast(it));
建立计算关系的代码不会消费和处理数据。刚刚搭建好处理管道的时候,我们安排好了计算时要做的事情,不过计算还没有实际发生。
而普通的数组处理则做不到这一点,建立计算关系的代码会立刻完成所有工作:
Array.from(Array(1000)).map((x,i)=>i+1)
.filter(x=>x%2===0) // 前面已经完成所有数据的计算
.filter(x=>x%3===0) // 前面已经完成所有数据的计算
[9] // 前面已经完成所有数据的计算
如果你仔细观察,会发现很多大型计算系统都是先定义好计算关系,然后才真正开始计算的。比如TensorFlow,它是先定义好计算关系(即神经网络,即模型),再开始训练的:
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10)
])
可以看到管道式模型的清晰性和模块性:管道框架可以将计算逻辑实现为相互解耦的模块,让用户通过管道来将计算逻辑组合起来。在这个例子里面,每种神经网络层结构,都被实现为一个可以组合的模块。框架使用者拥有极大的灵活性,因此这些框架几乎可以满足所有神经网络搭建需求。
另一个与前端开发相关的例子是webpack插件。当插件被apply到webpack实例的时候,插件只是将自己的行为注册到了webpack的生命周期钩子上。此时还没有任何打包构建发生,但是已经提前“安排”好了构建时要表现的行为。
特性二:惰性数据流可以是无穷多的
普通的(急切的)数据流是提前准备好所有数据,因此你无法表示和处理无穷大的数据序列(比如斐波那契数列)。
而惰性数据流则是只保存数列每一项的计算方法,在需要的时候才计算出来。这就是掌握规律与死记硬背的区别~
举个例子,斐波那契数据流:
// 生成顺序斐波那契流的方法
function getFibStream() {
return helper(0, 1);
function helper(pre2, pre1) {
return () => {
const val = pre2 + pre1;
return {
done: false,
val,
next: helper(pre1, val)
};
};
}
}
上游数据流有无穷多个,是很正常的事情。只要下游转换流、消费者能够停止,程序就能够终止。举个例子,打印1000以内的斐波那契数:
// 搭建数据流处理管道
const it = chain(getFibStream())
.pipe(takeUntil(x => x >= 1000))
.unWrap();
// 打印小于1000的斐波那契数
iteratorAll(it, console.log);
// 生成顺序斐波那契流的方法
function getFibStream() {
return helper(0, 1);
function helper(pre2, pre1) {
return () => {
const val = pre2 + pre1;
return {
done: false,
val,
next: helper(pre1, val)
};
};
}
}
function takeUntil(cb) {
return it => _takeUntil(it, 0);
function _takeUntil(it, currentIdx) {
return () => {
const { done, val, next } = it();
if (done || cb(val, currentIdx)) return { done: true };
return {
done: false,
val,
next: _takeUntil(next, currentIdx + 1)
};
};
}
}
function iteratorAll(it, cb) {
const { done, val, next } = it();
if (done) return;
cb(val);
iteratorAll(next, cb);
}
function chain(it) {
return {
pipe: transformer => chain(transformer(it)),
unWrap: () => it
};
}
如果你用普通数组的管道式处理,是无法实现这个功能的;
而如果你用迭代的方式,虽然可以算出结果,但是代码会相互糅杂,变得难以阅读、难以扩展。
汇入多个数据流
前面我们举的例子都是:先创建一个简单的源数据流,然后对它进行若干转换,得到我们想要的数据流。
但是实际上,在我们构造一个数据流的时候,能不止依赖一个源数据流。我们可以在管道处理的过程中不断“汇入”其它的数据流,通过一定的策略来计算输出数据流。
// 搭建数据流处理管道
const it = chain(getRangeStream(1, 10))
.pipe(combine(getRangeStream(1000, 2000), (v1, v2) => v1 + v2))
.unWrap();
// 消费数据流
console.log(iteratorAll(it, console.log));
// 1001, 1003, 1005 ......
// 生成顺序数字流的方法
function getRangeStream(min, max) {
return () => {
const done = min > max;
if (done) return { done: true };
return {
done: false,
val: min,
next: getRangeStream(min + 1, max)
};
};
}
// 类似于rxjs的zip: http://reactivex.io/documentation/operators/zip.html
function combine(stream2, combiner) {
return stream1 => helper(stream1, stream2);
function helper(stream1, stream2) {
return () => {
const res1 = stream1();
const res2 = stream2();
if (res1.done || res2.done) return { done: true };
return {
done: false,
val: combiner(res1.val, res2.val),
next: helper(res1.next, res2.next)
};
};
}
}
function iteratorAll(it, cb) {
const { done, val, next } = it(); // 计算下一个数据
if (done) return;
cb(val);
iteratorAll(next, cb);
}
// 超级简单的链式调用实现
function chain(it) {
return {
pipe: transformer => chain(transformer(it)), // 将流转换以后再用chain包起来,以便链式调用
unWrap: () => it
};
}
道生一,一生二,二生三,三生万物
我们只需要知道某几种非常基础的数据流(比如整数流、常数流)。在基本流的基础上,只需要经过几轮操作符的转换,就能得到非常复杂的流。
甚至,整数流本身也可以通过常数流来得到:
// 基本流
const one$ = () => ({
done: false,
val: 1,
next: one$
});
// 通过基本流得到整数流
const int$ = () => ({
done: false,
val: 1,
next: chain(int$)
.pipe(combine(one$, (x, y) => x + y))
.unWrap()
});
// 测试整数流
const truncated = chain(int$)
.pipe(takeUntil((x, idx) => idx > 10))
.unWrap();
// 打印idx从0~10的数字,即1~11
iteratorAll(truncated, console.log);
function takeUntil(cb) {
return it => _takeUntil(it, 0);
function _takeUntil(it, currentIdx) {
return () => {
const { done, val, next } = it();
if (done || cb(val, currentIdx)) return { done: true };
return {
done: false,
val,
next: _takeUntil(next, currentIdx + 1)
};
};
}
}
// 类似于rxjs的zip: http://reactivex.io/documentation/operators/zip.html
function combine(stream2, combiner) {
return stream1 => helper(stream1, stream2);
function helper(stream1, stream2) {
return () => {
const res1 = stream1();
const res2 = stream2();
if (res1.done || res2.done) return { done: true };
return {
done: false,
val: combiner(res1.val, res2.val),
next: helper(res1.next, res2.next)
};
};
}
}
function iteratorAll(it, cb) {
const { done, val, next } = it(); // 计算下一个数据
if (done) return;
cb(val);
iteratorAll(next, cb);
}
// 超级简单的链式调用实现
function chain(it) {
return {
pipe: transformer => chain(transformer(it)), // 将流转换以后再用chain包起来,以便链式调用
unWrap: () => it
};
}
你甚至可以将one$
理解成一个基本符号,它是一切流的始祖。
将简单事物的组合成复杂的事物,然后将复杂的事物当成一个整体来对待,为它赋予名称和含义(即抽象)。如此反复迭代,构造出变化万千的世界。正所谓“道生一,一生二,二生三,三生万物”。
出自老子的《道德经》。
用Generator来实现惰性数据流
ES6的Generator能够在使用者的控制下,按需计算出多个值(也可以是无穷多个)。
Generator函数返回一个Iterator,用户可以根据自己的需要,从Iterator中“取出”值。Iterator实际上就是一种惰性数据流。下面用Generator来实现“打印1000以内的斐波那契数”:
// 搭建数据流(迭代器)
const it = takeUntil(fib(), x => x >= 1000);
// 消费数据流
iterateAll(it, console.log);
// 生成数据流
function* fib() {
// 在generator的场景下,如果直接使用循环来实现,会更加简单直接。
// 不过这里出于学习的兴趣,还是尝试一下更有挑战性的函数式风格。
yield* helper(0, 1);
function* helper(pre2, pre1) {
const cur = pre2 + pre1;
yield cur;
yield* helper(pre1, cur); // 递归调用,将最近的2个斐波那契数字传给下一轮迭代使用
}
}
// 转换数据流
// 数据流装换器接受Iterator,返回Iterator
function* takeUntil(iterator, cb) {
yield* helper();
function* helper() {
const { done, value } = iterator.next();
if (done || cb(value)) return;
yield value;
yield* helper(); // 递归调用
}
}
// 消费数据流
function iterateAll(iterator, cb) {
helper();
function helper() {
const { done, value } = iterator.next();
if (done) return;
cb(value);
helper();
}
}
可以看出,用Generator来实现惰性数据流,和我们之前自己实现惰性数据流,其实效果是相差无几的,几乎每一行代码都可以对应起来。
对于Iterator,同样可以轻松实现链式处理:
// 搭建数据流(迭代器)
const it = chain(fib())
.pipe(takeUntil(x => x >= 1000))
.unWrap();
// 消费数据流
iterateAll(it, console.log);
// 生成数据流
function* fib() {
// 在generator的场景下,如果直接使用循环来实现,会更加简单直接。
// 不过这里出于学习的兴趣,还是尝试一下更有挑战性的函数式风格。
yield* helper(0, 1);
function* helper(pre2, pre1) {
const cur = pre2 + pre1;
yield cur;
yield* helper(pre1, cur); // 递归调用,将最近的2个斐波那契数字传给下一轮迭代使用
}
}
// 配置数据流转换器
function takeUntil(cb) {
// 返回数据流转换器
return iterator => {
return helper(); // 数据流装换器接受Iterator,返回Iterator
function* helper() {
const { done, value } = iterator.next();
if (done || cb(value)) return;
yield value;
yield* helper(); // 递归调用
}
};
}
// 消费数据流
function iterateAll(iterator, cb) {
helper();
function helper() {
const { done, value } = iterator.next();
if (done) return;
cb(value);
helper();
}
}
function chain(it) {
return {
pipe: transformer => chain(transformer(it)),
unWrap: () => it
};
}
ES6还提出了async iterators 和 async generators。我们也很容易基于上面的惰性数据流实现异步的版本,让惰性请求函数返回一个Promise就好了。用户请求下一个数据的时候,需要await一下。
惰性数据流与rxjs事件流的异同
在惰性数据流的世界中,请求与结果必定是结对出现的;
在rxjs事件流的世界中,事件不存在与之对应的”结果“。
惰性数据流是用户将数据拉出来(pull);用户每次执行pull的动作,会拿到对应的结果。请求与结果必定是结对出现的。比如const output = generator.next('input');
,惰性数据流会为你的每一次请求返回对应的结果,就如同普通的函数调用一样。
rxjs事件流是用户将一个事件推入(push)一个系统中;事件生产者每次push的时候,不知道会对这个系统产生什么影响,更拿不到push的“结果“(根本没有”结果“这个概念),比如observerable.next('event');
。请求与结果是解耦的。事件机制(即发布订阅机制)本身就是为了【将event emitter与event handler相互解耦】而产生的,因此事件发出者自然也不应该知道这个事件的”结果“是什么。甚至可以说,事件的”结果“(返回值)本身就是一个荒谬的概念。另一方面,当你subscribe一个事件流然后拿到事件的时候,你也不知道事件的发出者是谁,你也不知道当前事件是源自于哪个”初始事件“、经过了怎样的处理才到你手上的。push模型并不像pull模型那样有input->output对。
我在另一篇文章中详细论述了push与pull的区别:两种数据消费方式:pull与push,阴与阳
但是除此之外,惰性数据流与rxjs事件流之间具有很强的对应关系,尤其在组合与抽象的方面。举个例子:
- 惰性数据流的组合与抽象:先将流1的数据转发给用户,流1消耗完毕以后,将流2的数据转发给用户。将这个组合结果作为一个新的数据流使用。
- rxjs事件流的组合与抽象:先将流1的事件转发给用户,流1事件流complete以后,将流2的事件转发给用户。将这个组合结果作为一个新的事件流使用。即rxjs concate操作符:
事实上,rxjs的绝大部分操作符都可以实现一个数据流版本。比如rxjs的map操作符:
对比惰性数据流和rxjs事件流,就像是来到了镜中世界,概念几乎能一一对应,却又完全相反,真是奇妙。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。