在FRP中如何应用行为(和其他类型)?

16
我是一位帮助翻译的助手。

我正在使用reactive-banana编写程序,并想知道如何使用基本FRP构建块来构建我的类型结构。

例如,这里是我真实程序中的一个简化示例:假设我的系统主要由小部件组成——在我的程序中,这些小部件是随时间变化的文本片段。

我可以有

newtype Widget = Widget { widgetText :: Behavior String }

但我也可以

newtype Widget = Widget { widgetText :: String }

当我想谈论时间变化的行为时,我使用Behavior Widget。这似乎让事情更加“简单”,并且意味着我可以更直接地使用Behavior操作,而不必解包和重新打包Widget。
另一方面,前者似乎避免了在实际定义小部件的代码中重复,因为几乎所有的小部件都随时间变化而变化,即使是那些没有变化的小部件,我也会用Behavior来定义,因为它让我以更一致的方式组合它们。
作为另一个例子,对于两种表示法,都有一个Monoid实例(我想在我的程序中有一个),但是后者的实现似乎更自然(因为它只是将列表幺半群提升到新类型)。
(我的实际程序使用Discrete而不是Behavior,但我认为这并不相关。)
同样,我应该使用Behavior (Coord, Coord)还是(Behavior Coord, Behavior Coord)来表示二维点?在这种情况下,前者似乎是显而易见的选择;但是当它是一个表示游戏中实体之类的五元素记录时,选择似乎不太清晰。
实质上,所有这些问题都可以归结为:
在使用FRP时,我应该在哪一层应用Behavior类型?
(同样的问题也适用于Event,尽管程度较小。)
2个回答

6
我在开发FRP应用程序时使用的规则如下:
  1. 尽可能地隔离“变化的事物”。
  2. 将“同时更改的事物”分组为一个 Behavior/Event
原因是,如果您使用的数据类型尽可能原始,那么创建和组合抽象操作就会变得更容易。
这样做的原因是,像 Monoid 这样的实例可以重复使用于原始类型,正如您所描述的那样。
请注意,您可以使用 Lenses 轻松地修改数据类型的“内容”,就像它们是原始值一样,因此额外的“包装/解包”通常不是问题。(有关此特定 Lens 实现的介绍,请参见此最新教程;还有其他Lens
第二个规则的原因是它只是消除了不必要的开销。如果两个事物同时更改,它们“具有相同的行为”,因此应该将它们建模为这样的行为。
因此/简而言之:您应该使用 newtype Widget = Widget { widgetText :: Behavior String },因为它符合第一条规则。您应该使用 Behavior (Coord, Coord),因为它符合第二条规则(因为两个坐标通常同时更改)。

我认为这里镜头没有帮助——以使用Monoid示例为例,像f = liftA2 mappend这样的东西变成了f a b = Widget $ mappend (widgetText a) (widgetText b)。不可否认,提升组合器可以缓解这种痛苦。然而,我不确定你引用我的Monoid示例时想要表达什么——它是为String形式而辩护的,而不是Behavior String形式。 - ehird
你的规则听起来非常不错,我需要再考虑一下。非常感谢您发布这个问题!我暂时不会接受这个答案,因为我想听听其他人的观点和看法,而且这是一个相当微妙的问题。 - ehird
在你的“Widget”示例中,它仅包含“widgetText”,从而使原始提取变得微不足道。如果您在“Widget”中有更多值,则原始提取将比通过透镜提取“Behavior”并以那种方式执行操作要复杂得多。 - dflemstr
啊,你是指像 liftL2 :: Lens a b -> (b -> b -> b) -> a -> a -> a 这样的东西吗?(嗯,我想你可能可以将这个 lifting 模式转化为一个 applicative functor。) - ehird
但这显然存在一个问题:你把结果值放回哪个参数?我认为在多个值上使用镜头是一种误用。 - ehird
当然,对于只关心widgetText的多字段Widget,您无法创建Monoid实例。当Widget具有更多成员时,您将对其执行不同的操作。 我可能没有表达清楚:数据隔离导致实例(例如Monoid)的重复使用。如果该隔离还导致烦人的拆包/重新包装,您可以使用Lenses使得拆包/重新包装变得不那么繁琐。这些是两个单独的观点;我从未说过在Monoid中使用Lenses可能是一个好主意。 - dflemstr

5

我赞同dflemstr的建议

  1. 尽可能地隔离“变化的事物”。
  2. 将同时发生变化的“事物”归为一个行为/事件

并且我想为这些经验法则提供额外的原因。

问题归结为以下几点:你想要表示一对(元组)在时间上发生变化的值,问题是是否使用

a. (行为x,行为y)-一对行为

b. 行为(x,y)-一种成对行为

选择一个而不是另一个的原因是

  • a over b.

    In a push-driven implementation, the change of a behavior will trigger a recalculation of all behaviors that depend on it.

    Now, consider a behaviors whose value depends only on the first component x of the pair. In variant a, a change of the second component y will not recompute the behavior. But in variant b, the behavior will be recalculated, even while its value does not depend on the second component at all. In other words, it's a question of fine-grained vs coarse-grained dependencies.

    This is an argument for advice 1. Of course, this is not of much importance when both behaviors tend to change simultaneously, which yields advice 2.

    Of course, the library should offer a way to offer fine-grained dependencies even for variant b. As of reactive-banana version 0.4.3, this is not possible, but don't worry about that for now, my push-driven implementation is going to mature in future versions.

  • b over a.

    Seeing that reactive-banana version 0.4.3 does not offer dynamic event switching yet, there are certain programs that you can only write if you put all components in a single behavior. The canoncial example would be a program that features variable number of counters, i.e. an extension of the TwoCounter.hs example. You have to represent it as a time-changing list of values

    counters :: Behavior [Int]
    

    because there is no way to keep track of a dynamic collection of behaviors yet. That said, the next version of reactive-banana will include dynamic event switching.

    Also, you can always convert from variant a to variant b without any trouble

    uncurry (liftA2 (,)) :: (Behavior a, Behavior b) -> Behavior (a,b)
    

1
嗯,在 Widget 的情况下,只有一个字段并不是简化,这是我的实际情况,所以没有涉及元组 :) 虽然感谢您的帮助,但它将在未来非常有用!我现在会把 Behavior 放在新类型中。真希望我能接受两个答案 :) - ehird

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