第 4 部分 · 命令式与状态
红绿灯状态机——状态、转移与驱动
红绿灯自己会变——红灯到时间变绿,绿灯到时间变黄,黄灯又回到红。这件再普通不过的事,拆开看就是三件事:状态、转移、驱动。把三件事各写一段代码,一个会自己切换的红绿灯就跑起来了。
你天天见红绿灯,但有没有想过它到底“在跑什么”?大多数路口的红绿灯不看你有没有车,也不按按钮——它只是在一个固定节奏上,把当前的颜色换成下一个,周而复始。
命令式编程的核心一句话能讲完:让某个状态在循环里不断改变。但“状态随时间改变”这种说法太抽象,听着像定义,不像程序。这篇用一个红绿灯把它钉死。钉死之后你会发现,所谓状态机,就是三件事——状态、转移、驱动。
红绿灯的三件事
先别碰代码,把红绿灯这件事拆开。
第一件是状态:此刻是什么颜色。红绿灯任一时刻只能是红、绿、黄三种之一,不会“半个红”,也不会又红又绿。
第二件是转移:从这个状态,下一步去哪个状态。红绿灯的规则固定得不能再固定——红变绿,绿变黄,黄变红,一个圈。
第三件是驱动:谁来推着它往前走。红绿灯不会自己跳,是时间在推——每隔一会儿,走一步。
状态、转移、驱动,缺一不可。没有状态,程序不知道“现在在哪”;没有转移,它不知道“下一步去哪”;没有驱动,它永远停在原地。
这三件事凑齐,就是一台状态机。
状态:用 symbol 给颜色起个名
Racket 里表示“红灯”,最自然的写法是 'red——前面一个引号,后面一个名字。
'red
;; 'red
(symbol? 'red)
;; #t
这个 'red 叫 symbol(符号)。引号的意思是:这个名字就代表它自己,不是要去查找的变量。你可以把它想成一张写着 red 的标签。
为什么不用字符串 "red"?字符串也能跑,但字符串是给人读的文本,而状态只是一个身份标记。
symbol 是写着名字的标签,比字符串更适合做状态。 比较两个 symbol 是否相等,是一步到位的 eq?:
(eq? 'red 'red)
;; #t
(eq? 'red 'green)
;; #f
三个状态先定下来:'red、'green、'yellow。
转移:一个 next 函数
转移规则是红→绿→黄→红。写成函数,就一个 case:
(define (next state)
(case state
[(red) 'green]
[(green) 'yellow]
[(yellow) 'red]))
case 拿一个值去匹配,每条 [(red) 'green] 读作:碰到 'red,就给 'green。当前是 'red 给 'green,是 'green 给 'yellow,是 'yellow 绕回 'red。
把它放进交互区试:
(next 'red)
;; 'green
(next 'green)
;; 'yellow
(next (next (next 'red)))
;; 'red 转一圈,回来了
注意 next 里没有时间、没有打印、没有 sleep——给它一个状态,它吐出下一个状态,别的什么都不做。
它是个纯函数:同一个状态进去,永远是同一个下一个状态出来。 这一点很关键。状态机的“规则”应该是死的、可预测的,把所有变化都留给驱动那一层。
驱动:让它自己跑起来
光有 next,红绿灯还不会动——你只是定义了“下一个是谁”,没人去问。驱动就是那个不断发问、不断推进的循环。
最直白的写法,就是命令式编程里那个熟悉的形状:一个变量存当前状态,循环里打印、等待、再把它盖写成下一个。把 next 和 run 放进一个文件:
#lang racket
(define (next state)
(case state
[(red) 'green]
[(green) 'yellow]
[(yellow) 'red]))
(define (run state rounds)
(for ([i (in-range rounds)])
(displayln state) ; 打印当前状态
(flush-output) ; 立刻刷到屏幕
(sleep 1) ; 等 1 秒
(set! state (next state)))) ; 推进到下一个状态
(run 'red 6)
跑起来,终端里每秒换一行:
red
green
yellow
red
green
yellow
这里的 state 是个会变的变量——每轮 set! 把它盖写成下一个状态。前面说“状态随时间改变”,落到代码里就是这一行 set!。
这正是命令式编程最典型的形态:一个变量,存着程序此刻的样子,被循环一步步推着往前走。
三行小注解:displayln 打印并换行;flush-output 强制把这一行立刻送到屏幕,不然输出可能攒在缓冲区里、最后一口气全吐出来;sleep 1 暂停 1 秒,参数是秒,也能写 0.5。
同一台机器,换个不靠 set! 的写法——递归:
(define (run state rounds)
(cond
[(zero? rounds) (void)]
[else (displayln state)
(flush-output)
(sleep 1)
(run (next state) (sub1 rounds))]))
这里没有任何赋值——每一轮递归,把“下一个状态”作为参数传给自己。“状态随时间改变”照样成立,只是“时间”变成了递归的层数,“状态”变成了那个参数。同一台机器,两种驱动方式:一个靠改变量,一个靠传参数。
for + set! | 递归 | |
|---|---|---|
| 状态存在哪 | 一个会被改写的变量 | 递归的参数 |
| 像哪种写法 | 命令式(C、Python 的循环) | 函数式(Racket 的家常味) |
| 适合 | 直接对应“变量随时间改变” | 不想引入赋值 |
关于 sleep:在文件里跑更直观
这段代码在 DrRacket 的交互区里能跑,但你可能会觉得它“一顿一顿”——交互区自己也在跑编辑器的事件循环,sleep 和即时打印会被它干扰,输出有时会攒着一起冒出来。
想看到干净的一秒一跳,把上面的代码存成文件,用命令行直接跑:
racket traffic.rkt
这没什么要“修”的——只是交互区本来就不是为定时任务设计的。凡是带 sleep、要看出节奏的程序,跑文件最直观。
让它更像真红绿灯:每个灯亮不同时长
真路口的红绿灯,绿灯亮得久,黄灯一闪就过。给每个状态配一个时长,状态机就开始贴近现实:
#lang racket
(define (next state)
(case state
[(red) 'green]
[(green) 'yellow]
[(yellow) 'red]))
(define (duration state)
(case state
[(red) 3]
[(green) 5]
[(yellow) 1]))
(define (run state rounds)
(cond
[(zero? rounds) (void)]
[else (displayln state)
(flush-output)
(sleep (duration state)) ; 按状态决定等多久
(run (next state) (sub1 rounds))]))
(run 'red 6)
现在红灯停 3 秒、绿灯 5 秒、黄灯 1 秒。三件事的结构一个没动——状态还是那三个 symbol,转移还是那个 next,只把“等多久”从写死的 1 换成了查表。
状态机的好处在这里显出来了:每个状态可以自带数据(时长、计数、分数),规则不变,行为更丰富。
想要画面:big-bang 是另一条驱动
终端里一行行打印,已经能看清这台机器在跑。如果你想要一个真窗口、三个会变色的圆,Racket 的 2htdp/universe 给了 big-bang:你给它一个初始状态、一个按时钟触发的 tick 处理器(就是你的 next),再给一个按状态画圆的绘图函数。
状态、转移两件事一字不改——变的只是驱动,从 sleep 循环换成 big-bang 的时钟,再额外加一件“画”。这一篇先用命令行把三件事讲透,画面留到你想要个能炫耀的演示时再说。
状态机无处不在
红绿灯只是最小的一台。一旦你养成“状态、转移、驱动”这三件套的眼光,会到处看见它:自动售货机的投币→选择→出货、TCP 连接的握手→传输→关闭、游戏角色的站立→跳跃→下落、登录框的输入→校验→成功。每一个都是一个当前状态,一条通往下一个状态的规则,外加一个推着它往前的事件或时钟。
把一台红绿灯拆成状态、转移、驱动三段代码,“状态随时间改变”就从一个抽象说法,变成了你能跑、能改、能预测的程序。状态机,也就这么回事。