Haskell中的子类型多态性

34
建立一组GUI小部件类的层次结构在面向对象编程中是非常标准的练习。你需要某种抽象的Widget类,以及一个可以包含其他小部件的小部件的抽象子类,然后你会有一堆进一步的小部件支持文本显示,支持成为输入焦点的小部件,具有布尔状态的小部件,一直到实际的具体类,例如按钮、滑块、滚动条、复选框等。
我的问题是:Haskell中最好的做法是什么?
有一些东西使得在Haskell中构建GUI很困难,但不是我的问题的一部分。在Haskell中进行交互式I/O稍微有些棘手。实现GUI几乎总是意味着编写一个包装器来封装极低级别的C或C++库。并且编写这样的包装器倾向于逐字逐句地复制现有API(假定任何了解被封装库的人都会感到自在)。这些问题目前不会引起我的兴趣。我只关心如何最好地在Haskell中建模子类型多态。
我们希望从我们的假想GUI库中得到哪些特性?嗯,我们希望能够随时添加新的小部件类型。(换句话说,闭合的小部件集是不好的。)我们想要尽量减少代码重复。(有很多小部件类型!)理想情况下,我们希望能够在必要时规定一个特定的小部件类型,但也能够在需要时处理任何小部件类型的集合。
当然,在任何自尊心强的OO语言中,以上所有内容都是微不足道的。但在Haskell中最好的做法是什么?我可以想到几种方法,但我不确定哪一种方法最好。

2
我们的假想GUI库需要哪些属性呢?首先,不要使用面向对象。 - Cat Plus Plus
@CatPlusPlus 这确实很困难。特别是GUI设计是OO真正发挥作用的少数领域之一。我真的很想看到一些以不同范式设计的GUI库,但我所知道的几乎都坚持以面向对象的方式做事。 - pmr
3个回答

34

在面向对象编程中,拥有实际的部件对象是一种很常见的方法。而在函数式编程中,常用的技术是使用功能响应式编程(FRP)。当使用 FRP 时,一个纯 Haskell 的部件库会是什么样子呢?我将简要概述。


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 或者其他什么。把一个信号看作是类似于医院心电图(电心图监测/便携式记录仪)上的模拟信号:它是一个连续的线条,上下跳动但不会产生“间隙”。例如,一个窗口总是有一个标题(也许是空字符串),所以你总是可以询问它的值。


在 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单子中,因为它会在幕后在IO单子中调用createWindowsetTitleregisterKeyCallback等函数。

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

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

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

ReactiveWidget 表示键盘和鼠标事件的 S&E,以防您希望从应用程序内部模拟鼠标点击,并且一个 Event DrawCommand 表示您要在窗口上绘制的流。此数据结构适用于所有小部件。

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

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

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

ButtonProperties决定按钮的颜色、文本等等,而ReactiveButton则包含一个Event ButtonAction和一个Signal 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.

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


在 Haskell 中,rec 真的存在吗? - huon
4
GHC扩展:http://www.haskell.org/ghc/docs/7.4.2/html/users_guide/syntax-extns.html#recursive-do-notation说明:这是指向GHC用户指南中关于递归do表示法的页面的链接。 - Cat Plus Plus
也许我语法有误,显然我没有编译代码,所以请谨慎对待! - dflemstr
这是一个有趣的描述,说明了如何将Haskell程序连接到现有的小部件集。但它并没有解决如何实现它们的问题。例如,想象一下,你只能写像素和读取鼠标事件。你将如何编写完整的GUI堆栈? - MathematicalOrchid
大多数基本的绘图库至少可以让你绘制矩形、椭圆和渐变等。仅仅绘制像素会相当受限制。但是一个按钮的实际实现可能看起来像这样:button _ (ReactiveWidget { mouseClick = mc, mouseDown = md }) = (ReactiveButton { buttonPressed = md }, ReactiveWidget { drawCommands = reactiveIf md (FRect 0 0 25 25 White) (FRect 0 0 25 25 Black), mouseClick = mc })。显然,我在这个评论中没有太多的空间,但是这个按钮(为了简单起见,大小为25x25)将对点击做出反应,并且在按下时为黑色,在未按下时为白色。 - dflemstr
有关实际的FRP示例,请参见reactive-banana和演示适配器到wxWidgets的示例包reactive-banana-wx,或者使用GTK适配器的grapefruit-uigrapefruit-ui-gtk。这些库显然是适配器,因此它们仍然在不同的基础库中使用一组“普通”的非反应式小部件。 - dflemstr

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)
这种方式,类型为Control A的值也符合类型Window b,因此可以将控件用作窗口,但反过来不行。可以看出,子类型化是通过嵌套类型参数实现的。
有关此技术的更多信息,请参见Dan Leijen在wxHaskell上的第5节论文
请注意,此技术似乎仅限于小部件的实际表示方式统一的情况,即始终相同的情况。但是,我相信经过一些思考,可以将其扩展到小部件具有不同表示的情况。
特别地,观察到面向对象可以通过将方法包含在数据类型中来建模,如下所示。
data CWindow a = CWindow
    { close   :: IO ()
    , ...
    }
data CButton a = CButton
    { onClick :: (Mouse -> IO ()) -> IO ()
    , ...
    }

子类型化可能会减少一些样板代码,但并非必需。


你能详细说明为什么这里不需要子类型吗?在原始库中由子类型处理的事情,你会如何处理? - Hjulle

7
要理解Haskell中OOP的概念,例如子类型多态性,您可以查看OOHaskell。这个项目再现了各种强大的OOP类型系统的语义,保持了大部分类型推断。实际的数据编码没有被优化,但我认为类型族可能会提供更好的展示。
使用类型类可以对接口层次结构(例如Widget)进行建模。添加新实例是可能的,因此具体小部件集是开放的。如果您想要特定的小部件列表,则GADTs可以是简洁的解决方案。
子类的特殊操作是向上转换和向下转换。
这首先需要一个小部件集合,通常使用存在类型来实现。如果您阅读HList library的所有位,还有其他有趣的解决方案。向上转换相当容易,并且编译器可以确定所有转换在编译时都是有效的。向下转换本质上是动态的,并且需要一些运行时类型信息支持,通常使用Data.Typeable。给定类似Typeable的东西,向下转换只是另一个类型类,并将结果包装在Maybe中以指示失败。
这大多数情况下涉及样板文件,但QuasiQuoting和Templating可以减少这种情况。类型推断仍然可以大部分工作。
我还没有探索新的约束类型和种类,但它们可能增强存在解决方案的向上和向下转换。

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