FRP - 事件流和信号 - 仅使用信号会失去什么?

24
在最近的Classic FRP实现中,例如reactive-banana,有事件流和信号,它们都是步进函数(reactive-banana将它们称为behaviours,但它们仍然是步进函数)。我注意到Elm只使用信号,并且不区分信号和事件流。此外,reactive-banana允许从事件流转换为信号(编辑:虽然reactimate可以对behaviours进行操作,但这不被认为是一种好的实践),这在理论上意味着我们可以通过先将信号转换为事件流,应用并再次转换来应用所有事件流组合器到信号/behaviours上。因此,考虑到通常只使用和学习一个抽象更容易,拥有分离的信号和事件流的优势是什么?仅使用信号并将所有事件流组合器转换为操作信号是否会有任何损失?
编辑:讨论非常有趣。我自己得出的主要结论是,behaviours/event sources都需要互相递归定义(反馈)并且输出取决于两个输入(一个behaviour和一个event source),但仅当其中一个更改时才会引起动作(<@>)。

2
恩,就我理解来说,行为不是步进函数,而是随时间持续的? - Bergi
3
这篇文章可能是一篇好的阅读材料。 - Bergi
是的,行为不是步进函数,但据我所知,reactive-banana所称的行为实际上就是步进函数,而elm和reactive-web则称之为信号。感谢提供链接,我已经阅读了那篇论文。 - miguel.negrao
@Bergi 我认为从理论上讲它们应该是连续的,但在实际实现中真正的连续性很难实现,这就是为什么它们被这样实现的原因。 - xji
@JIXiang 是的,在实际实现中,它们只能被间歇性地采样。但理论上的差异很重要,它改变了我们对它们的看法。 - Bergi
显示剩余2条评论
4个回答

25
(澄清:在reactive-banana中,不可能将Behavior转换为Eventstepper函数是一个单向门票。有一个changes函数,但其类型表明它是“不纯的”,并且带有一个警告,说明它不能保留语义。)
我认为拥有两个不同的概念可以使API更加优雅。换句话说,这归结为API的可用性问题。 我认为这两个概念的行为有足够不同,如果你有两个不同的类型,事情会更顺利。
例如,每种类型的直积是不同的。 一对行为等同于一对的行为。
(Behavior a, Behavior b) ~ Behavior (a,b)

一对事件相当于一个直接的事件:

(Event    a, Event    b) ~ Event (EitherOrBoth a b)
如果您将这两种类型合并为一种类型,则这些等价关系都将不再成立。
然而,将事件和行为分开的主要原因之一是后者没有“更改”或“更新”的概念。这开始可能看起来像是一种省略,但实际上它非常有用,因为它能够使代码更简单。例如,考虑一个单子函数newInput,它创建一个输入GUI小部件,显示参数行为中指定的文本。
input <- newInput (bText :: Behavior String)
现在关键的一点是,显示的文本并不取决于行为 bText 更新了多少次(无论是更新为相同的值还是不同的值),而只取决于实际的值本身。这比另一种情况容易理解得多,因为在另一种情况下,您必须考虑当两个连续事件发生具有相同值时会发生什么。在用户编辑文本时重新绘制文本吗?
(当然,为了实际绘制文本,库必须与 GUI 框架进行接口交互,并跟踪行为中的更改。这就是“changes”组合器的作用。然而,这可以看作是一种优化,不能从“FRP内部”使用。)
另一个分离的主要原因是递归。大多数递归依赖于自身的事件都是未定义的。但是,如果您在事件和行为之间具有相互递归,则始终允许递归。
e = ((+) <$> b) <@> einput
b = stepper 0 e

不需要手动引入延迟,它直接可用。


你的回答非常有启发性。只有一件事让我感到困扰。 - miguel.negrao
另外,我认为在elm中没有一种方法可以表达你想要通过Applicative函子(lift或<~)获得的信号只在其中一个信号更新时更新,就像在reactive-banana中使用'apply'组合器一样。必须创建一种特殊类型的“lift”。我认为这种能力非常需要... - miguel.negrao
1
我已经更新了我的答案,涉及到“对行为做出反应”的点。基本上,这个想法是行为不包括变化的概念,所以你从来不会对它们做出反应(至少在FRP中是这样)。获取行为的值的唯一方法是使用apply组合器。这是一件好事,因为它可以避免你去考虑行为已经更新但其值仍然相同的情况。 - Heinrich Apfelmus
嗯,reactimate'仍然存在问题,因为它允许您观察破坏语义的事件。您需要确保在某种意义上IO操作是幂等的。我的意思是,这不会伤害任何人,但证明的责任在于您而不是库。我没有将changesreactimate'组合,因为在某些非常特定的情况下,您希望将从changes获取的Event与其他内容合并。 - Heinrich Apfelmus
1
如果你真的想以行为方式合并事件,那么你必须自己编写。这里有一个示例:Tidings类型 来自 threepenny-gui - Heinrich Apfelmus
显示剩余11条评论

16

对我来说非常重要的是失去了行为的本质,即在连续时间上(可能是持续的)变化。 精确、简单、有用的语义(独立于特定实现或执行)经常也会丢失。 请查看我的回答“Specification for a Functional Reactive Programming language”并按照其中的链接进行跟踪。

无论是在时间还是空间上,过早的离散化妨碍了可组合性并使语义复杂化。考虑矢量图形(以及其他像Pan的空间连续模型)。就像数据结构的过早有限化一样,如Why Functional Programming Matters中所解释的那样。


顺便提一下,连续时间是成长为FRP的单一基本思想(如函数响应式编程中的早期灵感和新方向所述),应用了指称语义的优雅和严谨。 - Conal

5
我认为使用信号/行为抽象与elm-style信号相比没有任何好处。正如您指出的那样,可以在信号/行为API之上创建仅信号的API(尚未准备好使用,但请参见https://github.com/JohnLato/impulse/blob/dyn2/src/Reactive/Impulse/Syntax2.hs以获取示例)。我很确定也可以在elm-style API之上编写信号/行为API。这将使这两个API在功能上等效。
关于效率问题,对于仅信号的API,系统应该具有机制,只有更新值的信号才会导致重新计算(例如,如果您不移动鼠标,则FRP网络不会重新计算指针坐标并重新绘制屏幕)。只要这样做,我认为与信号和流的方法相比,没有任何效率损失。我很确定Elm也是这样工作的。
我认为连续行为问题在这里没有任何区别(或者根本没有任何区别)。人们所说的行为随时间连续,是指它们在所有时间都被定义(即它们是连续域上的函数);行为本身不是连续函数。但是我们实际上没有一种方法在任何时间对行为进行采样;它们只能在与事件对应的时间进行采样,因此我们无法使用此定义的全部功能!
从语义上讲,从这些定义开始:
Event    == for some t ∈ T: [(t,a)]
Behavior == ∀ t ∈ T: t -> b

由于行为只能在定义事件的时间进行采样,我们可以创建一个新领域 TX,其中 TX 是所有定义事件的时间 t 的集合。现在我们可以放宽行为定义为:

Behavior == ∀ t ∈ TX: t -> b

在不损失任何功率的情况下(即在我们的FRP系统限制范围内,这等同于原始定义),现在我们可以列举TX中的所有时间,将其转换为:

Behavior == ∀ t ∈ TX: [(t,b)]

这与原始的Event定义完全相同,除了领域和量化。现在我们可以根据TX的定义更改Event的领域,并将Behavior的量化(从forall变为for some),我们就得到了:

Event    == for some t ∈ TX: [(t,a)]
Behavior == for some t ∈ TX: [(t,b)]

现在,EventBehavior在语义上是相同的,因此在FRP系统中可以使用相同的结构来表示它们。在这一步骤中我们会失去一些信息;如果我们不区分EventBehavior,我们就不知道Behavior在每个时间点t都被定义了,但实际上我认为这并不重要。据我所知,elm需要EventBehavior在所有时间点上都有值,并且如果一个Event没有更改,就使用先前的值(即将Event的量化改变为forall而不是改变Behavior的量化)。这意味着您可以将所有内容都视为信号,并且所有内容都可以正常工作;只是实现时,信号域恰好是系统实际使用的时间子集。

我认为这个想法是在一篇论文中提出的(我现在找不到了,还有人可以提供链接吗?),关于在Java中实现FRP,也许是来自POPL'14?由于是靠记忆回忆的,所以我的概述并不像原始证明那样严格。

并没有什么可以阻止您通过例如pure someFunction来创建更明确定义的Behavior,这仅意味着在FRP系统中无法利用那个额外的定义,因此通过更受限制的实现不会失去任何东西。

至于时间等概念信号,注意使用典型编程语言无法实现实际连续时间信号。由于实现必然是离散的,将其转换为事件流是微不足道的。

简而言之,我认为只使用信号不会失去任何东西。


2
据我所知,人们确实意味着行为是随时间连续变化的函数。例如,您会将移动的物体描述为其坐标的一种行为。您可以获得它的导数(随时间变化),并且有一个速度的行为。当然,您说得对,我们只能在离散的时间步长处对这种行为进行采样,但它仍然被定义为能够在任意时刻产生不同值。 - Bergi
1
感谢您详细的回答。这正是我所怀疑的。但实际上,在具有内在时间的拉取式FRP系统中,行为确实与步进函数不同,因为它们可以在事件发生之间重新计算,例如通过依赖于时间行为(即物理模拟)。我认为,只有在使用纯推送式系统时,行为和信号之间的区别才会消失,因为在事件发生之间什么也不会发生。 - miguel.negrao
2
此外,带箭头的 FRP 系统在语义上往往是完全连续的,甚至用 Maybe 的信号函数来表示事件,尽管其中一些又是基于推送的,因此在事件之间什么也不会发生(Yampa)。当然,在推送系统中,您仍然可以使用定时器以固定速率迭代时间。 - miguel.negrao
1
抱歉数学上不准确。我的意思是它们应该是分段连续的,而不是像你的答案所暗示的那样分段常数。你说得对,因为我们只关心TX域,所以用相同的方式表示它们就足够了。只是-如你所说-我们失去了它们连续定义的语义信息。可能还会失去很多效率,所以[(t, t->b)]会更好。 - Bergi
1
TX是所有定义事件的时间t的集合。我想知道是什么让你认为TX不是连续时间的全部。也许你是在说对于特定执行的特定实现,只进行了有限次的时间采样。这些采样时间与(与执行和实现无关的)语义无关。 - Conal
显示剩余7条评论

1

很遗憾我没有具体的参考资料,但我清楚地记得不同的反应式作者声称这种选择只是为了提高效率。你暴露出两个选项,让程序员可以选择哪种实现方式更适合解决问题。

我可能现在在撒谎,但我相信 Elm 在内部将所有内容都实现为事件流。像时间这样的东西就不会像事件流那样好用,因为在任何时间段内都有无限多的事件。我不确定 Elm 是如何解决这个问题的,但我认为这是一个很好的例子,说明某些概念在概念上和实现上更适合作为信号。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接