函数式编程如何处理从多个位置引用同一对象的情况?


10

我正在阅读和听到,人们(也在此站点上)经常赞美函数式编程范例,强调使所有内容保持不变是多么好的。值得注意的是,人们甚至在传统的命令式OO语言(例如C#,Java或C ++)中也提出了这种方法,不仅是在像Haskell这样的纯函数式语言中,这种语言迫使程序员这样做。

我觉得很难理解,因为我发现可变性和副作用...很方便。但是,考虑到人们当前如何谴责副作用并认为是在可能的情况下消除它们的一种好习惯,我相信如果我想成为一名合格的程序员,我必须让我的乔妮开始对这种范例有一些更好的了解...因此,我的问。

当我发现功能范式存在问题时,一个地方就是自然地从多个地方引用一个对象。让我用两个例子来描述它。

第一个例子是我打算在业余时间制作的C#游戏。这是一种基于回合制的网络游戏,其中两个玩家都有4个怪物组成的团队,并且可以将一个怪物从其团队中移到战场上,在那里它将面对对方玩家发送的怪物。玩家还可以从战场上召回怪物,并用其团队中的另一个怪物代替它们(类似于口袋妖怪)。

在这种情况下,可以从至少2个地方自然引用单个怪物:玩家的团队和战场,其中引用了两个“活动”怪物。

现在,让我们考虑一个怪物被击中并失去20点生命值时的情况。在命令式范式的括号内,我修改了此怪物的health字段以反映此更改-这就是我现在正在做的。但是,这使Monster类可变且相关的函数(方法)不纯净,我认为到目前为止,这仍被认为是一种不好的做法。

即使我允许自己以低于理想状态的方式编写游戏代码,以便希望在将来的某个时候完成游戏,但我还是想知道并理解它应该如何写得正确。因此:如果这是设计缺陷,如何解决?

就我所知,在函数式中,我将对此Monster对象进行复制,使其与旧对象相同,除了该字段。并且该方法suffer_hit将返回此新对象,而不是就地修改旧对象。然后,我同样会复制Battlefield对象,并保持其所有字段相同(除了该怪物)。

这至少有两个困难:

  1. 层次结构可以比Just- Battlefield>的简化示例更深入Monster。我必须对所有字段(除了一个字段)进行这种复制,并在此层次结构中一直返回一个新对象。这将是样板代码,我发现这很烦人,特别是因为功能编程应该减少样板代码。
  2. 但是,一个更为严重的问题是,这将导致数据不同步。野外活动的怪物的生命值会降低。但是,从其控制者那里引用的同一个怪物Team不会。如果我改用命令式风格,那么对数据的每次修改都可以在所有其他代码位置立即看到,在这种情况下,我发现它真的很方便- 但是我得到东西的方式正是人们所说的势在必行!
    • 现在,可以通过Team每次攻击之后的旅程来解决此问题。这是额外的工作。但是,如果以后突然可以从更多地方引用怪物,该怎么办?例如,如果我具有一种能力,例如,让一个怪物专注于另一个不一定在场上的怪物(我实际上正在考虑这种能力)怎么办?我肯定会记得在每次攻击后立即进行一次聚焦怪物的旅程吗?这似乎是一个定时炸弹,随着代码变得越来越复杂,它会爆炸,所以我认为这是没有解决方案的。

当我遇到相同的问题时,从第二个示例中得出一个更好的解决方案的想法。在学术界,我们被告知要在Haskell中编写我们自己设计的语言的口译员。(这也是我被迫开始了解FP是什么的方式)。当我实现闭包时出现了问题。现在可以再次从多个位置引用同一作用域:通过保存该作用域的变量以及任何嵌套作用域的父作用域!显然,如果通过指向该范围的任何引用对该范围进行了更改,则此更改也必须通过所有其他引用可见。

我提出的解决方案是为每个范围分配一个ID,并在Statemonad中保存所有范围的中央字典。现在,变量将仅保留它们绑定到的作用域的ID,而不是作用域本身,并且嵌套作用域还将保留其父作用域的ID。

我猜想在我的怪物战斗游戏中可以尝试相同的方法。相反,它们拥有保存在中央怪物词典中的怪物ID。

但是,我再次看到这种方法存在问题,使我无法毫不犹豫地接受它作为解决问题的方法:

它再次是样板代码的来源。它使单线必然变成三线:以前对单个字段进行单行就地修改现在需要(a)从中央字典中检索对象(b)进行更改(c)保存新对象到中央字典。同样,持有对象和中央字典的id而不是拥有引用会增加复杂性。因为FP的广告是为了减少复杂性和样板代码,所以这暗示我做错了。

我还打算写一个似乎更严重的第二个问题:这种方法引入了内存泄漏。无法访问的对象通常将被垃圾回收。但是,即使没有可访问的对象都引用此特定ID,也无法对中央词典中保存的对象进行垃圾回收。尽管从理论上讲,仔细的编程可以避免内存泄漏(一旦不再需要,我们可以小心地从中央字典中手动删除每个对象),但是这很容易出错,并且FP广告宣传增加了程序的正确性,因此这可能再次不是正确的方法。

但是,我及时发现它似乎是一个已解决的问题。Java提供WeakHashMap了可用于解决此问题的方法。C#提供了类似的功能- ConditionalWeakTable尽管根据文档,它是供编译器使用的。在Haskell中,我们有System.Mem.Weak

存储此类词典是解决此问题的正确功能解决方案,还是我看不到更简单的解决方案?我想象这样的词典的数量很容易增长,而且非常糟糕。因此,如果这些字典区域也应该是不可变的,这可能意味着很多参数传递,或者在支持该功能的语言中,表示单子计算,因为字典将以单子形式保存(但是我再次将其读为纯函数式)语言,尽可能少的代码应该是monadic,而这个字典解决方案会将几乎所有代码都放在Statemonad内;这再次使我怀疑这是否是正确的解决方案。)

经过一番考虑之后,我想我会再问一个问题:构建这样的词典能获得什么?许多专家认为,命令式编程的错误之处在于某些对象的更改会传播到其他代码段。为了解决这个问题,对象应该是不可变的-正是出于这个原因,如果我正确理解的话,对它们所做的更改应该在其他地方不可见。但是现在,我担心对过期数据进行操作的其他代码段,因此发明了中央字典,以便……将某些代码段中的更改再次传播到其他代码段中!因此,我们不是回到带有所有假定缺点的命令式样式,而是增加了复杂性吗?


6
为了提供这种观点,功能不变的程序主要用于涉及并发的数据处理情况 换句话说,是通过一组方程式或产生输出结果的过程来处理输入数据的程序。不可变性在这种情况下有多种帮助,原因有几个:保证多个线程读取的值在其生命周期中不会改变,这大大简化了以无锁方式处理数据的能力以及算法工作原理的原因。
罗伯特·哈维

8
关于功能不变性和游戏编程的肮脏小秘密是,这两件事彼此之间是不兼容的。您实际上是在尝试使用静态的,不可移动的数据结构为动态,不断变化的系统建模。
罗伯特·哈维

2
不要将可变性与不变性视为一种宗教教条。在某些情况下,每种方法都比另一种更好,而不变性并不总是更好,例如,编写具有不变数据类型的GUI工具包绝对是一场噩梦。
whatsisname

1
这个特定于C#的问题及其答案涵盖了样板问题,这主要是由于需要为现有的不可变对象创建经过稍微修改(更新)的克隆而导致的。
rwong

2
一个关键的见解是,该游戏中的怪物被视为实体。另外,每次战斗的结果(包括战斗序号,怪物的实体ID,战斗之前和之后的怪物状态)都被视为某个时间点(或时间步长)的状态。因此,玩家(Team)可以通过(战斗编号,怪物实体ID)元组检索战斗的结果,从而获得怪物的状态。
rwong

Answers:


19

函数式编程如何处理从多个位置引用的对象?它邀请您重新访问您的模型!

为了解释...让我们看一下有时如何编写网络游戏-带有游戏状态的中央“黄金源”副本,以及一组传入的客户端事件,这些事件会更新该状态,然后广播回其他客户端。

您可以了解Factorio团队在某些情况下使其表现良好的乐趣。这是他们模型的简短概述:

我们的多人游戏的基本工作方式是,所有客户端都模拟游戏状态,并且它们仅接收和发送玩家输入(称为输入动作)。服务器的主要职责是代理输入操作,并确保所有客户端在同一滴答中执行相同的操作。

由于服务器需要在执行动作时进行仲裁,因此玩家动作会发生如下变化:玩家动作->游戏客户端->网络->服务器->网络->游戏客户端。这意味着每个玩家动作只有在通过网络往返后才执行。这会使游戏感觉真的很滞后,这就是为什么自从引入多人游戏以来,延迟隐藏是游戏中增加的一种机制。延迟隐藏通过模拟玩家的输入进行工作,而无需考虑其他玩家的动作,也无需考虑服务器的套利。

在Factorio中,我们具有游戏状态,这是地图,玩家,实体和所有内容的完整状态。它根据从服务器收到的操作在所有客户端上确定性地进行模拟。这是神圣的,如果与服务器或任何其他客户端不同,则会发生不同步。

在游戏状态之上,我们有延迟状态。这包含主要状态的一小部分。延迟状态不是神圣的,它只是根据玩家执行的输入操作来表示我们认为游戏状态在将来的外观。

关键是,每个对象的状态在时间轴上的特定刻度处是不可变的。处于全球多人游戏状态的所有事物最终都必须收敛到确定性现实。

并且-这可能是您提出问题的关键。对于给定的滴答,每个实体的状态都是不可变的,并且您会跟踪随时间推移产生新实例的过渡事件。

如果考虑一下,服务器的传入事件队列必须有权访问实体的中央目录,以使其能够应用其事件。

最后,您不想复杂的简单的单行增幅器方法只是简单的,因为您并没有真正准确地建模时间。毕竟,如果健康状况可以在处理循环的中间进行更改,则此刻度中的较早实体将看到一个旧值,而后一个实体将看到一个已更改的值。认真管理这一点意味着至少要区分当前状态(不可变)和下一个状态(正在建设中),这实际上是行之有效的时间线中的两个行!

因此,作为一个广泛的指导,考虑将怪物的状态分解为许多与位置/速度/物理,健康/损坏,资产有关的小物体。构造一个事件来描述可能发生的每种突变,然后将主循环运行为:

  1. 处理输入并生成相应的事件
  2. 生成内部事件(例如由于对象碰撞等)
  3. 将事件应用于当前的不可变怪物,以便为下一个滴答生成新的怪物-在可能的情况下主要复制旧的未更改状态,但在需要时创建新的状态对象。
  4. 渲染并重复下一个刻度。

或类似的东西。我发现在想“我将如何分发它?” 通常,这是一种很好的心理锻炼,可以使我在对事物的生存位置以及它们应如何发展感到困惑时加深我的理解。

感谢@ AaronM.Eshbach的注释,突出显示这是与事件源CQRS模式类似的问题域,在这些域中,您将随着时间的流逝而发生的一系列不可变事件建模为分布式系统中状态的变化。在这种情况下,我们很可能试图通过隔离查询/查看系统中的mutator命令处理(顾名思义!)来清理一个复杂的数据库应用程序。当然更复杂,但更灵活。


2
有关更多参考,请参阅事件源CQRS。这是一个类似的问题域:随着时间的流逝,将分布式系统中的状态更改建模为一系列不可变的事件。
艾伦·艾希巴赫

@ AaronM.Eshbach就是那个!您介意我在回答中加入您的评论吗?这听起来更权威。谢谢!
SusanW

当然不能,请这样做。
艾伦·艾希巴赫

3

您仍然处于当务之急的一半。无需一次考虑一个游戏或事件的历史来思考您的游戏

p1 - send m1 to battlefield
p2 - send m2 to battlefield
m1 - attacks m2 (2 dam)
m2 - attacks m1 (10 dam)
p1 - retreats m1

等等

通过将动作链接在一起以生成不可变的状态对象,您可以在任何给定的点计算游戏的状态。每个播放都是一个函数,该函数接受一个状态对象并返回一个新的状态对象

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.