Ruchir Khaitan 2021年1月11日
我在Materialize工作已经快一年了,我非常喜欢学习和使用 Differential Dataflow(以下简称Differential)在我的日常工作中。在这篇文章中,我将介绍Differential,并通过实现一些常见的编程问题,如列表相交和大家最喜欢的FizzBuzz,作为数据流程序。最后,我将在Differential中建立一个简单版本的 Conway’s Game of Life(以下简称Life)的简单版本。我的主要目标是描述如何用Differential写算法,并给出一些关于什么时候写算法是个好主意的直觉。这篇文章需要有一些Rust的知识来阅读例子,但在其他方面不需要假设有任何背景。所有的例子代码都存在于一个 repository中;你只需要能够构建和运行Rust程序就可以了。
Differential是一个声明性的、数据并行的、面向数据流的、增量的框架,用于对变化的数据进行计算。这是一个相当专业的句子,所以让我们把它整理一下。
- "声明性 "意味着你不需要指定如何实现一个算法,而只需要指定算法的作用(把如何实现留给框架)。特别是,你不能做出诸如 "我应该在这里使用哈希表、二叉树还是链表?"这样的决定(这既是好事也是坏事)。相反,用户只被允许在集合方面进行思考。集合是通用的、多版本的 multisets这意味着 "在某个离散的时间,我们在我们的包里添加了数据的多重拷贝。Differential使用集合来表示随时间的变化,因此正的乘数表示插入,负的乘数表示删除。如果你仍然对集合感到困惑,在附录中有更详细的解释。
- "数据并行 "是指框架通过将数据切分给不同的工作者(在可能的情况下)来实现计算的并行。每个工作者都会得到它所需要的数据的本地副本,然后可以自由地进行计算,就好像它是唯一的一个。作为程序员,你可以自由地编写代码,并且可以毫不知情地拥有多个线程(大部分时间)。
- "数据流 "意味着你不能把你的程序看作是一个单一的控制线程,其中有一连串的指令,一些抽象的图灵机相当于处理器一次执行这些指令 ,把这种模式扔掉。相反,你有各种功能运算符,如map、reduce或join(以及其他)。每个运算符都接受一个或多个集合作为输入,并吐出一个新的集合作为输出。你编写程序所能做的就是把这些运算符拼接成一个数据流图,把一些运算符的输出发送到其他运算符的输入。这方面的实际代码看起来很像你用迭代器写的代码,只不过Differential让你写循环,把一些输出连接到早期输入。与迭代器的相似性是骗人的,因为我们要写的所有Differential代码只是设置了数据流和它们的操作符。一个单独的调度器会根据哪些输入可以使用来调用操作符。
- "增量 "是指当你向你的程序发送更多数据时,Differential不会从头开始重新计算答案。相反,Differential可以重新使用以前的计算结果,为变化的输入找出新的答案。在某些情况下,例如维持一个整数列表的平均值,这已经很容易了。在其他情况下,比如大多数图形算法,用手做这个是很难的。Differential数据流使这部分自动进行,在我看来,增量计算是使用Differential最令人信服的理由。
这是一个很大的描述! 不要担心,如果它不是马上就能理解的,随着我们对一些代码的深入研究,它将(希望)变得更好。
相互交错的列表
让我们从一个常见的面试问题开始:找到两个整数数组的交集(共同元素的集合)。这可能是大多数读者以前见过的问题,但还是让我们来简要介绍一下相当标准的解决方案。
use std::collections::HashSet;
fn intersection(first: &[i32], second: &[i32]) -> Vec {
let mut output = Vec::new();
let first_set: HashSet<_> = first.iter().cloned().collect();
let second_set: HashSet<_> = second.iter().cloned().collect();
for element in first_set.iter() {
if second_set.contains(element) {
output.push(*element);
}
}
output
}
这里有一些Rust特有的小东西,但总的来说,每个用命令式语言写过代码的人都应该感到熟悉。我们把两个整数数组作为输入,把它们都转化为集合,然后通过一个集合中的整数,看看它们是否也在第二个集合中。如果是,我们就把它们加到我们的输出中。很简单。让我们花点时间来理解两件事:第一,在第一和第二项任意改变的情况下,保持正确的结果是很难的;第二,我们不能在Differential中重复使用任何这些部分。我们不能将数组转换为哈希集,因为我们只能得到集合,我们不能访问for循环,因为我们不能访问控制流原语。
相反,我们需要找出一种方法来使用数据流操作符。在对文档进行了一番挖掘之后,我们可以看到 semijoinoperator semijoin接收两个集合,一个是(Key, Value)类型,另一个是(Key)类型,并产生一个(Key, Value)类型的集合,其中包含两个集合中所有的(k, v)键的非零乘数。这并不完全是我们想要的交集,但非常接近。不幸的是,我们还没有完全走出困境。很明显,我们可以用i32作为两个集合的键,但我们没有任何值。幸好,这不是一个问题,因为我们可以用 unit type来模拟一个值。我们可以用一个 map操作符将一个T类型的集合变成(T,())。同样地,我们可以使用另一个映射器将类型(T, ())转换回T。此时,输出结果几乎完全是我们想要的,只是输出中的乘数可能大于1,但我们特别想要交集集合。我们可以用操作符给出集合的语义。 distinct操作符给出集合的语义。这个实现对应于下面的数据流图。
( 译者注 : semi join 就像是 SELECT * FROM A WHERE A.key IN (SELECT B.key FROM B) )
这里,边缘代表采集,蓝色矩形代表数据流操作者。我用黄色的椭圆标记了第一个和第二个,以表明它们是输入集合,从外部输入获得数据,而不是从另一个运算符的输出。我还为每个集合标注了其类型,以使流程图的意图更加清晰。这个图的微分版本看起来像下面的代码。
// 假设`first`和`second`是其他地方定义的两个输入集合。
// Assume `first` and `second` are two input collections defined elswhere.
let output = first
.map(|x| (x, ()))
.semijoin(&second)
.map(|(x, _)| x)
.distinct();
在我们进一步讨论之前,我想指出三件事。首先,这是许多可能的Differential解决方案中的一个。例如,我们可以在调用semijoin之前对每个输入使用distinct。另外,我们也可以使用连接操作符来代替半连接操作符。所有这些选择都有不同的权衡,不幸的是,这些权衡超出了这篇文章的范围,但我想强调的是,Differential并不是强烈的 "声明性",只有一种表达程序的典型方式。第二,我想明确说明可视化数据流图和实际代码之间的关系。每个操作符(蓝色矩形)都对应于代码中的一个操作符函数调用。每条传入边都对应着这些函数的一个参数,每条传出边都对应着这些函数的一个输出。最后,也许是最重要的,我们仍然没有能力向我们的数据流运算符发送输入并使用所有这些逻辑。我们需要更多的差分和 Timely(Differential所建立的分布式计算的底层框架)来进行工作,最终的结果看起来是这样的。
timely::execute_directly(move |worker| {
let (mut first, mut second) = worker.dataflow(|scope| {
let (first_handle, first) = scope.new_collection();
let (second_handle, second) = scope.new_collection();
let output = first
.map(|x| (x, ()))
.semijoin(&second)
.map(|(x, _)| x)
.distinct();
output
.inspect(|(x, time, m)| println!("x: {} time: {} multiplicity: {}", x, time, m));
(first_handle, second_handle)
});
// Send some sample data to our dataflow
for i in 0..10 {
// Advance time to i
first.advance_to(i);
second.advance_to(i);
for x in i..(i + 10) {
first.insert(x);
second.insert(x + 5);
}
}
})
我们需要在这里设置一些Timely和Differential的模板来进行我们的计算。我们告诉Timely用dataflow方法创建一个新的数据流图,并可以用new_collection方法定义我们的输入集合。new_collection给了我们一个 "句柄",它基本上就像一个管道,我们可以用它从其他地方将数据送入这个集合,还有一个对集合的引用,我们可以在闭包中使用它来实现实际的图(这与之前的逻辑相同)。闭包中唯一一个新的部分是 inspect 调用,它让我们将集合的内容打印到 stdout。
封闭之外的其他代码是关于设置一个例子的。它在不同的逻辑时间向第一和第二发送整数(基于我们的 advance_to),这样我们就可以测试出逻辑。从嵌套的循环中并不明显,但是第一个得到[0, 19]中的所有东西(其中一些是重复的),第二个得到[5, 24]中的所有东西(同样是重复的)。因此,相交集应该是[5, 19],事实上,当我运行这个(你也可以看到它,如果你克隆了 repository) 我看到了。
altaria-2:life-differential $ cargo run --example intersection
Compiling life-differential v0.1.0 (/Users/Test/github/life-differential)
Finished dev [unoptimized + debuginfo] target(s) in 3.62s
Running `target/debug/examples/intersection`
x: 5 time: 0 multiplicity: 1
x: 6 time: 0 multiplicity: 1
x: 7 time: 0 multiplicity: 1
x: 8 time: 0 multiplicity: 1
x: 9 time: 0 multiplicity: 1
x: 10 time: 1 multiplicity: 1
x: 11 time: 2 multiplicity: 1
x: 12 time: 3 multiplicity: 1
x: 13 time: 4 multiplicity: 1
x: 14 time: 5 multiplicity: 1
x: 15 time: 6 multiplicity: 1
x: 16 time: 7 multiplicity: 1
x: 17 time: 8 multiplicity: 1
x: 18 time: 9 multiplicity: 1
FizzBuzz
在我们开始研究 "生命 "之前,我需要再介绍一个运算符,和上面一样,我将用一个简单的问题来激励它:计算 FizzBuzz为数字1-100。一个解决方案的例子是非常简单的。
for x in 1..=100 {
let str = if x % 3 == 0 && x % 5 == 0 {
"FizzBuzz"
} else if x % 5 == 0 {
"Buzz"
} else if x % 3 == 0 {
"Fizz"
} else {
""
};
println!("{} {}", x, str);
}
这个for-loop有一个清晰的迭代器1...=100,控制循环主体执行的次数。你也可以选择用while循环来写,像这样。
let mut x = 1;
while x <= 100 {
let str = ... // Same if statement as above
println!("{} {}", x, str);
x = x + 1;
}
在这里,我没有直接指定迭代的次数,而是指定了一个谓词,指出我们应该在什么时候停止执行这个循环。这是用一种稍微不同的方式来表达同一个想法。Differential有一个迭代操作符,叫做 iterate2但它不允许你指定迭代次数,或停止迭代的谓词。相反,iterate将你的逻辑(表示为数据流片段)重复应用于一个集合,直到输出停止变化,也就是达到一个 fixed point.这样写数据流的过程感觉不像是在写for循环,而更像是在写一个归纳证明。本着这种精神,我喜欢思考一个部分结果,看看什么数据流片段能让我们产生下一个迭代结果。更具体地说,让我们假设我们把FizzBuzz的数据存储在一个类型为(i32, String)的集合中,让我们假设经过四次迭代,我们有以下数据。
(1, ""),
(2, ""),
(3, "Fizz")。
(4, ""),
我们现在想把这个集合作为输入,并产生上面的所有内容+(5,"Buzz")作为输出。矛盾的是,在这里试图变得聪明并试图找到迄今为止产生的最大整数或类似的东西并不会有很大帮助。相反,我们将尝试更简单的策略,让每一个元素产生它的 "继承者"(例如,我们将(2,"")转化为(3,"Fizz")),然后将继承者集合与现有的输入集合结合起来。只要我们小心翼翼地只保留所有内容的一个副本,结果的输出应该是我们想要的。这个单一迭代逻辑的数据流图看起来像。
该逻辑的相应代码看起来如下。
let successors = input.map(|(x, _)| x + 1).map(|x| {
let str = if x % 3 == 0 && x % 5 == 0 {
"FizzBuzz"
} else if x % 5 == 0 {
"Buzz"
} else if x % 3 == 0 {
"Fizz"
} else {
""
};
(x, str.to_string())
});
let output = input.concat(&successors).distinct();
第二张图最终与命令式版本中的for-loop完全相同从这里开始,我们只需要对逻辑进行编码,使集合停止在100。记住,我们不能控制数据流计算的执行次数,但我们可以控制我们的输出内容。在这种情况下,我们可以使用一个 filter操作符来限制我们的FizzBuzz输出到[1, 100] 。我们现在可以用我们的逻辑来处理FizzBuzz的单次迭代,并将其作为迭代的逻辑。这就是我们FizzBuzz的最终数据流图。
FizzBuzz的最终差分代码在这一点上应该看起来很熟悉。
timely::execute_directly(move |worker| {
worker.dataflow::(|scope| {
// Seed the iteration with (1, "")
let initial = scope
.new_collection_from(vec![(1, "".to_string())].into_iter())
.1;
let result = initial.iterate(|input| {
let successors = input.map(|(x, _)| x + 1).map(|x| {
let str = if x % 3 == 0 && x % 5 == 0 {
"FizzBuzz"
} else if x % 5 == 0 {
"Buzz"
} else if x % 3 == 0 {
"Fizz"
} else {
""
};
(x, str.to_string())
});
let output = input.concat(&successors).distinct();
output.filter(|(x, _)| *x <= 100)
});
result
.inspect(|(x, time, m)| println!("x: {:?} time: {:?} multiplicity: {}", x, time, m));
});
})
你可以通过输入以下内容来运行它。
altaria-2:life-differential Test$ cargo run --example fizzbuzz
Compiling life-differential v0.1.0 (/Users/Test/github/life-differential)
Finished dev [unoptimized + debuginfo] target(s) in 4.40s
Running `target/debug/examples/fizzbuzz`
x: (1, "") time: 0 multiplicity: 1
x: (2, "") time: 0 multiplicity: 1
x: (3, "Fizz") time: 0 multiplicity: 1
x: (4, "") time: 0 multiplicity: 1
x: (5, "Buzz") time: 0 multiplicity: 1
...
这段代码中也有一些值得一提的小问题。首先,要注意的是,绝对要用一个有效的值作为这个计算的种子。如果我们不这样做,迭代就会停止,因为它将很快达到一个固定的空集合点。调试这样的情况是很棘手的,我仍然不时地忘记正确地做这个。另外,有一个distinct或consolidate或其他的东西,在特定的时间强制每个数据点只有一个实例,也是必须的。否则,我们最终可能会得到在逻辑上等价(即所有数据在每个时间的乘数之和是相同的)但在物理上不等价(我们实际上没有做这个加法)的状态之间交替的集合,然后一个本来可以收敛的计算可能不会收敛。最后,我想谈谈我第一次写这段代码时最大的担忧。这个实现,乍一看,似乎是FizzBuzz的二次方时间实现(可能不会通过大多数面试)。当然,似乎在每个迭代i,我们都要做工作,再次生成所有i个FizzBuzz数字。幸运的是,事实并非如此,因为所有的运算符都是适当的增量,因此在每个迭代i,我们只执行与迭代i-1和i之间的输入变化相称的工作。确切地说,这是一个有点超出范围的工作,但你可以从原文中得到一些细节 paper.
有些人可能不相信上面的解释。我自己最初当然也不确定。所以我们来测试一下。我们可以在发布模式下编译FizzBuzz,用管道将输出转到/dev/null,以确保我们在打印到终端时没有瓶颈,用命令行参数在不同的输入大小上运行它,用时间衡量结果。该调用看起来像。
altaria-2:life-differential $ time cargo run --release --example faster-fizzbuzz -- 1000000 > /dev/null
我把每个测试都跑了几遍,挑了一个看起来接近中位数的数字。不是我最科学的基准
输入数据 | 经过的时间(秒) |
1,000 | 0.11 |
10,000 | 0.48 |
100,000 | 4.31 |
1,000,000 | 44.05 |
然而,结果是相当清楚的。这个程序与输入的比例是线性的,而且是一个相当慢的实现。从技术上讲,我们甚至不需要迭代来计算FizzBuzz,但一般来说,这种方法很慢,因为我们每次迭代做的新工作太少。我们可以通过改变代码使其以对数的方式进行迭代,即每次迭代产生的新值是前一次的两倍,并巧妙地避免重复,而不使用像distinct这样的有状态的操作符,从而使其速度提高~50倍。所有这些优化的代码在 here. 总之,有了这些,我们就可以从FizzBuzz继续前进,并努力实现Life。
生命游戏
让我们简单地谈一下规则。我们有一个(无限的)方形单元格。所有的单元格都有8个相邻的邻居,所有的单元格要么是 "死 "要么是 "活",游戏在离散的回合中演变,如下所示。
- 任何有两个或三个活体邻居的活体细胞在下一轮都会保持活体。(这个故事的寓意:你需要有朋友,但不要太多。)
- 任何有三个活体邻居的死细胞在下一轮都会变成活体(这就是婴儿的产生方式)。
- 所有其他活细胞在下一轮中死亡。所有其他的死细胞在下一轮中保持死亡。(有些细胞自然死亡,有些被邻居杀死;没有僵尸)。
让我们先想象一下我们如何用一种命令式语言来做这件事。例如,我们可以使用一个数组来存储单元格,并使用一个双重嵌套的for-loop来迭代单元格,并通过轮回来演化它们的状态。我不会费力写下一个完整的Rust实现,因为很明显,这不会给我们带来很多关于如何用数据流图来表达它的启示。
相反,让我们做出一些具体的决定,看看我们要如何用集合来表示这个问题,并看看我们是否能勾勒出我们需要做什么来使事情顺利进行。我建议我们用一对整数(x,y)来表示单元格,表示它们在网格中的坐标,此外,我们保留一个回合中 "活着 "的单元格的集合,并使用迭代法在回合中进化这个集合。与FizzBuzz不同,Life并不保证会终止。现在,我们先不考虑这个问题,以后再来讨论。
我们现在所处的位置与之前的位置非常相似。我们想写一个数据流图,它可以接收一组配对作为输入,并产生一组新的配对作为输出。与FizzBuzz不同的是,从每个输入元素到输出元素没有一个自然的映射。相反,输出取决于每个单元的活体邻居的数量。在一个命令式的设置中,我们可能会写代码询问 "我的邻居中有多少是活的?"的网格中的每个单元。在Differential中,如果我们让每个活的单元宣布 "这些是我的邻居!"然后计算每个宣布的邻居与多少个活细胞相邻。
用散文很难完全描述这个想法,所以让我们通过一个直观的例子。想象一下,这是我们的网格在某一轮的状态,蓝色阴影的单元格是活的,其余的是死的。
我们可以将下一轮的潜在活细胞形象化,如下所示。只有下面填充的单元格在本轮至少有一个活的邻居,所以它们是唯一可能在下一轮活的单元格。浅粉色阴影的单元格正好有一个活的邻居,粉色阴影的单元格有两个活的邻居,最中间的一个单元格用深品红色阴影,有三个活的邻居。这是唯一一个在下一轮将是活的单元。蓝色边框的单元格代表在前一轮中本身是活的,但不幸的是,它们都没有足够的活邻居来进入下一轮。我们想写一个数据流片段,从之前的活细胞(上面的蓝色细胞)开始,并给出潜在的活细胞(下面的阴影细胞),然后再过滤到下一轮只有活细胞。
首先,我们必须让每个活细胞提出其所有的邻居。幸运的是,我们可以通过一点算术来做到这一点。
我们可以用 flat_map操作符来做这个算术,从每个活细胞中生成8个邻居,然后我们可以 count每个邻居被发射的次数(由以前的活细胞发射),最终得到一个可能符合条件的细胞集合,以及每个细胞的活邻居的数量。我在右边添加了一些英文注释,以配合左边的每个集合的类型签名。
这一部分的代码是。
let maybe_live_cells = live_cells.flat_map(|(x, y)| {
[
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]
.iter()
// This map is a function over an iterator, not a dataflow operator.
.map(move |(dx, dy)| ((x + dx, y + dy))
})
.count();
接下来,我们需要想出一个办法,将上面的进化规则1-3应用于这个也许活着的细胞集合。按照写法,这些规则要求我们知道 "这个细胞在上一轮中是否是活的",而我们目前还没有机会知道。但是,如果我们把规则1和2稍微换位一下,我们就可以把它们改写成。
- 所有有3个活邻居的细胞在下一轮都是活的。
- 所有拥有2个活体邻居且目前是活体的细胞在下一轮中保持活体。
现在我们可以根据所掌握的数据采取行动了。我们可以过滤掉有3个邻居的单元格;所有这些单元格在下一轮中都会被激活。我们还可以过滤出有2个邻居的单元格,现在将它们与之前的live_cells进行半连接,以找出其中哪些单元格之前是活的。最后,我们可以将这两个结果集合连接起来,这就是下一轮的活细胞集合和FizzBuzz一样,这个逻辑描述了生命的一次迭代,我们可以把整个过程放在一个迭代循环里,这就是完整的实现了。
让我们退一步,看看我到目前为止只是口头上描述的数据流图。
这个片段的代码是为 "生命 "做逻辑处理的,它的代码与此非常接近。
live_cells.iterate(|live| {
let maybe_live_cells = live
.flat_map(|(x, y)| {
[
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]
.iter()
.map(move |(dx, dy)| ((x + dx, y + dy)))
})
.count();
let live_with_three_neighbors = maybe_live_cells
.filter(|(_, count)| *count == 3)
.map(|(cell, _)| cell);
let live_with_two_neighbors = maybe_live_cells
.filter(|(_, count)| *count == 2)
.semijoin(&live)
.map(|(cell, _)| cell);
let live_next_round = live_with_two_neighbors
.concat(&live_with_three_neighbors)
.distinct();
live_next_round
})
基本上就是这样了我们可以运行这个程序(src/main.rs中的其余代码用一个起始活细胞列表来进行计算,以便生命在几轮中收敛)。
altaria-2:life-differential $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/life-differential`
x: 1, y: 3, time: 0 diff: 1
x: 2, y: 2, time: 0 diff: 1
x: 2, y: 3, time: 0 diff: 1
x: 3, y: 2, time: 0 diff: 1
x: 1, y: 2, time: 0 diff: 1
x: 3, y: 3, time: 0 diff: 1
x: 2, y: 1, time: 0 diff: 1
x: 2, y: 2, time: 0 diff: -1
x: 2, y: 3, time: 0 diff: -1
x: 2, y: 4, time: 0 diff: 1
这不是最惊心动魄的图形:)。你可以编辑起始单元的列表,看到更复杂和无限进化的网格,其中有一些有趣的东西,如 gliders或其他更奇特的自动装置。虽然这在技术上是一个有效的生命游戏的实现,但目前它并不十分有用,因为我们不能要求它进化,比如说,未来的20次迭代。它是全有或全无的,直到游戏状态收敛到一个固定点才会停止,而这可能永远不会发生。我们将解决所有这些问题,并在下一篇文章中比较差分版的性能和更标准的实现。此外,我们还将展示Differential让你用部分有序时间做的一些非常令人费解的时间旅行式的事情,这些事情用手是很难做到的。
附录:集合
集合是时间变化的、广义的多集。 multiset基本上是一个集合的relaxation,其中每个元素都可以有多个副本。一个有3根香蕉、一个苹果和两个胡萝卜的杂货袋是一个多集,包含了香蕉、苹果、胡萝卜的食物集合。在这个杂货袋中,香蕉的多重性是3。 在集合中,多重性也可以是负数。在集合中,我们可以把负的乘数看作是删除,而把正的乘数看作是在不同时间的插入。
假设我们有一个水果和蔬菜的集合。我们将把进入集合的更新表示为3个图元(data, time, multiplicity),在这种情况下,data是一种水果或蔬菜,时间是一个正整数,表示这个数据在逻辑上应该被应用,而multiplicity是一个正或负的整数,表示这个更新的多重性如何变化。请注意,时间可以部分排序,但在这个例子中,它们将是完全排序的。
例如,让我们说我们得到了以下三个更新。
(土豆,0,1)
(苹果, 0, 1)
(土豆, 0, 3)
如果这是时间0的所有更新,那么这个集合在时间0时的最终状态将是。
(苹果, 0, 1)
(土豆, 0, 4)
如果在1和2的时候,我们得到一些更多的更新,如
(猕猴桃,1,6)
(苹果,1,1)
(香蕉, 1, -2)
(苹果, 2, -2)
那么在时间2的时候,集合的最终状态将是。
(香蕉, 2, -2)
(土豆,2,4)
(猕猴桃,2,6)
尽管我尽了最大努力,我们仍然有-2个香蕉。这可能看起来很奇怪,但实际上在微分土地上是可以的。这种表示方法和负数乘法的灵活性的好处是,随着时间的推移,它真的很容易合并对同一数据的不同更新。
- 另一种思考方式是,普通代码让我们决定哪些代码块在其他代码块之前或之后执行。在数据流代码中,我们无法做出这样的决定。 ?
- 事实上,你可以只用flat_map来计算FizzBuzz,因为一个值和下一个值之间实际上并没有依赖关系。但这是一个很好很简单的例子 :) ?
- 这已经不完全是事实了,但仍然是一个有用的心理模式。参见 this post以了解更复杂的集合的使用情况,这些集合并不是真正的多集,而是从(数据,时间)到单数实例的映射。 ?