在面向对象编程中,拥有实际的部件对象是一种很常见的方法。而在函数式编程中,常用的技术是使用功能响应式编程(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) ,
Event (Key, Action) ,
...)
这个函数将会是神奇的,因为它需要调用特定于操作系统的函数并创建一个窗口(除非操作系统本身就是FRP,但我对此表示怀疑)。这也是为什么它在FRP
单子中,因为它会在幕后在IO
单子中调用createWindow
、setTitle
和registerKeyCallback
等函数。
当然,人们可以将所有这些值分组到数据结构中,以便有:
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
包含有关边框大小和所包含小部件的
ReactiveWidget
s的信息。然后,
ReactiveLayout
将包含一个
[ReactiveWidget]
,每个子小部件都有一个元素。
布局要做的是具有内部
Signal [Int]
,该信号确定布局中每个小部件的高度。然后,它会接收来自输入
ReactiveWidget
的所有事件,然后根据分区布局选择输出
ReactiveWidget
以将事件发送到,同时还通过分区偏移转换原始鼠标事件等事件的起点。
为了演示此API的工作原理,请考虑以下程序:
main = runFRP $ do rec
(win, winOut) <- window winProps winInp
let (lay, layOut) = layout (def { widgets = [butOut, labOut] }) layInp
(but, butOut) = button butProps butInp
(lab, labOut) = label labProps labInp
layInp = winOut
winInp = layOut
[butInp, layInp] = layoutWidgets lay
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对象”。布局只是一个函数,根据分区系统转换输入和输出事件,因此您可以使用获得对小部件的访问权限的事件流,或者您可以让另一个子系统完全生成这些流。按钮和标签也是如此:它们只是将单击事件转换为绘制命令或类似事物的函数。这是完全解耦合的表示形式,并且在其性质上非常灵活。