【问题标题】:Subtype polymorphism in HaskellHaskell 中的亚型多态性
【发布时间】:2012-08-17 09:13:30
【问题描述】:

构建 GUI 小部件类的层次结构几乎是面向对象编程中的标准练习。你有某种抽象的Widget 类,有一个抽象的小部件的子类可以包含其他小部件,然后你有大量支持文本显示的小部件的进一步抽象类,支持作为输入焦点的小部件,具有布尔状态,一直到实际的具体类,例如按钮、滑块、滚动条、复选框等。

我的问题是:在 Haskell 中最好的方法是什么?

有很多事情使构建 Haskell GUI 变得困难,但 不是 我的问题的一部分。在 Haskell 中进行交互式 I/O 有点棘手。实现 GUI 几乎总是意味着为极低级别的 C 或 C++ 库编写包装器。编写此类包装器的人倾向于逐字复制现有的 API(大概是这样任何了解包装库的人都会有宾至如归的感觉)。目前我对这些问题不感兴趣。我完全感兴趣的是如何最好地在 Haskell 中建模子类型多态性。

我们希望从假设的 GUI 库中获得什么样的属性?好吧,我们希望可以随时添加新的小部件类型。 (换句话说,封闭的一组可能的小部件是不好的。)我们希望最大限度地减少代码重复。 (有很多小部件类型!)理想情况下,我们希望能够在必要时规定一种特定的小部件类型,同时也能够处理任何小部件的集合如果需要,请键入。

在任何自尊的 OO 语言中,上述所有内容当然都是微不足道的。但是在 Haskell 中最好的方法是什么?我可以想到几种方法,但我不确定哪一种是“最好的”。

【问题讨论】:

  • “我们希望从假设的 GUI 库中获得什么样的属性?”首先,不是 OO。
  • @CatPlusPlus 这真的很难。尤其是 GUI 设计是 OO 真正闪耀的少数领域之一。我真的很想看到一些以不同范式设计的 GUI 库,但我所知道的几乎所有人都坚持以对象方式做事的某种方式。

标签: haskell polymorphism


【解决方案1】:

拥有实际的小部件对象是非常面向对象的。函数世界中常用的技术是使用函数响应式编程 (FRP)。我将简要概述纯 Haskell 中的小部件库在使用 FRP 时的外观。


tl/dr:您不处理“小部件对象”,而是处理“事件流”的集合,而不关心来自哪些小部件或这些流来自何处。


在 FRP 中,有Event a 的基本概念,可以将其视为无限列表[(Time, a)]。所以,如果你想为一个计数的计数器建模,你可以把它写成[(00:01, 1), (00:02, 4), (00.03, 7), ...],它将一个特定的计数器值与给定的时间相关联。如果你想为一个被按下的按钮建模,你会产生一个[(00:01, ButtonPressed), (00:02, ButtonReleased), ...]

通常还有一种称为Signal a 的东西,它类似于Event a,只是模型值是连续的。您在特定时间没有一组离散的值,但您可以向Signal 询问其值,例如00:02:231,它会给您4.754 或其他值。将信号视为模拟信号,例如医院的心脏电荷计(心电图设备/Holter 监视器)上的信号:它是一条上下跳跃但从不产生“间隙”的连续线。例如,一个窗口总是有一个标题(但也许它是空字符串),所以你总是可以询问它的值。


在 GUI 库中,在底层,会有 mouseMovement :: Event (Int, Int)mouseAction :: Event (MouseButton, MouseAction) 之类的。 mouseMovement 是实际的 USB/PS2 鼠标输出,因此您只能将位置差异作为事件获得(例如,当用户向上移动鼠标时,您会收到事件 (12:35:235, (0, -5))。然后您就可以“集成" 或者更确切地说,“累积”移动事件以获得mousePosition :: Signal (Int, Int),它为您提供绝对鼠标坐标。mousePosition 还可以考虑绝对指针设备,例如触摸屏,或重新定位鼠标光标的操作系统事件等。

与键盘类似,会有一个keyboardAction :: Event (Key, Action),也可以将该事件流“集成”到一个keyboardState :: Signal (Key -> KeyState) 中,让您可以随时读取键的状态。


当您想要在屏幕上绘制内容并与小部件交互时,事情会变得更加复杂。

要只创建一个窗口,可以使用一个名为“魔术函数”:

window :: Event DrawCommand -> Signal WindowIcon -> Signal WindowTitle -> ...
       -> FRP (Event (Int, Int) {- mouse events -},
               Event (Key, Action) {- key events -},
               ...)

这个函数会很神奇,因为它必须调用特定于操作系统的函数并创建一个窗口(除非操作系统本身是 FRP,但我对此表示怀疑)。这也是它在 FRP monad 中的原因,因为它会在 IO monad 的幕后调用 createWindowsetTitleregisterKeyCallback 等。

当然可以将所有这些值分组到数据结构中,这样就会有:

window :: WindowProperties -> ReactiveWidget
       -> FRP (ReactiveWindow, ReactiveWidget)

WindowProperties 是决定窗口外观和行为的信号和事件(例如,是否应该有关闭按钮、标题应该是什么等)。

ReactiveWidget 表示 S&E,即键盘和鼠标事件,以防您想在应用程序中模拟鼠标点击,Event DrawCommand 表示您想在窗口上绘制的一连串内容。这种数据结构对所有小部件都是通用的。

ReactiveWindow 表示窗口最小化等事件,而输出 ReactiveWidget 表示来自外部/用户的鼠标和键盘事件。

然后创建一个实际的小部件,比如说一个按钮。它会有签名:

button :: ButtonProperties -> ReactiveWidget -> (ReactiveButton, ReactiveWidget)

ButtonProperties 将确定按钮的颜色/文本/等,ReactiveButton 将包含例如一个Event ButtonActionSignal ButtonState 来读取按钮的状态。

请注意,button 函数是一个纯函数,因为它只依赖于纯 FRP 值,例如事件和信号。

如果想要对小部件进行分组(例如将它们水平堆叠),则必须创建例如一个:

horizontalLayout :: HLayoutProperties -> ReactiveWidget
                 -> (ReactiveLayout, ReactiveWidget)

HLayoutProperties 将包含有关边框大小的信息以及所包含小部件的ReactiveWidgets。然后,ReactiveLayout 将包含一个 [ReactiveWidget],每个子小部件都有一个元素。

布局的作用是它有一个内部Signal [Int],它决定了布局中每个小部件的高度。然后它将接收来自输入ReactiveWidget 的所有事件,然后根据分区布局选择一个输出ReactiveWidget 将事件发送到,同时还转换例如的来源。分区偏移量的鼠标事件。


要演示此 API 的工作原理,请考虑以下程序:

main = runFRP $ do rec -- Recursive do, lets us use winInp lazily before it is defined

  -- Create window:
  (win, winOut) <- window winProps winInp

      -- Create some arbitrary layout with our 2 widgets:
  let (lay, layOut) = layout (def { widgets = [butOut, labOut] }) layInp
      -- Create a button:
      (but, butOut) = button butProps butInp
      -- Create a label:
      (lab, labOut) = label labProps labInp
      -- Connect the layout input to the window output
      layInp = winOut
      -- Connect the layout output to the window input
      winInp = layOut
      -- Get the spliced input from the layout
      [butInp, layInp] = layoutWidgets lay
      -- "pure" is of course from Applicative Functors and indicates a constant Signal
      winProps = def { title = pure "Hello, World!", size = pure (800, 600) }
      butProps = def { title = pure "Click me!" }
      labProps = def { text = reactiveIf
                              (buttonPressed but)
                              (pure "Button pressed") (pure "Button not pressed") }
  return ()

def 来自Data.Default 中的data-default

这会创建一个事件图,如下所示:

     Input events ->            Input events ->
win ---------------------- lay ---------------------- but \
     <- Draw commands etc.  \   <- Draw commands etc.      | | Button press ev.
                             \  Input events ->            | V
                              \---------------------- lab /
                                <- Draw commands etc.

请注意,任何地方都不必有任何“小部件对象”。布局只是一个根据分区系统转换输入和输出事件的函数,因此您可以将获得访问权限的事件流用于小部件,或者您可以让另一个子系统完全生成流。按钮和标签也是如此:它们只是将点击事件转换为绘制命令或类似内容的函数。它是完全解耦的一种表现形式,并且在本质上非常灵活。

【讨论】:

  • rec 真的是 Haskell 中的东西吗?
  • @CatPlusPlus,太棒了!谢谢:)
  • 可能是我语法错了,我明显没有编译代码,所以请谨慎对待!
  • 这是一个关于如何将 Haskell 程序连接到现有小部件集的有趣描述。但它没有说明您如何实现它们。例如,想象一下,您只能写入像素和读取鼠标事件。您将如何编写完整的 GUI 堆栈?
【解决方案2】:

wxHaskell GUI 库出色地利用了幻像类型来为小部件层次结构建模。

想法如下:所有小部件共享相同的实现,即它们是指向 C++ 对象的外部指针。但是,这并不意味着所有小部件都需要具有相同的类型。相反,我们可以像这样构建一个层次结构:

type Object a = ForeignPtr a

data CWindow a
data CControl a
data CButton a

type Window  a = Object  (CWindow a)
type Control a = Window  (CControl a)
type Button  a = Control (CButton a)

这样,Control A 类型的值也与Window b 类型匹配,因此您可以将控件用作窗口,但反之则不行。如您所见,子类型化是通过嵌套类型参数实现的。

有关此技术的更多信息,请参阅Dan Leijen's paper on wxHaskell 中的第 5 节。


请注意,这种技术似乎仅限于小部件的实际表示是统一的,即始终相同的情况。但是,我相信经过一番思考,它可以扩展到小部件具有不同表示的情况。

特别观察到,可以通过在数据类型中包含方法来对面向对象进行建模,就像这样

data CWindow a = CWindow
    { close   :: IO ()
    , ...
    }
data CButton a = CButton
    { onClick :: (Mouse -> IO ()) -> IO ()
    , ...
    }

子类型化可能会在这里节省一些样板,但这不是必需的。

【讨论】:

  • 你能详细说明为什么这里不需要子类型吗?您将如何处理在原始库中通过子类型处理的事情?
【解决方案3】:

要了解什么 OOP,例如子类型多态,可以在 Haskell 中完成,您可以查看 OOHaskell。这再现了各种强大的 OOP 类型系统的语义,保留了大多数类型推断。实际的数据编码并没有得到优化,但我怀疑类型族可能允许更好的呈现。

可以使用类型类对接口层次结构(例如 Widget)进行建模。添加新实例是可能的,因此一组具体的小部件是打开的。如果您想要一个可能的小部件的特定列表,那么 GADT 可以是一个简洁的解决方案。

子类的特殊操作是向上转换和向下转换。

这首先需要有一个 Widgets 的集合,通常的结果是使用存在类型。如果您阅读HList library 的所有内容,还有其他有趣的解决方案。向上转换相当容易,编译器可以确定所有转换在编译时都是有效的。向下转换本质上是动态的,需要一些运行时类型信息支持,通常是 Data.Typeable。给定类似 Typeable 的东西,向下转换只是另一个类型类,结果包装在 Maybe 中以指示失败。

其中大部分都有样板文件,但 QuasiQuoting 和 Templating 可以减少这种情况。类型推断在很大程度上仍然可以工作。

我还没有探索新的约束种类和类型,但它们可能会增加向上转换和向下转换的存在解决方案。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-01-11
    • 1970-01-01
    • 2017-01-14
    • 2015-07-14
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多