在本页中:
2.4.1 背景
2.4.2 简单的模拟
animate
run-simulation
run-movie
2.4.3 交互
big-bang
to-draw
on-draw
on-tick
key-event?
key=?
on-key
on-release
pad-event?
pad=?
on-pad
pad-handler
up
down
left
right
space
shift
mouse-event?
mouse=?
on-mouse
stop-when
stop-with
check-with
record?
close-on-stop
display-mode
state
name
2.4.4 第一个世界的例子
2.4.4.1 对门的理解
2.4.4.2 关于设计世界的提示
2.4.5 世界还不够
2.4.5.1 消息
sexp?
2.4.5.2 发送消息
package?
make-package
2.4.5.3 连接到宇宙
LOCALHOST
register
port
2.4.5.4 接收消息
on-receive
2.4.6 宇宙服务器
2.4.6.1 世界与消息
iworld?
iworld=?
iworld-name
iworld1
iworld2
iworld3
bundle?
make-bundle
mail?
make-mail
2.4.6.2 宇宙的描述
universe
on-new
on-msg
on-disconnect
to-string
2.4.6.3 探索宇宙
launch-many-worlds
launch-many-worlds/  proc
2.4.7 第一个宇宙的例子
2.4.7.1 两个扔球的世界
2.4.7.2 关于设计宇宙的提示
2.4.7.3 球宇宙的设计
2.4.7.4 球服务器的设计
2.4.7.5 球世界的设计

2.4 世界和宇宙: "universe.rkt"

Matthias Felleisen
及Racket-zh项目组译

 (require 2htdp/universe) package: htdp-lib

universe.rkt教学包实现并提供用于创建(由简单数学函数组成的)交互式图形程序的功能。 我们将此类程序称为世界(world)程序。 此外, 世界程序也可以成为宇宙(universe)的一部分,宇宙是可以交换信息的世界的集合。

本文档的目的,是为经验丰富的Racketeer和HtDP教师提供使用该库的简明概述。 文档的第一部分侧重于世界程序。第一个世界的例子展示了如何为简单领域设计此类程序; 它适用于知道如何为枚举、区间和联合设计条件函数的新程序员。本文档的后半部分侧重于“宇宙”程序: 如何通过服务器管理宇宙、世界程序如何向服务器注册,等等。 最后两节展示了如何设计由两个相互通信的世界构成的简单宇宙。

注意:要快速从教育角度理解世界, 请参阅《程序设计方法(第二版)》的序言。 2008年8月,我们还编写了一本小册子How to Design Worlds,其中包含一系列项目。

2.4.1 背景

universe教学包宇宙教程包假定你了解基本的图像处理运算, htdp/image2htdp/image都可以。 就这个本扩展手册而言,这两个图像教学包之间的主要区别是

htdp/image程序将它们的状态呈现为场景,即满足scene?谓词的图像。

回忆一下,htdp/image将场景定义为pinhole位于(0,0)的图像。 如果程序使用2htdp/image中的运算,那么所有图像都是场景。

虽然两个图像教学包都适用于本教学包中的运算,但我们希望在不久的将来去除htdp/image。 所有示例程序都已使用2htdp/image运算编写。 我们敦促程序员在设计新的“世界”和“宇宙”程序时使用2htdp/image, 并将现有的htdp/image程序重写以使用2htdp/image

2.4.2 简单的模拟

最简单的动画世界程序是基于时间的模拟,也就是一系列的图像。 程序员的任务是提供函数,为每个自然数创建图像。将此函数传给教学包,就会显示该模拟。

函数

(animate create-image)  natural-number/c

  create-image : (-> natural-number/c scene?)
关于scene?请参见背景 打开一个画布,并启动一个每秒钟滴答28次的时钟。每次时钟滴答时, DrRacket都会将create-image应用于自animate函数调用以来经过的滴答数。 create-image函数调用的结果将被显示到画布中。模拟将一直运行, 直到单击DrRacket中的中断按钮或关闭窗口。此时,animate返回已经过的滴答数。

例子:
(define (create-UFO-scene height)
  (underlay/xy (rectangle 100 100 "solid" "white") 50 height UFO))
 
(define UFO
  (underlay/align "center"
                  "center"
                  (circle 10 "solid" "green")
                  (rectangle 40 4 "solid" "green")))
 
(animate create-UFO-scene)

函数

(run-simulation create-image)  natural-number/c

  create-image : (-> natural-number/c scene?)
关于scene?请参见背景 animate最初被称为run-simulation,为了向后兼容性该名字也被保留。

函数

(run-movie r m)  [Listof image?]

  r : (and/c real? positive?)
  m : [Listof image?]
run-movie显示图像的表m,每个图像花费r秒。 动画停止时,它返回剩余未显示的图像表。

2.4.3 交互

从模拟程序到交互式程序的变化相对较小。粗略地说,模拟指定一个函数create-image, 作为一种事件的处理程序:时钟滴答。除了时钟滴答,世界程序还可以处理其他两种事件: 键盘事件和鼠标事件。当计算机用户按下键盘上的键时,将触发键盘事件。类似地, 鼠标事件是鼠标的移动、鼠标按钮的单击、鼠标移动过边界交叉,等等。

程序可以通过指定处理程序函数来处理此类事件。具体来说, 本教学包提供了四种事件处理程序的安装:on-tickon-keyon-mouseon-pad。此外,世界程序必须指定一个render函数, 每当程序可视化当前世界时调用该函数,以及一个done谓词,用于确定世界程序何时应该关闭。

每个处理函数都读入世界的当前状态,以及(可选的)事件的数据表示。它返回新的世界状态。

big-bang形式将World_0安装为初始的WorldState(世界状态)。 处理程序tockreactclick将一个世界转换为另一个; 每次处理某个事件时,都会使用done来检查世界是否是最终的,如果是的话程序会被关闭; 最后,render将每个世界呈现为图像,并在外部画布上显示。

WorldState : any/c

世界程序的设计要求程序员提供所有可能状态的数据定义。我们将这个数据集合称为WorldState, 使用大写字母W将其与程序区分开。原则上,对此数据定义没有约束, 不过它不能是Package结构体的实例(见下文)。 你甚至可以隐式的定义它,尽管这违反了设计诀窍

语法

(big-bang state-expr clause ...)

 
clause = (on-tick tick-expr)
  | (on-tick tick-expr rate-expr)
  | (on-tick tick-expr rate-expr limit-expr)
  | (on-key key-expr)
  | (on-pad pad-expr)
  | (on-release release-expr)
  | (on-mouse mouse-expr)
  | (to-draw draw-expr)
  | (to-draw draw-expr width-expr height-expr)
  | (stop-when stop-expr)
  | (stop-when stop-expr last-scene-expr)
  | (check-with world?-expr)
  | (record? r-expr)
  | (close-on-stop cos-expr)
  | (display-mode d-expr)
  | (state expr)
  | (on-receive rec-expr)
  | (register IP-expr)
  | (port Port-expr)
  | (name name-expr)
state-expr指定的初始状态启动世界程序,当然state-expr必须求值WorldState的元素。 它的行为由可选子句中给出的处理函数指定,特别是世界程序如何处理时钟滴答、键盘事件、鼠标事件,以及最后来自宇宙的消息; 它如何将自己呈现为图像;何时必须关闭程序;在哪里将世界注册到宇宙中;以及是否记录事件流。 世界规范不能包含多个on-tickto-drawregister子句。 当满足停止条件(见下文)、或者程序员单击中断按钮或关闭画布时,big-bang表达式返回最后一个世界。

big-bang描述的唯一强制性子句是to-draw(或on-draw以实现向后兼容):
  • 语法

    (to-draw render-expr)

     
      render-expr : (-> WorldState scene?)
    关于scene?请参见背景 告诉DrRacket,每当必须绘制画布时调用函数render-expr。 在DrRacket处理完任一事件之后,通常会重新绘制外部画布。画布的大小由第一个生成图像的大小决定。

    语法

    (to-draw render-expr width-expr height-expr)

     
      render-expr : (-> WorldState scene?)
      width-expr : natural-number/c
      height-expr : natural-number/c
    关于scene?请参见背景 告诉DrRacket使用width-exprheight-expr的画布,而不是由第一个生成的图像确定。

    出于兼容性原因,teachpack还支持关键字on-draw代替to-draw,但现在应该使用后者。

所有其他子句都是可选的。为了介绍它们,我们还需要一个数据定义(处理函数的结果):

HandlerResult :是WorldState的同义词,直到世界还不够

下面的例子表明,(run-simulation create-UFO-scene)是三行代码的简写:

(define (create-UFO-scene height)
  (underlay/xy (rectangle 100 100 "solid" "white") 50 height UFO))
 
(define UFO
  (underlay/align "center"
                  "center"
                  (circle 10 "solid" "green")
                  (rectangle 40 4 "solid" "green")))
 
 
(big-bang 0
          (on-tick add1)
          (to-draw create-UFO-scene))

习题:添加一个条件,当UFO到达底部时停止飞行。

2.4.4 第一个世界的例子

本节使用一个简单的例子来解释世界的设计。第一小节介绍了例子的领域,自动关闭的门。 第二小节是关于世界程序的一般设计,后续小节实现门的模拟。

2.4.4.1 对门的理解

假设我们希望设计世界程序,模拟一扇会自动关闭的门。当门锁着时,它可以被解锁。 虽然这么做本身并没有打开门,但现在可以这样做了。也就是说,未锁定的门还是关着的, 此时推门就能打开。一旦放开开着的门,自动闭门器就会接管并将其关上。当然,关着的门可以被锁住。

将这个描述翻译成图片表示就是:

世界程序的一般运作图片一样,这个图表显示了一个所谓的“状态机”。 三个带圆圈的词是对门非正式描述所确定的状态:锁定、关闭(且解锁)和打开。 箭头指定门如何从一个状态进入另一个状态。例如,当门打开时,随着时间的推移,自动闭门器会将门关上。 这个转换用标记为“时间流逝”的箭头表示。其他箭头以类似的方式表示转换:

2.4.4.2 关于设计世界的提示

世界程序模拟任何动态行为都需要完成两件不同的事。首先, 我们必须梳理出领域中随时间变化或对行动做出反应的那些部分,并为这些信息开发数据表示。 这就是我们所说的WorldState。请记住,良好的数据定义可以使阅读者轻松地将数据映射到现实世界中的信息, 或者将现实世界中的信息映射为数据。对于世界的所有其他方面,我们使用全局常量,包括渲染函数中需要用到的图形或图像常量。

其次,我们必须将领域中的操作——上图中的箭头——转换universe教学包可以处理的计算机的交互。 一旦决定用时间表示一个方面、用按键表示另一个方面、用鼠标移动表示第三个方面, 我们必须开发函数将世界的当前状态(表示为WorldState数据)映射到下一个世界状态。 换种说法,我们刚刚创建了包含三个处理函数的愿望清单,这些函数一般具有以下的契约和目的声明:

; tick : WorldState -> HandlerResult
; 处理时间的流逝
(define (tick w) ...)
 
; click : WorldState Number Number MouseEvent -> HandlerResult
; 处理当前世界w中、位于(x,y)的、
; me类型的鼠标点击
(define (click w x y me) ...)
 
; control : WorldState KeyEvent -> HandlerResult
; 处理当前世界w中的键盘事件ke
(define (control w ke) ...)

也就是说,一旦定义了如何用所选语言来表示领域,各种处理函数的契约也就决定了(要编写的)函数和契约。

典型的程序并不使用所有这三个函数。此外,这些函数的设计仅提供了顶层、初始设计目标。 经常会需要设计许多辅助函数。所有这些函数的集合便是世界程序。

程序设计方法(第二版) 中提供了一个扩展示例。

2.4.5 世界还不够

我们已经说明了如何使用本库设计单个交互式图形用户界面(模拟、动画、游戏等)程序。 本节,我们将介绍如何设计分布式程序,也就是一批以某种方式协调其动作的程序。 单个的程序可以运行在现实中任何的计算机上(例如在地球上,以及在航天器上), 只要它连在互联网上并且允许程序(通过TCP)发送和接收消息。我们将这种安排称为宇宙, 并将协调这些的程序称为宇宙服务器,简称为或服务器

本节介绍了消息是什麽、世界程序如何发送消息、如何接收消息,以及如何将世界程序连接到宇宙

2.4.5.1 消息

在世界程序成为宇宙的一部分之后,它可以发送消息并接收消息。就数据而言,消息就是S-expression

S-expression S表达式大致是基本数据的嵌套列表;确切地说,S表达式是以下之一:

  • 字符串、

  • 符号、

  • 数值、

  • 布尔值、

  • 字符、

  • S表达式的表、

  • S表达式的预制struct、

  • 字节串。

注意这里list子句当然也包括empty

函数

(sexp? x)  boolean?

  x : any/c
判断x是否是S-expression

2.4.5.2 发送消息

世界程序中每个返回世界的(用于处理时钟滴答事件、键盘事件和鼠标事件的)回调, 除了返回WorldState之外还可以返回Package

HandlerResult是下列之一:
其中Package表示序对,由WorldState和从世界程序到服务器的消息组成。 因为程序只通过Package发送消息,所以本教学包只提该供结构体的构造函数和谓词,不提供选择函数。

函数

(package? x)  boolean?

  x : any/c
判断x是否是Package

函数

(make-package w m)  package?

  w : any/c
  m : sexp?

回想一下,事件处理程序返回HandlerResult,而我们刚刚改进了这个数据定义。 因此,处理程序即可以返回WorldState,也可以返回Package。如果事件处理程序返回Package, 那么其中world字段的内容将成为下一个世界,而message字段将指定世界向宇宙所发送的内容。 这个区别也解释了为什么WorldState的数据定义不包含Package

2.4.5.3 连接到宇宙

消息被发送往宇宙程序,它运行于现实中的某台计算机之上。下一节会介绍创建此类宇宙服务器的构造。 现在,我们只需要知道它存在并且是消息的接收者。

IP string?

在世界程序可以发送消息之前,它必须先注册到服务器。注册必须指定运行服务器的计算机的Internet地址, 也称为IP地址或网址。这里IP地址是格式正确的字符串, 例如"192.168.1.1""www.google.com"

本机的IP。在开发分布式程序时,尤其是在调查参与的世界程序是否以正确的方式进行协作时, 可以使用它。这被称为集成测试,与单元测试有很大不同。

如果世界程序需要与其他程序通信,那么big-bang描述必须包含以下形式之一的register子句:

当世界程序注册到宇宙程序,之后宇宙程序停止工作时,世界程序也会停止工作。

2.4.5.4 接收消息

最后,从服务器接收消息是个事件,就和滴答事件、键盘事件及鼠标事件一样。 处理收到的消息与处理任何其他事件完全相同。DrRacket会调用世界程序指定的事件处理程序; 如果没有这样的子句,那么丢弃该消息。

big-bangon-receive子句指定消息接收事件的处理程序。

语法

(on-receive receive-expr)

 
  receive-expr : (-> WorldState sexp? HandlerResult)
告诉DrRacket每当收到消息时,对当前WorldState和收到的消息调用receive-expr。 调用的结果成为当前WorldState

因为receive-expr是(或计算为)返回世界的函数,它不仅可以返回WorldState, 也可以返回Package。如果结果是Package,其中的消息内容会被发送往server

下图以图形形式总结了本节中的扩展。

只要事件处理程序返回Package,注册过的世界程序随时可以向宇宙服务器发送消息。 该消息被发送往服务器,服务器可以将其原样转发给另一个世界程序,或以修改后发送。 消息的到来只是世界程序必须处理的另一种事件。与所有其他事件处理程序一样, receive读入WorldState和一些辅助参数(在这里就是消息), 返回WorldStatePackage

当消息从任何世界发送到宇宙、或宇宙发送到世界时,发送者和接收者都无需同步。 实际上,发送方可以根据需要发送尽可能多的消息,而不管接收方是否已经处理过它们。 消息只是在队列中等待,直到接收服务器世界程序处理它们。

2.4.6 宇宙服务器

服务器宇宙的中央控制程序,用于在参与宇宙的世界程序之间接收和发送消息。 和世界程序一样,服务器是对事件做出反应的程序,区别在于事件不同于世界程序。 主要的两种事件是宇宙中新出现的世界程序、以及收到的来自世界程序的消息。

本教学包提供了为服务器指定事件处理程序的机制,与描述世界程序的机制非常相似。 根据指定的事件处理程序,服务器可以执行不同的角色:

事实上,通信服务器基本上是不可见的, 看起来好像所有通信都是从宇宙中对等世界世界

本节首先介绍服务器用于表示世界和其他事项的一些基本数据形式。 接下来解释了如何描述服务器程序。

2.4.6.1 世界与消息

要理解服务器的事件处理函数,需要用到几种数据表示:世界程序(的连接),以及事件处理程序的响应。

2.4.6.2 宇宙的描述

服务器会记录它管理的宇宙的信息。一种需要记录的信息显然是参与世界程序的集合, 但一般来说,和世界程序一样,服务器记录的信息类型以及信息的表示方式取决于场合和程序员。

UniverseStateany/c

宇宙服务器的设计要求为所有可能的服务器状态提供数据定义。要运行宇宙, 本教学包要求提供服务器(状态)的数据定义。任何数据都可以代表状态。 我们假设已经为可能的状态引入了数据定义,并且事件处理程序是按照此数据定义的设计诀窍设计的。

服务器本身是使用描述创建的,该描述包含初始状态以及多个子句,子句指定处理宇宙事件的函数。

语法

(universe state-expr clause ...)

 
clause = (on-new new-expr)
  | (on-msg msg-expr)
  | (on-tick tick-expr)
  | (on-tick tick-expr rate-expr)
  | (on-tick tick-expr rate-expr limit-expr)
  | (on-disconnect dis-expr)
  | (state expr)
  | (to-string render-expr)
  | (port port-expr)
  | (check-with universe?-expr)
用给定的状态state-expr创建服务器。其行为由必要和可选子句通过处理函数指定。 这些函数控制服务器如何处理新世界的注册、如何断开世界、如何将消息从一个世界发送到其他注册的世界, 以及如何将其当前状态呈现为字符串。

universe表达式求值会启动服务器。在视觉上它会打开一个控制台窗口, 可以在其中看到世界的加入、从哪个世界接收哪些消息、以及哪些消息被发送到哪个世界。 过长的邮件在显示之前会被截断。

为方便起见,控制台还有两个按钮:一个用于关闭宇宙,另一个用于重新启动它。 后者在集成分布式程序的各个部分期间特别有用。

universe服务器描述的必要子句是on-newon-msg

  • 语法

    (on-new new-expr)

     
      new-expr : (-> UniverseState iworld? (or/c UniverseState bundle?))
    告诉DrRacket每当另一个世界加入宇宙时,调用new-expr函数。用当前状态和加入的iworld来调用事件处理程序,该iworld还不表中。 特别地,处理程序可以拒绝某个世界程序参与宇宙,方式是简单地返回输入状态、或将新世界放入返回的bundle结构体的第三字段中。

    修改于package htdp-lib的1.1版本:允许宇宙处理程序返回宇宙状态

  • 语法

    (on-msg msg-expr)

     
      msg-expr : (-> UniverseState iworld? sexp? (or/c UniverseState bundle?))
    告诉DrRacket将msg-expr应用于宇宙的当前状态、发送消息的世界w以及消息本身。

    修改于package htdp-lib的1.1版本:允许宇宙处理程序返回宇宙状态

合法的事件处理程序要么返回宇宙状态,要么返回一个bundle。服务器对宇宙的状态进行安全保护,直到下一个事件, 并且会按指定的方式发送邮件。bundle第三个字段中的iworld表将从等待消息的参与者表中删除。

下图提供了服务器工作的图形概述。

除了必要处理程序之外,程序还能使用一些可选的处理程序:

2.4.6.3 探索宇宙

为了探索宇宙的运作,有必要在同一台计算机上启动服务器和几个世界程序。 我们推荐从DrRacket的一个标签中启动服务器,然后从第二个标签中根据需要启动多个世界。 对于后者的操作,本教学包提供了一种特殊的(语法)形式。

语法

(launch-many-worlds expression ...)

并行地计算所有子表达式。通常,每个子表达式都是计算big-bang表达式的函数调用。 当所有世界都停止时,该表达式将按顺序返回所有的最终世界。

设计完世界程序后,在标签的末尾添加一个关于big-bang的函数定义:
; String -> World
(define (main n)
  (big-bang ... (name n) ...))
然后在DrRacket的交互区中,使用launch-many-worlds创建几个不同命名的世界:
> (launch-many-worlds (main "matthew")
                      (main "kathi")
                      (main "h3"))
10
25
33
接下来这三个世界可以通过服务器进行交互。 当所有这些都停止时,它们会返回最终状态,例如102533

对于高级程序员,本库还提供了一个用于并行启动多个世界的编程接口。

函数

(launch-many-worlds/proc thunk-that-runs-a-world    
  ...)  
any ...
  thunk-that-runs-a-world : (-> any/c)
并行调用所有给定的thunk-that-runs-a-world。通常,每个参数都是无参数的函数, 计算一个big-bang表达式。当所有世界都停止时,本函数表达式按顺序返回所有的最终世界。

因此,可以在运行时决定并行运行哪些世界,运行多少个:
> (apply launch-many-worlds/proc
         (build-list (random 10)
                     (lambda (i)
                       (lambda ()
                         (main (number->string i))))))
0
9
1
2
3
6
5
4
8
7

2.4.7 第一个宇宙的例子

本节使用一个简单的例子来解释宇宙的设计,这里的代码使用“中级+lambda”语言。 尤其是其服务器和参与的世界的设计。第一小节解释这个例子,第二小节介绍这些宇宙的总体设计方案。 后续小节介绍完整的解决方案。

2.4.7.1 两个扔球的世界

假设我们想要表示一个由多个世界组成的宇宙,以循环的方式为每个世界提供一个“轮次”。如果轮到一个世界, 它会显示从画布底部上升到顶部的球。然后它交出自己的轮次,由服务器转交给下一个世界。

这是一个图像,说明如果有两个世界参与,这个宇宙将如何运作:

两个世界程序可以位于两台不同的计算机上,也可以只位于一台计算机上。 服务器负责协调两个世界,包括最初的启动。

2.4.7.2 关于设计宇宙的提示

设计宇宙的第一步是从全局视角理解世界的协调。在某种程度上, 这关心的是知识和在整个系统中知识的分配。我们知道,在服务器启动并且世界加入之前, 宇宙不存在。然而,由于计算机和网络的性质,这里不存在别的假设。 我们的网络连接能确保,如果某个世界服务器以某种顺序将两条消息发送到同一个地方, 它们会以相同的顺序到达(如果都到达的话)。反之,如果两个不同的世界程序各自发送一个消息, 网络不保证到达服务器的顺序;类似地,如果要求服务器向几个不同的世界程序发送消息, 则它们可以按发送的顺序、或以某种其他顺序到达那些世界。同样,也不可能确保一个世界在另一个世界之前加入。 最糟糕的是,当有人断开运行世界程序的计算机与网络其余部分之间的(有线或无线)连接, 或者当网络电缆被切断时,消息不会被送达。出于这种不可预测性,设计者的任务是建立一个协议, 强制宇宙按某个顺序执行,这种活动称为协议设计

宇宙的角度来看,协议设计需要设计跟踪服务器中宇宙及参与世界信息的数据表示, 以及设计消息的数据表示。关于后者,我们知道它们必须是S-expression, 但通常世界程序并不会发送所有的S-expression。因此,消息的数据定义必须选择合适的S-expression的子集。 至于服务器和世界的状态,它们必须反映它们目前与宇宙的关系。之后,在设计他们的“本地”行为时, 我们可能会向其状态空间中添加更多组件。

总之,协议设计的第一步是引入:

如果所有世界随着时间的推移表现出相同的行为,那么单个数据定义就足以满足步骤2。 如果它们扮演不同的角色,我们可能每个世界需要一个数据定义。

当然,在定义这些数据集合时,请始终牢记数据的含义,以及它们从宇宙角度所代表的含义。

协议设计的第二步是要处理的重大事件——向宇宙添加世界、在服务器或世界中消息的到达——以及它们所导致的消息交换。 反过来说,当服务器向世界发送消息时,这可能对服务器的状态和世界的状态都有影响。可以使用交互图写出这些协议。

 

Server              World1                  World2

  |                   |                       |

  |   'go             |                       |

  |<------------------|                       |

  |    'go            |                       |

  |------------------------------------------>|

  |                   |                       |

  |                   |                       |

垂直线是世界程序或服务器的生命线。水平箭头表示从一个参与宇宙者发送到另一个的消息。

协议的设计,尤其是数据定义,对事件处理函数的设计有直接的影响。例如,在服务器中, 我们可能需要处理两种事件:新世界的加入和接收来自世界之一的消息。这会转换为设计两个头部如下的函数,

; Bundle是
;   (make-bundle UniverseState [Listof mail?] [Listof iworld?])
 
; UniverseState iworld? -> Bundle
; 当世界iw加入状态为s的宇宙时,
; 宇宙的下一个状态表
(define (add-world s iw) ...)
 
; UniverseState iworld? W2U -> Bundle
; 当世界iw发送消息m给状态为s的宇宙时,
; 宇宙的下一个状态表
(define (process s iw m) ...)

最后,我们还必须决定这些消息如何影响各个世界的状态;他们中的哪个回调可以发送消息、何时发送; 以及如何处理世界收到的消息。因为这个步骤很难抽象地解释,所以我们继续讨论球世界宇宙的协议设计。

2.4.7.3 球宇宙的设计

宇宙的运行有一个简单的总体目标:确保在任何时间点,只有一个世界是活动的,而所有其他世界都在等待。 活动的世界显示一个移动中的球,等待中的世界也应该显示一些东西,任何表明不是它的轮次的东西。

至于服务器的状态,它显然必须记录加入宇宙的所有世界,并且它必须知道哪个世界是活动的, 哪些在等待。当然,最初宇宙是空的,也就是没有世界,那时,服务器没有任何东西可以记录。

虽然有许多不同的方式可以表示这样的宇宙,这里我们使用传入每个处理程序的iworlds表, 并且处理程序返回它们的bundle。对于这个简单的例子来说,UniverseState本身毫无用处。 我们这样解释非空列,第一个iworld是活动的,其余的iworld在等待。至于两种可能的事件,

既然服务器认为只有其表中的第一个iworld是活动的,应向它发送消息。同理, 它应该只会从这个活动的iworld接收消息,而非其他iworld。 这两种消息的内容几乎无关紧要,因为从服务器到iworld的消息意味着该iworld的轮次到了, 而从iworld到服务器的消息意味着轮次已经结束。为了不要迷惑自己,我们为这两条消息使用两个不同的符号:
  • GoMessage'it-is-your-turn

  • StopMessage'done

宇宙的角度来看,每个世界都处于以下两种状态之一:
  • 等待中的世界正在休息。我们用'resting表示这个状态。

  • 活动中的世界不再休息。我们先不为这种世界选定表示,到设计其“本地”行为时再进行。

同样显然的是,活动的世界可能会收到其他消息,可以将之忽略。当完成自己的轮次时,它会发送一条消息。

Server

  |                 World1

  |<==================|

  |  'it-is-your-turn |

  |------------------>|

  |                   |                    World2

  |<==========================================|

  |  'done            |                       |

  |<------------------|                       |

  |  'it-is-your-turn |                       |

  |------------------------------------------>|

  |                   |                       |

  |                   |                       |

  |  'done            |                       |

  |<------------------------------------------|

  |  'it-is-your-turn |                       |

  |------------------>|                       |

  |                   |                       |

  |                   |                       |

这里(水平的)双线表示注册步骤,其他水平线则是消息交换。因此, 该图显示了服务器决定让第一个注册的世界成为活动的,并在其他世界加入时登记。

2.4.7.4 球服务器的设计

前面一小节说明,我们的服务器程序这样开始:

(require 2htdp/universe)
 
; UniverseState是[Listof iworld?]
; StopMessage是'done。
; GoMessage是'it-is-your-turn。

协议的设计直接影响服务器事件处理函数的设计。这里我们需要处理两种事件:新世界的出现和消息的接收。 根据我们的数据定义,还有本文档中详述的事件处理函数的一般契约,愿望列表中是这两个函数:

; Result是
;   (make-bundle [Listof iworld?]
;                (list (make-mail iworld? GoMessage))
;                '())
 
; [Listof iworld?] iworld? -> Result
; 当服务器处于状态u时,将世界iw添加到宇宙中
(define (add-world u iw) ...)
 
; [Listof iworld?] iworld? StopMessage -> Result
; 当服务器处于状态u时,世界iw发送消息m
(define (switch u iw m) ...)

虽然可以重复使用本文档中的通用契约,但我们也从协议中知道,服务器只向一个世界发送消息。 请注意这些契约只是对通用契约的改进。(面向类型的程序员会说,这里的契约是通用契约的子类型。)

设计诀窍的第二步是函数示例:

; 添加世界的一个明显例子:
(check-expect
  (add-world '() iworld1)
  (make-bundle (list iworld1)
               (list (make-mail iworld1 'it-is-your-turn))
               '()))
 
; 从活动世界接收消息的例子:
(check-expect
 (switch (list iworld1 iworld2) iworld1 'done)
 (make-bundle (list iworld2 iworld1)
              (list (make-mail iworld2 'it-is-your-turn))
              '()))

请注意,我们的协议分析规定了这两个函数的行为。还请注意, 这里我们使用了world1world2world3, 因为教学包会将这些事件处理程序应用于真实的世界。

习题:根据我们的协议为这两个函数创建其他示例。

协议告诉我们,add-world只是将输入的世界结构体——真实世界程序的数据表示——添加到输入的世界的表中。 然后它会向此表中的第一个世界发送消息,以使事情开始:

(define (add-world univ wrld)
  (local ((define univ* (append univ (list wrld))))
    (make-bundle univ*
                 (list (make-mail (first univ*) 'it-is-your-turn))
                 '())))

因为univ*至少包含wrld,所以可以创建给(first univ*)的邮件。 当然,同样的推理也意味着,如果univ不是空的,它的第一个元素就是活动的世界, 并将会收到第二个'it-is-your-turn消息。

同样地,协议表明由于世界程序发送消息而调用switch时, 相应世界的数据表示会被移动到表的末尾,并且(结果)表中的下一个世界会被发送消息:

(define (switch univ wrld m)
  (local ((define univ* (append (rest univ) (list (first univ)))))
    (make-bundle univ*
                 (list (make-mail (first univ*) 'it-is-your-turn))
                 '())))

和以前一样,将第一个世界附加到表的末尾可以保证此表中至少存在这一个世界。因此,为这个世界创建邮件是可以接受的。

现在启动服务器。

(universe '() (on-new add-world) (on-msg switch))

习题:函数定义假设了wrld iworld=?(first univ), 并且收到的消息m'done。修改函数定义,检查这些假设,并在其中任何一个错误时抛出错误。 从函数示例开始。如果遇到困难,请重新阅读HtDP关于带检查函数的部分。(注意:在宇宙中, 某个程序很可能向服务器注册但未能遵守商定的协议。如何正确处理这些情况取决于上下文。这里, 遇到这种情况时停止宇宙,返回空的世界表。也请考虑替代解决方案。)

习题:另一种状态表示是将UniverseState等同于世界结构体,记录活动的世界。 服务器中的世界表仅记录等待中的世界。设计对应的的add-worldswitch函数。

2.4.7.5 球世界的设计

最后一步是设计球世界。回想一下,每个世界都处于两种可能的状态之一:活动或等待。 前者向上移动小球,减少球的y坐标;后者显示说是别人的轮次。 假设球总是沿垂直线移动并且垂直线是固定的,那么世界状态是两种情况的枚举:

(require 2htdp/universe)
 
; WorldState是以下之一:
;  Number             %% 表示y坐标
;  'resting
 
(define WORLD0 'resting)
 
; WorldResult是以下之一:
;  WorldState
;  (make-package WorldState StopMessage)
这个定义表明最初的世界是在等待。

通信协议和改进后的WorldState数据定义决定了契约和目的声明:

; WorldState GoMessage -> WorldResult
; 确保球在动
(define (receive w n) ...)
 
; WorldState -> WorldResult
; 每个时钟滴答都向上移动小球
; 或者返回'resting
(define (move w) ...)
 
; WorldState -> Image
; 将世界呈现为图像
(define (render w) ...)

我们来一次设计一个函数,从receive开始。由于协议没有说明receive计算的内容, 让我们利用WorldState的数据组织结构来创建一组合理的函数示例:

(check-expect (receive 'resting 'it-is-your-turn) HEIGHT)
(check-expect (receive (- HEIGHT 1) 'it-is-your-turn) ...)

由于存在两种状态,我们至少需要编写两种例子:一种用于'resting状态,另一种用于数值状态。 第二单元测试的结果部分中的点揭示了第一个模糊性;具体而言,当活动世界收到另一条激活自身的消息时, 不清楚结果应该是什么。当我们研究其他例子,设计处理数值区间的函数时(HtDP,第4章)会出现第二个模糊性。 也就是,我们应该考虑receive的以下三种输入:

在第三种情况下,该函数可以返回三个不同的结果:0'resting(make-package 'resting 'done)。第一个做法保持一切不变;第二个将活动的世界变为静止的;第三个也会这样做,同时告知宇宙这一变化。

我们这样设计receive,它忽略消息并返回活动世界的当前状态。这确保了球以连续的方式移动,并且世界保持活跃。

习题:另一种设计是,每次收到'it-is-your-turn时将球移回图像的底部。请设计这个函数。

(define (receive w m)
  (cond
    [(symbol? w) HEIGHT] ; 含义:(symbol=? w 'resting)
    [else w]))

来设计第二个函数move,它计算小球的移动。我们已经有契约了,设计诀窍的第二步要求例子:

; WorldState -> WorldState or (make-package 'resting 'done)
; 移动小球,如果它在飞的话
 
(check-expect (move 'resting) 'resting)
(check-expect (move HEIGHT) (- HEIGHT 1))
(check-expect (move (- HEIGHT 1)) (- HEIGHT 2))
(check-expect (move 0) (make-package 'resting 'done))
 
(define (move x) ...)

还是遵从HtDP进行,这些例子涵盖了四种典型情况:'resting、指定数字区间的两个终点和一个内点。 它们表明,move保留等待中的世界不变,否则它会移动小球直到y坐标变为0。 对于后一种情况,返回是使世界停止并告知服务器的package。

将这些想法转化为完整的定义现在很简单:

(define (move x)
  (cond
    [(symbol? x) x]
    [(number? x) (if (<= x 0)
                     (make-package 'resting 'done)
                     (sub1 x))]))

习题:如果我们这样设计receive——当世界的状态是0时它返回'resting——会发生什么? 使用这里的答案解释,为什么你认为将此类状态更改留给滴答事件处理程序、而不是消息接收处理程序更合适?

最后是第三个函数,它将状态呈现为图像:

; String -> (WorldState -> Image)
; 将世界的状态呈现为图像
 
(check-expect
 ((draw "Carl") 100)
 (underlay/xy (underlay/xy MT 50 100 BALL)
              5 85
              (text "Carl" 11 "black")))
 
(define (draw name)
  (lambda (w)
    (overlay/xy
     (cond
       [(symbol? w) (underlay/xy MT 10 10 (text "resting" 11 "red"))]
       [(number? w) (underlay/xy MT 50 w BALL)])
     5 85
     (text name 11 'black))))

这样做的话,我们就可以使用相同的程序创建许多不同的世界,都注册于本地计算机的服务器
; String -> WorldState
; 创建世界,并连接到LOCALHOST服务器
(define (create-world name)
  (big-bang WORLD0
   (on-receive receive)
   (to-draw    (draw n))
   (on-tick    move)
   (name       name)
   (register   LOCALHOST)))

现在先启动服务器,然后可以分别使用(create-world 'carl)(create-world 'sam)来运行两个不同的世界。 您可能希望在这里使用launch-many-worlds

习题:设计函数,能够处理宇宙和世界失去联系的情况。Result是否是此函数正确的契约?