不可变游戏对象,基本函数式编程问题

8
我正在尝试“学习更多”和“从”函数式编程中学到的东西,以及不可变性对并发等方面的好处。
作为一个思考练习,我想象了一个简单的游戏,在游戏中Mario类型的角色可以在敌人射击他的情况下奔跑和跳跃...
然后我试图想象使用不可变对象来编写这个游戏。
这引起了我一些困惑(我是一名命令式面向对象的程序员)。
1) 如果我的小家伙在位置x10,y100向右移动1个单位,我是否只需使用旧值重新实例化他,并将其x位置加1(例如x11,y100)?
2) (如果我的第一个假设是正确的) 如果我的输入线程将小家伙向右移动1个单位,而我的敌人AI线程射击小家伙并且敌人AI线程在输入线程之前解决,则我的家伙将失去健康,然后在输入线程解决时,恢复健康并向右移动...
这是否意味着即使具有不可变性,我也不能快速启动线程? 当两个线程操作完成时,我需要同步地发送我的线程以执行它们的工作,然后再进行new()up小家伙吗?还是有一个简单的“功能性”解决方案?
这是一个与我日常工作中面临的稍微不同的线程问题。 通常,我必须决定我是否关心线程解决的顺序。而在上述情况下,我技术上并不关心他先受到伤害还是先移动。但是,如果实例化期间的竞争条件导致一个线程的数据完全丢失,则我确实关心。
3) (再次,如果我的第一个假设是正确的)不断地实例化对象的新实例(例如Mario小家伙)是否具有可怕的开销,使其成为非常严重/重要的设计决策?
编辑 抱歉,因为我不知道这里关于后续问题的良好做法...
4) 如果不可变性是我应该追求并且即使跳跃经过改变也要实例化对象的新版本...如果我每次移动时都实例化我的小家伙(仅具有不同的位置),那么我难道不会遇到与可变性相同的问题吗?因为某些在某个时间点引用他的东西实际上正在查看旧值?..我挖得越深,我的头越晕,因为生成具有不同值的相同事物的新版本似乎就像通过黑客进行可变性。
我的问题是:这应该如何工作?它如何有益于仅突变他的位置?
for(ever)//simplified game-loop update or "tick" method
{
   if(Keyboard.IsDown(Key.Right)
      guy = new Guy(guy){location = new Point(guy.Location.x +1, guy.Location.y)};
}

以下代码意味着该对象是可变的!(即使它的属性不是)

4.5) 完全不可变的对象是否可能实现这一点?

谢谢,

J.


阅读系列文章纯函数式复古游戏,其中描述了如何使用大部分纯函数式语言Erlang实现Pac-Man。他所描述的问题是所有函数式游戏编程的共同问题,他提供了对你所有问题的良好、可用的解决方案。 - JSBձոգչ
编辑了新问题(4),因为我只是越来越困惑了;谢谢,J. - jdoig
5个回答

3

以下是针对您的观点的一些评论:

1) 可能如此。为了减少开销,实际设计很可能会在这些实例之间共享很多状态。例如,也许您的小家伙有一个“装备”结构,该结构也是不可变的。新副本和旧副本可以安全地引用相同的“装备”结构,因为它是不可变的;因此,您只需要复制一个引用,而不是整个结构。这是您只有在使用不可变性时才能获得的常见优势--如果“装备”是可变的,则无法共享引用,因为如果它发生更改,则您的“旧”版本也会发生更改。

2) 在游戏中,这个问题最实用的解决方案可能是拥有一个全局“时钟”,并且在时钟周期内进行这种处理。请注意,如果您没有按照函数式风格编写代码,那么您的确切情况仍然是个问题:假设H0是时间T处的健康值。如果您将H0传递给一个函数,该函数决定了时间T处的健康状况,您将在时间T+1受到损伤,然后该函数在时间T+5返回,它可能已经根据您当前的健康状况做出了错误的决定。

3) 在鼓励函数式编程的语言中,对象实例化通常尽可能便宜。我知道在JVM上,将小对象创建在堆上非常快,实际情况下几乎从不考虑性能问题,在C#中,我从来没有遇到过需要考虑性能的情况。


在函数式编程的不可变世界中,将老人包装在装饰器类中是否可接受? 例如,老人吃了一颗神奇的加速药丸,所以我设置guy = new GuyDecorator_SpeedPowerUp(guy); 或者装饰器被视为一种“突变”形式? - jdoig
糟糕!我刚意识到这会使那个人成为可变的,因为他被赋予了不同的值...别担心,我迟早会掌握这个技巧的;¬) - jdoig

2
如果我的小家伙在位置x10,y100向右移动1个单位,我只需使用他的旧值重新实例化他,并将其x位置加1(例如x11,y100)吗?
嗯,并不一定。您可以实例化该角色一次,并在游戏过程中更改其位置。您可以使用代理来建模这一点。该角色是代理,AI也是代理,渲染线程也是代理,用户也是代理。
当AI射击该角色时,它会发送一条消息,当用户按下箭头键时,会发送另一条消息,依此类推。
 let guyAgent (guy, position, health) =
     let messages = receiveMessages()
     let (newPosition, newHealth) = process(messages)
     sendMessage(renderer, (guy, newPosition, newHealth))
     guyAgent (guy, newPosition, newHealth)

“一切”现在都是不可变的(实际上,在代理的调度队列下,可能仍有一些可变状态)。
  1. 如果不可变性是我应该追求的东西,甚至需要通过实例化已更改对象的新版本来跳过障碍...而且如果我每次移动时实例化我的人物角色(只是位置不同),那么我是否会遇到与可变值相同的问题?
是的,使用可变值循环和使用不可变值递归是等效的。
编辑:
  1. 对于代理商来说,维基百科 总是很有帮助的。
  2. Luca Bolognese 有一个 F# 实现 的代理。
  3. 这本书(有些人称之为 智能代理书),虽然针对的是 AI 应用程序(而不是软件工程的视角),但非常优秀。

太棒了!我不知道你是否有关于“Agent”模式的任何信息链接呢?再次感谢, J. - jdoig

0

另一种解决这类问题的功能性方法是退后一步,将状态的概念与你的小家伙的概念分开。

你的状态将包括你的小家伙的位置,以及你的坏蛋和它的射击的位置,然后你有一些函数,这些函数需要一些或所有的状态,并执行生成下一个状态和绘制屏幕等操作。

当你想要并行化的事物彼此依赖时,你所谈论的时间问题是真正的问题,虽然不同语言的解决方案可能更或少方便。

已经提出了几个建议,也有各种并发解决方案。中央时钟和代理可以工作,软件事务内存、互斥锁或CSP(go风格通道)也可以,还可能有其他解决方案。最好的方法将取决于问题的具体情况,某种程度上也取决于个人喜好。

至于让人头晕的问题,请尽量不要过于关注一件事是否在改变。不可变性的重点不在于事物不会改变,而在于你可以创建纯函数,使得你的程序更容易推理。

例如,一个面向对象的程序可能有一个绘图函数,它遍历场景中的所有对象,并要求它们自己绘制,而一个函数式程序可能有一个函数,它接受一个状态并绘制一帧。
最终结果将是相同的场景,但逻辑和状态的组织方式非常不同。
就我个人而言,当你把所有数据都放在这里,形成一个大的输入块,所有的绘图逻辑都封装在一些函数中时,我发现工作起来更容易。这种结构还有一些明显的架构优势——序列化、测试和交换前端变得更加容易。

0

如果全局系统状态中除了当前堆栈帧以外的所有内容都是不可变的,除非将堆栈中的某个东西的引用传递给另一个线程(非常危险),否则线程之间就没有任何影响对方的方法。你可以快速启动并忘记,或者干脆一开始就不要启动,效果是一样的。

假设全局状态中有一些部分是可变的,则一种有用的模式是:

进行以下操作
  锁定可变对象的引用
  基于锁定的引用生成新对象
循环直到比较并交换成功。

如果比较交换仍然指向旧对象,则应更新可变引用为新对象。如果没有并发访问,则此方法避免了锁定的开销,但如果许多线程都尝试更新同一对象并从获取的引用生成新实例很慢,则可能性能较差。此方法的一个优点是不存在死锁的危险,但在某些情况下可能会出现活锁。


-1

你的程序中并不是所有东西都应该是不可变的。玩家的位置是你期望它是可变的。他的名字,也许不需要。

不可变性是好的,但你应该重新考虑你的方法,使用更多并发解决方案而不是简单地“不可变化”一切。考虑这个:

线程AI获取你位置的副本 你向左移动三个单位。 AI基于你旧的位置射击你,结果命中了...这不应该发生!

此外,大多数游戏都是在“游戏时钟”中完成的 - 没有太多的多线程运行!


你似乎从命令式而不是函数式的角度来看待这个问题。我不会期望在 Haskell 程序中位置可变。 - Chuck
@chuck 也许你是对的,我似乎确实忽略了手头问题中非常重要的一个方面。 - corsiKa

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