函数式编程-在Has中维护复杂状态

假设您正在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'

Monkey函数签名中明确提及每种类型的实体已经很丑陋。 您可以想象,如果拥有20种实体,它将变得多么可怕。 (对于复杂的模拟,这不是不合理的20。)因此,我认为这是不可接受的方法。 但是它的优点是,像Elephant这样的函数在其工作中非常明确:它们获取Monkey列表并返回一个新列表。

因此,下一个想法是将所有内容都放到一个拥有所有状态的大数据结构中,从而清除Monkey的签名:

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

有人建议我们将Monkey包裹在State Monad中,然后在Elephant中致电Elephant,等等。 有人宁愿建议我们使用功能组合对其进行清理。 我想也可以。 (顺便说一句,我是Haskell的新手,所以也许我对此有些误解。)

但是问题是,像Monkey这样的函数无法从其类型签名中提供有用的信息。 您不能真正确定它们的作用。 当然,Elephant是一个描述性的名称,但这实在没有什么可安慰的。 当我传递一个神物并说“请更新我的全局状态”时,我感到我们回到了当务之急。 感觉就像全局变量一样,您可以使用它的名字:您拥有一个对全局状态有所作为的函数,您将其称为“函数”,并且希望获得最佳状态。 (我想您仍然避免在命令式程序中全局变量会出现一些并发问题。但是,并发并不是全局变量几乎唯一的错。)

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

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

假设在Monkey中有一个叫它的名字,因为这是我们检查是否有大象在任何猴子踩踏的地方。 在这种情况下,您如何优雅地将更改传播给猴子和大象? 在我们的第二个示例中,Elephant接收并返回了一个上帝对象,因此它可以同时影响这两个更改。 但是,这只会进一步模糊我的观点,并强化我的观点:有了上帝的对象,您实际上只是在改变全局变量。 而且,如果您不使用God对象,则不确定如何传播这些类型的更改。

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

仅出于比较的目的,这就是我如何解决OOP世界中的问题。 将有MonkeyElephant等对象。 我可能会有类方法来对所有活体动物进行查找。 也许您可以按位置,按ID进行查找。 由于查找函数基础的数据结构,它们将在堆上保持分配状态。 (我假设是GC或引用计数。)它们的成员变量会一直变异。 任何类别的任何方法都将能够突变任何其他类别的任何活体动物。 例如。 Elephant可以具有stomp方法,该方法将减少传入的Monkey对象的运行状况,并且无需通过该方法

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

rlkw1024 asked 2019-11-03T18:46:44Z
2个解决方案
30 votes

答案是功能反应式编程(FRP)。 它是两种编码样式的混合:组件状态管理和与时间有关的值。 由于FRP实际上是整个设计模式系列,所以我想更具体一点:我建议使用Netwire。

基本思想很简单:您编写了许多小型独立的组件,每个组件都有自己的本地状态。 这实际上等效于时间相关的值,因为每次查询这样的组件时,您可能会得到不同的答案并引起本地状态更新。 然后,您将这些组件组合起来以形成您的实际程序。

尽管这听起来很复杂且效率低下,但实际上它只是常规功能的一个很薄的层。 Netwire实施的设计模式是受AFRP(Arrowized Functional Reactive Programming)的启发。 它可能有足够的不同,以至应得其名(WFRP?)。 您可能需要阅读本教程。

无论如何,都会进行一个小演示。 您的构建块是电线:

myWire :: WireP A B

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

particle :: WireP [Particle] Particle

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

time :: WireP a 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可以为您做的一小部分。

ertes answered 2019-11-03T18:48:13Z
1 votes

我知道这是老话题。 但是,当我尝试从exercism.io实施Rail Fence密码练习时,我现在正面临着相同的问题。 看到如此普遍的问题在Haskell中受到如此关注,这是非常令人失望的。 我不认为要做一些像维护状态那样简单的事情,我需要学习FRP。 因此,我继续使用Google搜索,发现解决方案看起来更简单-State monad:[https://en.wikibooks.org/wiki/Haskell/Understanding_monads/State]

alehro answered 2019-11-03T18:48:38Z
translate from https://stackoverflow.com:/questions/15467925/maintaining-complex-state-in-haskell