34

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

我的问题是:在 Haskell 中执行此操作的最佳方法是什么?

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

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

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

4

3 回答 3

33

拥有实际的小部件对象是非常面向对象的。函数世界中常用的技术是使用函数响应式编程 (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), ...]

通常还有一种叫做 a 的东西Signal a,它类似于 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), 并且还可以将该事件流“集成”到 akeyboardState :: Signal (Key -> KeyState)中,让您可以在任何时间点读取键的状态。


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

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

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

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

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

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

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

代表键盘和鼠标事件的ReactiveWidgetS&E,以防您想在应用程序中模拟鼠标单击,以及Event DrawCommand代表您想在窗口上绘制的内容流。这种数据结构对所有小部件都是通用的。

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

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

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

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

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

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

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

HLayoutProperties包含有关边框大小和ReactiveWidget所包含小部件的 s 的信息。然后将为每个子小部件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.Defaultdata-default

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

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

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

于 2012-08-17T10:44:45.327 回答
10

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)

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

有关此技术的更多信息,请参阅Dan Leijen 关于 wxHaskell 的论文中的第 5 节。


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

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

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

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

于 2012-08-23T12:13:35.523 回答
7

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

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

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

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

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

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

于 2012-08-17T11:24:39.123 回答