函数式外壳与可重用组件:简化 GUI 开发
摘要
一些面向对象的 GUI 工具包将状态管理与渲染混合在一起。像 GUI Easy 这样的函数式外壳和可观察工具包通过类比函数式编程简化并促进了可重用视图的创建。我们在小型和大型 GUI 项目中成功使用了 GUI Easy。我们报告了构建和使用 GUI Easy 的经验,并从中得出了几条用于从命令式系统构建函数式程序的架构模式和原则。
1. 引言
面向对象编程传统上被认为是一种构建图形用户界面(GUI)程序的良好范式,因为它支持继承、组合和泛化。Racket 的 GUI 工具包是面向对象的,具有基于消息传递的小部件和可变状态。Racket 平台为 GUI 工具包提供了核心的类和对象库。
图 1 展示了典型的 Racket GUI 代码:它渲染了一个带有按钮的计数器,用于增加和减少一个数字。首先,我们创建了一个顶级窗口容器,称为 frame% 。
#lang racket/gui
(define f (new frame% [label "Counter"]))
(define container
(new horizontal-panel% [parent f]))
(define count 0)
(define (update-count f)
(set! count (f count))
(define new-label (number->string count))
(send count-label set-label new-label))
(define minus-button
(new button% [parent container]
[label "-"]
[callback (λ _ (update-count sub1))]))
(define count-label
(new message% [parent container]
[label "0"]
[auto-resize #t]))
(define plus-button
(new button% [parent container]
[label "+"]
[callback (λ _ (update-count add1))]))
(send f show #t)
图 1:以上是使用 Racket GUI 面向对象的小部件构建的计数器 GUI
为了水平布局控件,我们将一个 horizontal-panel% 作为窗口的子级嵌套其中。我们定义了计数状态 count 和一个同时更新计数及其相关标签的过程 update-count。接下来,我们创建了计数器的按钮和标签。最后,我们调用 frame% 的 show 方法,将其呈现给用户。图 1 的代码存在几个缺点。相对于它所描述的 GUI 的复杂性,代码显得冗长,并且组织方式掩盖了最终界面的结构。程序员需要手动通过变异来同步应用程序状态(如计数)和 UI 状态(如消息标签)。GUI Easy 是一个基于可观察值和函数组合的函数式外壳,旨在解决命令式基于对象的 API 中存在的问题。你可以通过 DrRacket 的菜单或使用 Racket 命令行工具 raco 安装 gui-easy 包,之后你可以在 DrRacket 中运行示例代码,或者在你最喜欢的编程环境中运行。
#lang racket/gui/easy
(define @count (@ 0))
(render
(window
#:title "Counter"
(hpanel
(button "-" (λ () (<~ @count sub1)))
(text (~> @count number->string))
(button "+" (λ () (<~ @count add1))))))
图 2:使用 GUI Easy 的功能小部件的计数器 GUI

图 3:macOS 上的渲染计数器 GUI
使用 GUI Easy 后,图 2 中的代码解决了之前的不足。作为状态,我们定义了一个可观察值 @count,其初始值为数字 0。然后,我们渲染了一个由 window、hpanel、button 和 text 等小部件组成的界面。小部件的属性(如大小或标签)可以是常量值或可观察值。当它们的可观察输入发生变化时,渲染的小部件会自动更新,类似于 React 和 SwiftUI 等系统。在这个例子中,按下按钮会导致计数器更新,从而更新文本标签。在本报告中,我们在第 2 节探讨了使用面向对象 GUI 系统编程的困难,并激励了寻找不同系统的必要性;在第 3 节描述了 GUI Easy 的主要抽象概念;在第 4 节中报告了构建大型 GUI 程序的经验;在第 5 节探讨了关键的架构教训;并在第 6 节探讨了 GUI 编程的相关趋势。
2. 两位程序员的故事
我们通过两个项目的起源故事来展开。首先,Bogdan 描述了他在使用 Racket 的 GUI 系统时遇到的挫折,这些挫折促使他创建了 GUI Easy。其次,Ben 描述了他希望通过函数式方法构建一个大型 GUI 程序的愿望。这两个愿望的结合教会了我们将在第 4 节中介绍的架构经验。
2.1 寻求更简单的 GUIs
Bogdan 的日常工作涉及编写许多小型 GUI 工具,供内部使用。Racket 的 GUI 框架被证明是构建这类工具的最佳选择,因为它提供了快速迭代时间、跨主要操作系统的可移植性,以及分发自包含应用程序的能力。然后,随着时间的推移,Bogdan 反复被同样的不便之处所困扰。Racket 的类系统需要冗长的代码。每个项目都以自己的方式管理状态。Racket GUI 构建视图层次结构的主要方式是通过构造子小部件,并引用它们的父小部件,这使得组合特别令人沮丧,因为各个组件总是必须针对其父级进行参数化。由于 Racket GUI 没有提供任何特殊的支持来管理应用程序状态,Bogdan 不得不自己带来状态管理,导致每个新项目都有临时的解决方案。例如,图 1 中的 update-count 就是一个临时状态管理的例子。这促使了 GUI Easy 中可观察抽象的诞生。在下一节中,我们将看到可观察值和可观察感知视图如何自动连接 GUI 小部件和状态变化。
Bogdan 发现,构造大多数小部件需要引用父小部件,这很不方便。考虑以下 Racket 代码:
(define f (new frame% [label "A window"]))
(define msg (new message% [parent f] [label "Hello World"]))
在这个例子中,我们不能在框架对象之前创建消息对象,因为我们需要一个父级来引用消息对象。这限制了我们组织代码的方式。为了绕过这个问题,我们可以对消息对象的构造进行抽象,但这不必要地复杂了接口的连接工作。这促使 Bogdan 想出了 GUI Easy 中的视图抽象。在第 3 节中,我们将看到视图如何允许函数式抽象,从而实现新的组织方法,我们将在第 4 节中探索这些方法。
2.2 前往 Frosthaven 小镇
Ben 喜欢和一群朋友玩棋牌游戏,特别是 《Frosthaven》,这是《Gloomhaven》的续集。由于其高度复杂性,《Gloomhaven》包含许多棋子、卡片和其他物理部件,玩家必须操作这些部件来玩游戏。这包括追踪怪物生命值和状态,六种魔法元素的力量(这些元素赋予特殊能力),等等。原版《Gloomhaven》有一个移动设备上的辅助应用程序,用于减少物理操作;在某个时刻,似乎《Gloomhaven》不会得到同样的待遇。
#lang racket/gui/easy
(define (counter @count action)
(hpanel
(button "-" (λ () (action sub1)))
(text (~> @count number->string))
(button "+" (λ () (action add1)))))
(define @c1 (@ 0))
(define @c2 (@ 5))
(render
(window
#:title "Counters"
(counter @c1 (λ (proc) (<~ @c1 proc)))
(counter @c2 (λ (proc) (<~ @c2 proc)))))
图 4:GUI Easy 中的组件重用。可以从单个定义创建多个计数器小部件。
Ben,作为一名程序员,决定为他自己的游戏小组解决这个问题,通过创建他自己的辅助应用程序。但是,如何实现呢?对于熟悉类、方法和事件关系的程序员来说,像 Racket 的 GUI 工具包这样的经典的面向对象系统可能感觉很自然。对于新手来说,GUI Easy 代表了一种更简单、更函数式的界面编程路径。GUI Easy 使得通过简单的部分——函数和数据——构建复杂系统成为可能。Ben 熟悉函数式编程,并理解了 GUI Easy,因此他在 2022 年开始使用 GUI Easy 开发《Frosthaven》管理器。
3. GUI Easy 概述
GUI Easy 的目标是通过为 Racket 的命令式 API 封装一个函数式外壳,从而简化 Racket 中的用户界面构建。GUI Easy 大致可以分为两部分:可观察值(observables)和视图(Views)。可观察值包含值,并通知订阅的观察者其内容的变化。第 3.1 节解释了可观察值的操作符。视图是 Racket GUI 小部件树的表示,当渲染时,会产生这些树的具体实例,并处理将状态和小部件连接在一起的细节。我们将在第 3.2 节更详细地讨论视图抽象。可观察值和视图的核心抽象对应于图形应用程序中流行的模型-视图-控制器(MVC)架构,由于 Smalltalk-80 推广。我们在第 3.3 节中描述了这种对应关系。
3.1 可观察值
可观察值的核心抽象是任意观察者可以对可观察内容的变化作出反应。使用 GUI Easy 进行应用程序开发的开发者使用一些核心操作符来构建和操作可观察值。我们使用 @ 创建可观察值。按照惯例,我们使用相同的符号作为可观察绑定的前缀。我们可以使用 <~ 更改可观察值的内容。这个过程接受一个可观察值和一个一元过程作为参数,该过程表示当前值,用于生成新值。每次更新都会传播到更新时注册的任何观察者。我们可以使用 ~> 从现有的可观察值派生新的可观察值。这个过程接受一个可观察值和一个一元过程。当前值作为参数。派生的可观察值会随着其依赖的可观察值的变化而变化,通过将映射过程应用于输入可观察值来实现。在图 4 中,派生的可观察值(~> @count number->string)会在每次 @count 更新时发生变化;其值是将 number->string 应用于 @count 的值的结果。我们不能直接更新派生的可观察值。我们可以使用 obs-peek 查看可观察值,它返回可观察值的内容。此操作适用于在显示模态对话框或其他需要状态快照的视图时获取可观察值的即时值。
3.2 视图作为函数
视图是返回 view<%> 实例的函数,其底层细节将在第 5.2 节中介绍。视图可能包装一个特定的 GUI 小部件,如文本消息或按钮,或者它们可能构建一个由较小视图组成的更大的组件。在本报告中,这两种情况都被成为“视图”。我们已经看到许多视图的例子,如 text、hpanel 和 counter 。视图通常是针对每个单独视图以有意义的方式感知可观察值的。例如,text 视图接受一个可观察字符串作为输入,渲染的文本标签会随着该可观察值的变化而更新。图 4 展示了如何通过组合视图来创建一个可重用的计数器组件。许多 Racket GUI 小组件已经被 GUI Easy 包装,但程序员可以自己实现 view<%> 接口,以便将 Racket 生态系统中的任意小部件(例如第三方包中的小部件)集成到他们的项目中。
3.3 模型、视图和控制器
图形应用程序的流行 MVC 架构将程序模块划分为应用程序领域的模型、模型的视图以及视图耦合以将用户交互转换为影响模型的命令的控制器。

图 5:macOS 上的 Frosthaven 管理器主窗口
Racket GUI 应用程序可以根据 MVC 架构进行组织。在图 1 中,模型是一个整数 count;视图是 button% 和 message% 对象的组合,控制器是 update-count 过程。然而,需要注意的是,将视图对象显式组合成一个可重用组件需要扭曲负责对象创建的代码。尽管可以隐式使用 MVC 模式,但没有显式支持 MVC 模式。
GUI Easy 通过可观察值和视图抽象鼓励使用类似于 MVC 的架构。以图 4 为例:可观察值 @c1 和 @c2 构成了模型,并且它们与普通值区分开来。同样,counter 过程既是 GUI Easy 视图,也是 MVC 视图。最后,控制器的角色由 action 回调函数履行,它为计数器的使用者提供了控制用户交互如何转换为模型更新的能力。
总之,GUI Easy 鼓励的 MCV 架构使用可观察值作为模型,GUI Easy 视图作为视图,回调函数作为控制器。
4. Frosthaven 架构
在本节中,我们描述了大型 GUI Easy 应用程序《Frosthaven Manager》的各个组成部分。截至撰写本文时,《Frosthaven Manager》包含大约 5000 行 Racket 代码。其中大约一半的代码用于将 GUI Easy 视图与特定领域的代码结合起来,构成主应用程序。在剩余的代码行中,大约 1000 行实现了负责游戏状态的数据结构和转换;500 行用于绘制图像;750 行实现了三种用户可编程的数据定义语言;300 行用于测试项目;其余的行则是小型语法工具。此外,《Frosthaven Manager》还包含大约 3000 行 Scribble 代码,Scribble 是 Racket 的一种散文和文档语言,其中包含如何玩游戏的指南和开发者参考。
《Frosthaven Manager》操作许多种类的数据。这包括游戏角色及其各种属性、怪物及其属性、随机战利品、元素效果的状态等等。为了组织和操作这些数据,Ben 选择了一种“函数式核心,命令式外壳”的架构。选择函数式核心和命令式外壳有诸多好处。例如,核心代码独立于 UI 呈现的选择,并且可以独立于其他应用程序进行测试或使用。函数式核心还可以简化程序员对应用程序数据流的推理,将状态变更保留在系统的边界上。
在构建《Frosthaven Manager》时,Ben 主要将数据组织成不可变记录、枚举和集合,并与根据游戏规则转换数据的纯函数一起使用。因此,我们说《Frosthaven Manager》使用了函数式核心。在函数式核心之上,我们发现了《Frosthaven Manager》中另外两个主要组成部分:特定于 GUI 的数据和基于 GUI Easy 构建的特定于领域的视图。在许多方面,Ben 在这里也采用了函数式方法。与 GUI 相关的数据按照典型的习惯用法组织,并与转换函数配对。尽管这些函数式特性存在,但由于大多数相关数据是可观察的或打算成为可观察的,因此最终的系统感觉更像是命令式的。例如,函数式层中的纯转换与可观察更新配对——类似于变异——以对 GUI 的状态产生影响。因此,尽管许多重要且可重用的视图看起来是纯的,但它们可以很容易地组合成一个高度命令式的系统。这些视图和更新都成了《Frosthaven Manager》的命令式外壳。《Frosthaven Manager》的主 GUI 由许多较小的可重用视图组成。通过类比函数式编程的构建块——函数——小型可重用视图允许我们通过组合来构建大型系统。我们将在第 5.1 节中讨论可重用视图的设计原则。
5. 架构经验
在本节中,我们总结了在开发这些系统过程中学到的主要经验教训。首先可重用视图(第 5.1 节)允许通过约束状态操作来进行类似于函数式组合的界面组合。其次,用函数式外壳封装命令式 API(第 5.2 节)允许程序员在构建命令式系统时使用函数式编程技术。第三,控制反转(第 5.3 节)创建了一个可扩展的应用程序框架。
5.1 可重用视图
我们在构建应用程序的经验告诉我们,尽可能使用可重用视图。与纯函数类似,可重用视图是可组合的,并且对状态操作有约束。GUI Easy 提供的所有视图都符合本节描述的可重用视图标准。可重用视图的一个主要设计约束时:视图不应该直接操作外部状态。这类似于纯函数的规则,所有相同的论点都表明,操作外部状态会使视图的可重用性降低。这自然导致了“数据向下,动作向上”(DDAU)的原则。它还指导我们决定哪些状态应该在 GUI 的最高层集中管理,哪些状态应该在可重用视图中本地化。
DDAU 规定了可重用视图应该如何操作状态。“数据向下”意味着所有必要的数据必须作为输入传递给视图。回想一下图 4 中的计数器视图:显示计数值所需的数据是一个名为 @count 的可观察输入。而“动作向上”则意味着视图不应直接操作状态;相反,它们应该将可操作的数据传递回调用者,而调用者更适合决定如何操作状态。动作由回调函数表示。对于计数器视图,动作回调函数传递了一个过程,表明是点击了减号按钮还是加号按钮;计数器视图的调用者决定如何响应用户对 GUI 的操作。通常,直接修改可观察输入是不安全的,因为它们可能是派生的可观察值。非正式地要求某个特定视图的可观察输入不是派生的可观察值,会给想要在新上下文中重用该视图的程序员设置陷阱,违反了可重用视图的原则。可重用视图可以采用单独的输入和输出可观察形式参数来解决这一限制,但这种方法通常不如回调函数灵活,也不如用户方便。回调函数也比单独的输入和输出可观察值更容易组合。例如,当父视图使用子视图时,它可以将父视图自己的回调函数指定为子视图的回调函数。结果是子视图中的事件会传递到父视图,父视图可以拦截、修改和过滤来自子视图的事件。
DDAU 自然地将应用程序状态向上层架构传递,使得应用程序的顶层包含了所有必要的状态。调用者将状态(或其子集)传递给各个组件视图,并提供过程以响应动作。这种状态的向下流动一直持续到最底层。然而,有时我们需要既不是调用者的也不是被调用者的责任的状态。在这种情况下,可重用视图可以维护本地状态,它可以自由地操作这些状态。这符合函数式编程的传统,即通过允许内部的——但不可见的——可变性来优化函数式程序。可重用视图的好处是三方面的。小型可重用视图易于独立测试。通用视图可以考虑提取到单独的库中,就像通用数据结构函数一样。特定领域的视图有助于内聚性,例如 GUI 应用程序的视觉风格。尽管可重用视图时一个 GUI 特定的概念,但 DDAU 和约束状态操作的概念也是函数式编程中更一般的教训:识别状态操作的模式并对这种状态操作进行约束,是一种有用的方法,可以将状态限制在代码的较小部分中,并在其余部分允许使用函数式计数。
5.2 函数式外壳,命令式核心
“函数式核心,命令式外壳”架构涉及将纯函数式代码的核心用命令式的外壳包裹起来。在这个范式的变体中,GUI Easy 视图的核心是命令式对象声明周期,而其外壳是函数式的。在本节中,我们详细描述了这个外壳,并解释了它如何允许在处理命令式系统时使用函数式编程技术。
GUI 对象声明周期体现在 view<%> 接口(图 6)中。该接口的实现必须知道如何创建小部件,如何在数据依赖项发生变化时更新它们,以及在必要时如何销毁它们。它们还必须将数据依赖项向上传播到对象树中。数据依赖项时任何输入到视图的可观察值。框架在依赖项发生时发出信号,允许 view<%> 将更新传播到它们包装的小部件。至关重要的是,view<%> 实例必须是可重用的,因此它们必须小心地将任何内部状态与每个渲染的小部件关联起来。要从 view<%> 到函数式视图,剩下的就是将对象构造包装在一个函数中。因此,外壳——大多数库消费者交互的部分——是函数式的。图 7 展示了一个自定义 view<%> 的实现及其函数包装器。
(require (prefix-in gui: racket/gui))
(define text%
(class* object% (view<%>)
(init-field @label)
(super-new)
(define/public (dependencies) (list @label))
(define/public (create parent)
(new gui:message% [parent parent]
[label (obs-peek @label)]))
(define/public (update widget what val)
(send widget set-label val))
(define/public (destroy widget) (void))))
(define (text @label)
(new text% [@label @label]))
图 7:为显示标签文本实现的自定义 view<%>
这样的外壳如何允许使用函数式编程技术呢?我们已经在前面的章节和实例代码中看到,这个外壳从大多数库消费者那里抽象出了所有命令式细节:直到现在,我们还没有需要理解被封装的命令式基于对象的 API,以便编写 GUI 程序。此外,这些 GUI 程序使用了函数式编程技术,例如可重用视图的组合。即使《Frosthaven Manager》也主要坚持使用 GUI Easy 的函数式外壳,因此能够使用“函数式核心,命令式外壳”架构。对于函数式程序员来说,关键教训是:如果可能的话,用函数式外壳封装命令式 API 可以带来函数式编程的所有好处。对于像 GUI 这样高度复杂的系统,重写整个系统以采用函数式风格可能是不切实际的。相反,通过用函数式外壳封装它来重用现有的命令式或基于对象的工作会更加实现。
5.3 控制反转
控制反转指的是一种架构,其中主要应用程序提供被某个框架调用的过程,而不是由其他应用程序代码调用。框架负责大部分的协调活动,例如管理事件循环。GUI Easy 就是一个这样的框架。它管理 view<%> 实例的对象声明周期,这也是 GUI Easy 图形应用程序的声明周期。调用 render 函数启动这个生命周期,该声明周期由 GUI Easy 管理,并调用应用程序代码以响应用户交互。控制反转导致了一个可扩展的应用程序框架:应用程序的主干框架控制。应用程序将主要任务挂在框架提供的扩展点上。在 GUI Easy 的情况下,这些扩展点是(a)标准组件提供的事件处理器,也称为控制器(第 3.3 节),以及(b)用于创建新的框架感知组件的 view<%> 接口。能够将各个框架组件组合成更大的组件,也有助于可扩展性和重用性。
#lang racket/gui/easy
(require racket/class)
(define close! void)
(render
(window
#:title "Goodbye World"
#:mixin (λ (window%)
(class window% (super-new)
(set! close!
(λ ()
(when (send this can-close?)
(send this on-close)
(send this show #f))))))
(button "Click Me!" (λ () (close!)))))
图 8:使用 Mixins 编写一个 GUI Easy 应用程序,点击按钮关闭窗口
5.4 挑战
当然,维护可重用组件以及针对函数式外壳进行编程并非没有挑战。当你需要访问现有包装器未暴露的底层面向对象 API 的功能时,该怎么办?当编写可重用组件时,难以预测几乎全局状态的使用情况,又该如何处理?幸运的是,这两个问题都有解决方案。
在面向对象的工具包中,我们会根据需要对小部件进行子类化以创建新行为。如果我们无法访问被包装的类,那么就无法对其进行子类化,因为这些类被外壳隐藏了。作为回应,一些 GUI Easy 视图支持一个 mixin 参数,这是一个从类到类的函数。Mixins 允许我们在运行时动态地对小部件进行子类化,以覆盖或增强它们的方法。Myers 的 “Goodbye World” 程序提供了一个很好的例子:如何在视图中包含一个按钮,而这种功能仅存在于面向对象的工具包中?图 8 展示了如何实现:通过使用 mixin,我们可以获取窗口的 on-close 和 show 方法的引用。
《Frosthaven Manager》 使用 mixins 来实现窗口关闭行为,就像在 “Goodbye World” 程序中一样,结合了一个宏来实现 mixin-over-(set! close! ...)模式;它还增强了窗口关闭行为,使得关闭窗口可以表现得像接受一个选择。当 mixins 不足以解决问题时,我们可以选择编写自己的 view<%> 实现来包装我们想要的任何小部件。《Frosthaven Manager》使用自定义的 view<%> 来显示渲染的 Markdown 文本,例如。
全局状态的问题可以通过函数式编程技术来解决。本质上,我们有两种选择:线程化状态(threading state)或动态绑定(dynamic binding)。如果我们确定状态将在所有可重用视图中都需要,我们可以通过将状态作为输入从一个视图传递到下一个视图,就像穿针引线一样贯穿程序的所有部分。线程化状态是 DDAU 和可重用视图的首选解决方案。例如,《Frosthaven Manager》通过可观察值 @env 贯穿整个应用程序,以便对怪物信息或特定场景属性进行简单的算术公式求值。因此,许多视图接受 @env 参数,并且许多视图将 @env 传递给子视图。图 9 展示了简化后的示例。
(define (monster-group-view @monsters @env)
(define @monster ...)
(tabs @monsters
(monster-view @monster @env)))
(define (monster-view @monster @env)
(counter (monster->hp-text @monster @env)
(λ (action) ...)))
图 9:从怪物组视图到怪物视图再到怪物声明值视图,传递 @env 参数
线程化不常用的状态很快就会变得繁琐,并且当状态并非处处需要时,会引入不必要的复杂性。作为回应,我们可以使用动态绑定,这会牺牲一些函数式纯度以换取便利性,并允许每个视图只在绝对必要时才关心全局状态。使用动态绑定会使视图的可重用性降低:它们现在有了不由其输入定义的依赖项。动态绑定允许每个视图只在绝对必要时才关心全局状态。《Frosthaven Manager》尽可能地线程化状态,但在少数情况下也使用了动态绑定。重要的是要提到,通过 Racket 的参数使用动态绑定并不只管,因为 GUI 系统是多线程的,且回调时排队的;为了在《Frosthaven Manager》中实现动态绑定,Ben 不得不在 GUI 事件中绑定参数,并且在需要重新绑定时小心地启动更多的事件线程。这种复杂性可能并不适用于所有应用程序。
6. 相关工作
GUI Easy 从 Swift UI 中汲取灵感,后者是另一个将命令式 GUI 框架封装在函数式外壳中的系统。其他灵感来源包括 Clojure 的 Reagent 和 JavaScript 的 React。
Elm 编程语言严格限制组件组合以遵循“数据向下,动作向上”风格。 Clojure 的 re-frame 库在 Reagent 的基础上增加了更复杂的状态管理。这包括全局存储和效果处理器,类似于 GUI Easy 的可观察值和更新过程,以及查询,类似于 GUI Easy 的派生可观察值。Frappé 是 Java 中 FRP 的一个实现,它将命令式 API(Java Beans)封装在一个声明式外壳中。与 GUI Easy 一样,Frappé 实现了依赖途中值传播的“推送”模式:Frappé 中的行为持有值,并支持注册监听器以在它们持有的值发生变化时得到通知,类似于 GUI Easy 中的可观察值。与 Frappé 不同的是,GUI Easy 没有显式的事件概念。相反,可观察值可以直接通过回调函数进行更新。在 Racket 中,FrTime 实现了一个基于推送的 FRP 语言,用于 GUI 和其他任务。FrTime 语言扩展了 Racket 语言的一个子集,使信号值成为一等公民。相比之下,GUI Easy 是一个建立在 Racket 语言之上的普通库——这是一个有有意识地选择,以便于将 Racket Easy 轻松集成到现有的 Racket 程序中。这种选择的一个副作用是,尽管 FrTime 信号可以在 DrRacket 等编辑器的支持下以连续变化的方式显示,但 GUI Easy 的可观察值是普通得到 Racket 值,并以普通方式显示。FrTime 和 GUI Easy 都通过内部使用变异来跟踪状态,并且 FrTime 行为和 GUI Easy 可观察值都会在发生变化时异步更新。Fred 时 FrTime 对 Racket GUI 的封装。它通过子类化 Racket GUI 小部件来封装其面向对象的 API,以与 FrTime 信号值一起工作。相比之下,GUI Easy 视图是实现 view<%> 接口的独立类。尽管存在这一差异,但这两个框架都执行类似的操作,以将它们的反应式抽象连接到底层小部件。FrTime 使用宏来生成大部分封装代码,而 GUI Easy 视图则是手动实现的。与 GUI Easy 不同,Fred 没有隐藏 Racket 类系统的细节,因此最终用户可以直接使用。由于其小部件子类化了 Racket GUI 小部件,Fred 具有与 Racket GUI 相同的定义顺序约束,我们在第 2.1 节中描述了这一点。Flapjax 是一个基于推送的 FRP 实现。它提供了从 Flapjax 语言到 JavaScript 编译器以及一个独立的库。与 FrTime 类似,Flapjax 语言扩展了 JavaScript,使行为成为一等公民,并在必要时隐式地将表达式提升为行为上工作。Flapjax 编译器是可选地,独立库可以直接从 JavaScript 中使用,就像 GUI Easy 可以直接从 Racket 中使用一样。尽管 GUI Easy 的可观察值是异步独立更新的,但 Flapjax 中的更新是通过依赖图的拓扑顺序传播的,避免了共享依赖图的行为之间可能出现的潜在不一致性。Andrew 工具包和 Garnet 系统等早期系统知道 MVC 架构紧密耦合了视图和控制器。典型的解决方案是不分离视图和控制器,或者完全放弃控制器。DDAU 通过使用回调函数来鼓励解耦视图和控制器:它们提供了典型的控制器会用来响应用户交互的相同插入点,并且它们允许不同的视图实例对相同的模型更新作出响应。当视图可以显示许多不同的模型时,,这一点尤其重要。解耦视图和控制器还允许在组合视图时组合控制器。在 Garnet 系统中,通过提供一组灵活的交互器,并使用公式化的约束来连接交互和更新,从而避免了“意大利面条”式回调。
控制反转有着悠久的理解:Tajo 和 Mesa 系统的发展将其称为“好莱坞原则”。Myers 在开发 Garnet 系统时,同样将监控进程与用于应用程序代码分开,为应用程序提供了响应框架事件的狗子。在 Johnson 和 Foote 的语言中,我们问 GUI Easy 是一个”白盒“还是”黑盒“控制反转框架。白盒框架之所以被称为”透明“,是因为它们通常要求程序对框架组件进行子类化并添加方法,这需要了解实现细节。相比之下,黑盒框架是一个”进化目标“,其中不透明组件仅通过共享协议进行通信。因此,黑盒框架更容易学习和使用。白盒框架通常维护全局状态,而黑盒框架仅在需要时显式共享状态。
根据这些标准,我们可以自信地说,面向对象的 Racket GUI 工具包是一个白盒框架。GUI Easy 主要是黑盒的,依赖于可观察值作为状态和过程作为通信的协议。然而,GUI Easy 提供了不同程序复杂性的”逃生舱口“,带回了白盒框架的专家风味;也就是说,mixins 和 view<%> 接口。为了给予应有的赞誉,我们认识到在许多现成的例子之后进行抽象要容易得多——如果没有 Racket 的 GUI 工具包,我们就不会开发出 GUI Easy。另一个方面,使用 GUI Easy 已被证明是学习如何使用 Racket 的 GUI 工具包的一个好方法!
7. 结论
我们报告了使用命令式、基于对象的 API 编程状态化 GUI 的困难。我们还描述了一个围绕 Racket 面向对象的 GUI 库的功能性封装,旨在解决其中的一些缺点。GUI Easy 已成功用于小型和大型项目,包括本报告中讨论的《Frosthaven Manager》。我们从这两个项目中得出了几条架构原则:函数式外壳覆盖命令式 API,使程序员能够在处理底层实现为命令式的系统时使用函数式编程技术。为了在需要时允许访问底层系统,函数式外壳需要提供可扩展的钩子。可重用组件,就像纯函数一样,不应该修改外部状态。像纯函数一样,可重用组件可以独立测试,并且可以轻松地相互组合。