适当的抽象代替异构(但共享-class)列表?

Appropriate abstraction in lieu of heterogenous (but shared-class) list?

问题

我正在尝试用 PureScript 编写游戏引擎。我是新手,但是自从我之前经历过 Real World Haskell 以来学习一直很顺利(尽管我也没有太多使用 Haskell 来处理 "real" 事情的经验) .任何将我的运行时错误尽可能多地转化为编译时错误的东西,在我看来都是一个胜利——但如果这种语言被证明过度限制了我抽象出问题的能力,它可以消除一些胜利。

好的,所以,我正在尝试在 HTML5 Canvas/context2d 上用 PureScript 构建一个 2d 游戏引擎(显然 purescript-canvas 是一个很好的选择 - 我比 Elm 的 Graphics.Canvas 模块更喜欢它,因为它与实际的底层 JS API 映射得更紧密,特别是让我可以访问 Canvas 的各个像素)。 =32=]

在我现有的(未完成但可用的)JS 引擎中,核心功能是我会保留一个 "sprites" 的列表(异构的,除了它们都共享一个公共的 class),并循环遍历它们以调用 .update(timeDelta).draw(context2d) 方法。

精灵都共享一个通用界面,但必须在后台支持根本不同的数据。一个可能有 x/y 个坐标;另一个(可能代表环境影响)可能具有 "percent complete" 或其他动画状态。

问题是,我无法想出一个等效的抽象(对 heterogeneous/shared-class 列表)来完成我需要它做的事情,而不滥用 FFI 来侵入我的方式非常不纯的代码。

解决方案(及其问题)

异构列表(废话)

显然,可以等效于异构列表的最佳抽象是异构列表。

Haskell-样式

事实证明 Haskell(即伪造的 GHC,不是官方 spec/report)提供 exactly what I want - 您可以在保留 [=114 的同时打包类型信息=] 约束,在列表中的所有项目上应用单个多态函数,而不会破坏类型安全。这将是理想的,但是 PureScript 目前不允许我表达这样的类型:

data ShowBox = forall s. Show s => SB s

PureScript 风格(最先进的)

对于 PureScript,有 purescript-exists 包,它可能旨在提供与上面 Haskell 解决方案等效的功能,并且让我——不是隐藏,而是 remove——输入信息,重新输入。这会让我有一个异构列表,但以完全破坏类型安全为代价。

更重要的是,我认为我无法使它的工作令我满意,因为即使我有 [Exists f] 的列表,我也不能只是 extract/re-add 类型作为通用 forall a. (Draw a) => a——我必须知道我要恢复的实际类型。我可以包含某种 "tag" 类型,告诉我应该提取哪种 "real" 类型,但如果我要提取这些类型的恶作剧,我还不如用纯 JS 编码。我可能必须这样做(对于列表,不一定包含精灵)。

一个海量数据值中的所有状态

我可以将所有精灵统一为同一类型,方法是在一个巨大的结构中表示单个精灵的所有状态,将其传递给每个精灵的 "update" 实现(仍然不能使用 class 多态性,但我可以为每个单独的精灵值包含一个突变函数作为类型的一部分,并使用它)。这很糟糕,原因很明显:每个精灵都可以自由 mutate/update 其他精灵的数据。对于我必须表示的每种新的精灵状态,必须在全球范围内更新海量数据结构。不能把它做成一个库,因为每个使用引擎的人都必须修改它。也可能是JS。

独立的同质状态类型

或者每个精灵都可以有单独的状态,并且都具有相同的状态表示。这将避免 "fingers in each others' pies" 场景,但我仍然有一个统一的结构,我必须更新每个精灵的需求的过多知识,每个精灵不需要的类型结构的那些位的大量浪费数据.抽象性很差。

表示JSON中的不同数据或者你有什么

呃。这种方式基本上 只是使用 JS 数据并假装它是 PureScript。不得不放弃 PureScript 类型的所有优势。

没有抽象

我可以将它们全部视为完全不相关的类型。这意味着如果我想添加一个新的精灵,我必须更新最外层的 draw 函数以添加一个 drawThisParticularSprite,最外层的 update 函数也是如此。可能是所有可能解决方案中最糟糕的。

我可能会做什么

假设我对我可用的抽象选择的评估是正确的,那么很明显我将不得不以一种或另一种方式滥用 FFI 来做我需要的事情。也许我会有一个统一的记录类型,比如

type Sprite = { data: Data, draw: Data -> DrawEffect, update: Data -> Data }

其中 Data 是一些杂乱无章的类型删除的东西,比如某种 Exists f,并且

type DrawEffect = forall e. Eff (canvas :: Canvas | e) Context2D

之类的。 drawupdate 方法都特定于单个记录,"know" 都是从 Data.

中提取的真实类型

与此同时,我可能会继续询问 PureScript 开发人员有关支持 Haskell 风格存在主义内容的可能性,这样我就可以在不破坏类型安全的情况下获得适当的、真正的异构列表。我认为主要的一点是(对于 Haskell example previously linked),ShowBox 必须存储其(隐藏)成员的实例信息,因此它知道 [=27= 的正确实例] 来使用,从它自己对 show 函数的覆盖。

请求

有人可以确认以上关于我目前在 PureScript 中的可用选项是否准确吗?如果您有任何更正,我将不胜感激,特别是如果您发现了一种更好的方法来处理这个问题——尤其是如果有一种方法允许我只使用 "pure" 代码而不牺牲抽象——请告诉我!

我在这里假设你的 Draw class 看起来像

class Draw a where
  draw :: a -> DrawEffect
  update :: a -> a

purescript-exists 选项可以工作,而且它绝对是类型安全的,尽管您声称要删除信息而不是隐藏信息。

您需要将 class 上的操作移动到一个类型中:

data DrawOps a = DrawOps { "data" :: a
                         , draw :: a -> DrawEffect
                         , update :: a -> a 
                         }

现在,你要的类型是Exists DrawOps,可以放到一个列表中,例如:

drawables :: List (Exists DrawOps)
drawables = fromArray [ mkExists (DrawOps { "data": 1
                                          , draw: drawInt
                                          , update: updateInt
                                          }
                      , mkExists (DrawOps { "data": "foo"
                                          , draw: drawString
                                          , update: updateString
                                          }
                      ]

您可以(安全地)使用 runExists 解包类型,注意 runExists 的类型强制您忽略包装数据的类型:

drawAll :: List (Exists DrawOps) -> DrawEffect
drawAll = traverse (runExists drawOne)
  where drawOne (DrawOps ops) = ops.draw ops."data"

但是,如果这些是您 class 中唯一的操作,那么您可以使用同构类型

data Drawable = Drawable { drawn :: DrawEffect
                         , updated :: Unit -> Drawable
                         }

想法是这种类型表示 DrawOps:

中操作的展开
unfoldDrawable :: forall a. DrawOps a -> Drawable
unfoldDrawable (DrawOps ops) 
  = Drawable { drawn: ops.draw ops."data"
             , updated: \_ -> unfoldDrawable (DrawOps (ops { "data" = ops.update ops."data" })) 
             }

现在您可以用 Drawable 包含不同类型数据的事物填充列表:

drawables :: List Drawable
drawables = fromArray [ unfoldDrawable 1     drawInt    updateInt
                      , unfoldDrawable "foo" drawString updateString
                      ]

同样,您可以安全地解包类型:

drawAll :: List Drawable -> DrawEffect
drawAll = traverse drawOne
  where drawOne (Drawable d) = d.drawn

updateAndDrawAll :: List Drawable -> DrawEffect
updateAndDrawAll = traverse updateAndDrawOne
  where updateAndDrawOne (Drawable d) = (d.updated unit).drawn

@phil-freeman(和任何其他读者):作为参考,这是我从你的答案的存在部分改编的代码的完整工作版本,以供我自己验证(在底部找到). (这是一个自我回答,以避开评论的文本长度限制,而不是因为它是一个实际的答案)

所以,很明显我在 Exists 工作原理的一些关键方面是错误的。我已经阅读了源代码,但作为 PureScript 的新手,我认为我无法正确阅读 runExists 的 Rank-2 类型。我听说过 Rank-N 类型,并且了解它们限制了 forall 的范围,但不明白为什么这有用 — 现在我明白了。 :)

据我了解,它用于 runExists 会强制其函数参数适用于所有 DrawOps,而不仅仅是某些——这就是为什么它必须依赖于 DrawOps(而且只有它)具有自我意识和 DTRT 及其更新方法。

我也花了一点时间才弄明白你用非 Exists 示例做了什么,但我想我现在明白了。 Drawableupdated 函数的 \_ -> ... 定义让我有些吃惊,可能是因为我怀疑这种技术在惰性求值 Haskell 中不是必需的,但是在 PureScript 中,当然它需要一个函数来防止它同时展开所有内容。

我在想,也许非Exists方法是次等的,因为它不允许任何人对除它自己之外的数据进行操作...但是当然反思,那是废话,因为同样Exists 方法是正确的——它 看起来 就像一个局外人能够玩弄数据(在,比方说,drawOne)——但我猜类型runExists 保证任何此类 "outsider" 必须完全依赖 DrawOps 自己的方法来处理有关数据的任何特定内容,因此它们等同于同一件事。

一些 sprites/Drawables 实际上需要更多地了解每个 other/interact - 例如,碰撞检查和目标跟踪,所以我必须扩展适当地允许 DrawOps 或 Drawables 显示更多信息的可用功能,但我想我现在应该能够管理它。

感谢您的极具教育意义的解释!

(工作 Exists 示例代码如下,供其他好奇的读者使用:)

module ExistsExample where

import Data.Exists
import Data.List
import Control.Monad.Eff.Console
import Control.Monad.Eff
import Prelude
import Data.Traversable

main :: forall e. Eff (console :: CONSOLE | e) Unit
main = do
    let all = updateAll $ updateAll drawables
    drawAll all

type DrawEffect = forall e. Eff (console :: CONSOLE | e) Unit

data DrawOps a = DrawOps { "data" :: a
                         , draw :: a -> DrawEffect
                         , update :: a -> a
                         }

updateInt = (+1)
updateString = (++ ".")

drawables :: List (Exists DrawOps)
drawables = fromFoldable $ [ mkExists (DrawOps { "data": 1
                                          , draw: print
                                          , update: updateInt
                                          })
                           , mkExists (DrawOps { "data": "foo"
                                          , draw: print
                                          , update: updateString
                                          })
                           ]

drawAll :: List (Exists DrawOps) -> DrawEffect
drawAll list = do
    traverse (runExists drawOne) list
    return unit
  where
    drawOne :: forall a. (DrawOps a) -> DrawEffect
    drawOne (DrawOps ops) = ops.draw ops."data"


updateAll :: List (Exists DrawOps) -> List (Exists DrawOps)
updateAll =
    map (runExists updateOne)
  where
    updateOne :: forall a. DrawOps a -> Exists DrawOps
    updateOne (DrawOps ops) = mkExists (DrawOps ( { draw: ops.draw
                                        , update: ops.update
                                        , "data": ops.update ops."data" } ))