66

假设您正在 Haskell 中构建一个相当大的模拟。有许多不同类型的实体,它们的属性会随着模拟的进行而更新。例如,假设您的实体称为猴子、大象、熊等。

维护这些实体状态的首选方法是什么?

我想到的第一个也是最明显的方法是:

mainLoop :: [Monkey] -> [Elephant] -> [Bear] -> String
mainLoop monkeys elephants bears =
  let monkeys'   = updateMonkeys   monkeys
      elephants' = updateElephants elephants
      bears'     = updateBears     bears
  in
    if shouldExit monkeys elephants bears then "Done" else
      mainLoop monkeys' elephants' bears'

mainLoop函数签名中明确提到每种类型的实体已经很丑陋了。你可以想象如果你有 20 种实体,它会变得多么糟糕。(20 对于复杂的模拟来说并非不合理。)所以我认为这是一种不可接受的方法。但它的可取之处在于,like 函数updateMonkeys的作用非常明确:它们获取一个 Monkey 列表并返回一个新的。

所以接下来的想法是将所有内容滚动到一个包含所有状态的大数据结构中,从而清理 的签名mainLoop

mainLoop :: GameState -> String
mainLoop gs0 =
  let gs1 = updateMonkeys   gs0
      gs2 = updateElephants gs1
      gs3 = updateBears     gs2
  in
    if shouldExit gs0 then "Done" else
      mainLoop gs3

有人会建议我们GameState在 State Monad 中结束,然后updateMonkeysdo. 没关系。有些人宁愿建议我们用函数组合来清理它。也很好,我想。(顺便说一句,我是 Haskell 的新手,所以也许我对其中的一些错误。)

但问题是,像updateMonkeys这样的函数并没有从它们的类型签名中给你有用的信息。你不能确定他们在做什么。当然,updateMonkeys是一个描述性的名称,但这并不能算是安慰。当我传入一个上帝对象并说“请更新我的全局状态”时,我感觉我们回到了命令世界。感觉就像是另一个名字的全局变量:你有一个对全局状态做某事的函数,你调用它,你希望它是最好的。(我想你仍然可以避免一些在命令式程序中会出现在全局变量中的并发问题。但是,并发几乎不是全局变量的唯一错误。)

另一个问题是:假设对象需要交互。例如,我们有一个这样的函数:

stomp :: Elephant -> Monkey -> (Elephant, Monkey)
stomp elephant monkey =
  (elongateEvilGrin elephant, decrementHealth monkey)

假设这被调用updateElephants,因为这是我们检查是否有任何大象在任何猴子的踩踏范围内的地方。在这种情况下,你如何优雅地将变化传播给猴子和大象?在我们的第二个示例中,updateElephants接受并返回一个上帝对象,因此它可以影响两种更改。但这只会进一步混淆水域并强化我的观点:使用上帝对象,您实际上只是在改变全局变量。如果您不使用上帝对象,我不确定您将如何传播这些类型的更改。

该怎么办?当然,许多程序都需要管理复杂的状态,所以我猜测有一些众所周知的方法可以解决这个问题。

只是为了比较,这就是我可能如何解决 OOP 世界中的问题。会有Monkey,Elephant等对象。我可能有类方法可以在所有活体动物的集合中进行查找。也许您可以按位置、ID 等进行查找。由于查找函数底层的数据结构,它们将保持在堆上分配。(我假设 GC 或引用计数。)它们的成员变量会一直发生变异。任何类的任何方法都可以使任何其他类的任何活体动物发生变异。例如,一个Elephant可以有一个stomp方法会减少传入Monkey对象的健康,并且不需要传递它

同样,在 Erlang 或其他面向角色的设计中,您可以相当优雅地解决这些问题:每个角色都维护自己的循环,从而维护自己的状态,因此您永远不需要上帝对象。消息传递允许一个对象的活动触发其他对象的更改,而无需将一堆东西一直传递到调用堆栈。然而,我听说 Haskell 中的演员不受欢迎。

4

2 回答 2

31

答案是函数式反应式编程(FRP)。它混合了两种编码风格:组件状态管理和时间相关值。由于 FRP 实际上是一整套设计模式,我想更具体一点:我推荐Netwire

基本思想非常简单:您编写许多小的、自包含的组件,每个组件都有自己的本地状态。这实际上等同于时间相关值,因为每次查询此类组件时,您可能会得到不同的答案并导致本地状态更新。然后你结合这些组件来形成你的实际程序。

虽然这听起来很复杂且效率低下,但实际上它只是围绕常规函数的一个非常薄的层。Netwire 实现的设计模式受到 AFRP(箭头函数响应式编程)的启发。它可能足够不同,值得拥有自己的名字(WFRP?)。您可能想阅读教程

无论如何,下面是一个小演示。你的构建块是电线:

myWire :: WireP A B

将此视为一个组件。它是类型B的时变值,取决于类型A的时变值,例如模拟器中的粒子:

particle :: WireP [Particle] Particle

它依赖于粒子列表(例如所有当前存在的粒子)并且本身就是一个粒子。让我们使用预定义的线(具有简化类型):

time :: WireP a Time

这是Time (= Double )类型的时变值。好吧,是时候了(从 0 开始,从有线网络启动时算起)。由于它不依赖于另一个随时间变化的值,因此您可以随心所欲地提供它,因此是多态输入类型。还有一些固定线(不随时间变化的时变值):

pure 15 :: Wire a Integer

-- or even:
15 :: Wire a Integer

要连接两条线,您只需使用分类组合:

integral_ 3 . 15

这为您提供了一个从 3(积分常数)开始以 15 倍实时速度(15 随时间积分)的时钟。由于各种类实例,电线非常方便组合。您可以使用常规运算符以及应用样式或箭头样式。想要一个从 10 点开始并且是实时速度两倍的时钟吗?

10 + 2*time

想要一个以 (0, 0) 速度开始和 (0, 0) 并以每秒 (2, 1) 每秒加速的粒子吗?

integral_ (0, 0) . integral_ (0, 0) . pure (2, 1)

想要在用户按下空格键时显示统计数据?

stats . keyDown Spacebar <|> "stats currently disabled"

这只是 Netwire 可以为您做的一小部分。

于 2013-03-18T01:47:46.607 回答
1

我知道这是老话题。但是我现在在尝试从 exercism.io 实施 Rail Fence 密码练习时面临同样的问题。看到如此普遍的问题在 Haskell 中没有得到如此低的关注,真是令人失望。我不认为做一些像维护状态这样简单的事情,我需要学习 FRP。所以,我继续谷歌搜索,发现解决方案看起来更简单 - State monad:https ://en.wikibooks.org/wiki/Haskell/Understanding_monads/State

于 2017-12-28T17:32:47.673 回答