Aura 的第一个应用,双向预览同一份正在跑的程序

Aura 发布之后,第一个拿给人用的应用是 果果编程课。上课的人是孩子。页面上有一盏灯、一排开关卡片、红绿蓝三条颜色,还有一张 8×8 的小画。

这些东西和旁边的 Aura 程序是同一份数据。翻一张卡片,程序里的 0 和 1 变。改程序里的字,再按一下,卡片跟着翻。真值是正在跑的工作区里的七个字符串字面量,不是浏览器里另存的一份状态。

这就是双向预览。它用的是 Aura 最细的那一刀:按节点编号做 mutate:replace-value。


这一课在演示什么

上一篇说过,现在的 Agent 多半在「生成文本 → 外面跑一下 → 再生成」里打转。精度靠 diff,反馈靠整段重编,改完也不知道哪个节点还是原来那个。

果果把这件事缩到一个孩子看得懂的动作上。

工作区里钉着七个名字:

1
2
3
4
5
6
7
8
(define *lamp* "1")
(define *width* "5")
(define *bits* "01011")
(define *red* "11111111")
(define *green* "00000000")
(define *blue* "00000000")
(define *pixels* "00111100
...")

灯是 *lamp*。卡片是 *bits*,张数是 *width*。颜色是 *red*、*green*、*blue*。小画的八行挤在 *pixels* 里。页面读这些字,也把这些字印回文本框。

所以孩子看到的不是「界面对一下模型」。他看到的是:程序就是这些开关的写法,写法就是正在跑的那几个节点。


一次翻卡片,实际走了什么

页面不保存开关。点击第 0 张卡片,只发一行:

1
2
POST /api
flip 0

服务端分成三截。

先算。 bits:command 看出正文不是一段程序,交给 bits:line。bits:do-flip 在一份新状态里把那一位从 0 改成 1,或从 1 改成 0。这一截是普通计算,不碰工作区。进制、字母、颜色、像素也都在 aura/bits.aura 里,纯函数。

再写。 算出的状态要变回七个字符串。board:commit! 拿它们和已经钉住的七个节点逐个比。文本一样的跳过。变了的收成一批:

1
2
3
(mutate:atomic-batch
(list (list "mutate:replace-value" 字面量编号 "11011" "guoguo"))
"guoguo")

翻一张卡片,通常只有 *bits* 进这批。改一种颜色,动的是 *red*、*green* 或 *blue* 里的一个。写完再把节点读回来,和想要的字符串不一致,这次 mutation 作废,原来的字还在。

最后画。 成功之后,(current-source) 写入 runtime/live.aura。返回的 JSON 里有新的 bits、印出来的程序、工作区原文,以及每个格子的定义号、字面量号、新文本、这次有没有改过。mutation-log 给出一行人话:replace-value #…,改了工作区里的 *bits*。页面用 bits 把卡片重画,用这行字告诉孩子:编号没变,变的是引号里的字。

文本框正在被改、还没按「用这段程序更新画面」时,返回的程序不会盖掉正在打的字。


它怎么知道要改哪一个 AST 节点

不靠在源码里搜字符串,也不是页面把节点号传回来。

启动时做一次 set-code,装上 aura/board-seed.aura,再 eval-current。然后 board:pin! 对七个名字各走一遍:

  1. (query :find "*bits*") 打到定义名索引,得到这个 define 的节点号。
  2. (query :children 那个号) 的第一个孩子是定义的值,也就是 "01011" 那个 LiteralString。
  3. 确认它读出来是字符串,把这个孩子的编号放进 *ids*。

之后每次 replace-value 都拿 *ids* 里的整数。replace-value 就地改这个节点的内容,编号不变,所以钉一次就够。页面上的「定义 12 → 字 34」是另一次查询,给人看的。真正写进去的是「字」那个号。


细到什么程度

这一刀是细的,边界也要说清楚。

replace-value 只改那一个 LiteralString。脏标记从它沿着父指针往上走:字面量、它所属的 define,再往上到更外层。旁边那六个定义的内部不会被重写。这个定义的运行时格子会当场换成新字符串,不用把整份程序 set-code 再装一遍。

它没有做的,是按使用点把整个程序的依赖图重编一遍。开关课的工作区里没有别的定义去调用 *bits*。卡片为什么变成十进制 27,是 bits.aura 用新字符串重算的,不是编译器去重编译一棵调用图。

和「改一个字符就重读、重解析、重装整份源文件」比,这是节点级的写。和「语言自动找出所有读过这个绑定的地方并只重做那些地方」比,果果这课还没走到那一步。七个格子是一个闭集。


从程序改回画面

另一个方向走同一条写路径。

文本框里是工作区印出来的程序,「工作区原文」是 (current-source)。按下按钮,POST 的正文是整段带 (define *bits* "…") 的文本。bits:command 看见 (define,改走 bits:apply:按名字抽出七个字符串,检查灯只能是 0 或 1,宽度只能是 4、5 或 8,*bits* 的长度要和宽度一样,颜色和像素必须是规定的 0 和 1。

通过之后,还是 board:commit!,还是只 replace-value 那些变了的字面量。不会把孩子贴来的正文整段 set-code 进工作区。

因此多写一个 (define *age* "7") 不会把服务弄坏,也不会变成新节点。应用成功后,文本框按工作区重印,这个定义消失。从 runtime/live.aura 读回来也一样:文件里仍是那七个定义,再替换一次字面量。

课进行中,树的形状不动。栈帧一层层压上去、malloc 多出一块、学生往指令序列里插入一行,这些才需要 mutate:splice、mutate:wrap、mutate:set-body 这种会增减节点的操作,插完还要重新钉住新编号。开关课故意不碰这些。


和其他语言差在哪

用 Rust 做同一页,常见写法是结构体里放 bits: u32,源文件里再放一份 "01011",自己把两边同步。改界面改的是结构体。改源码要重解析,或者让编译器增量重编这个 crate。程序拿不到「这个字符串字面量的节点号是 34,改完还是 34」。rustc 的增量单位是编译产物,不是你能钉住的 AST 节点。

用 Python 做,多半是一份 dict,外加 exec 一段文本。exec 进来的是新对象。上一次那个字符串没有身份,谈不上原地替换。

传统 Lisp 已经把代码当列表。可是常见路径仍是 read 一段文本,得到一棵新的列表,再 eval。你手里的旧 cons 不一定还在。编辑器里的 S 表达式和镜像里正在跑的对象,常常仍是两份。

Smalltalk 的镜像是活的,方法却通常按源码重新编译进类。浏览器改的是方法文本,不是一个钉死的字面量节点。React 的热更新换的是模块;组件状态要另想办法留住。Excel 的格子和公式互相推,但那不是一棵你能 query 的程序树。

Aura 在果果里换成这一句:运行中的那个节点就是值。页面是它的一种看法,印出来的程序是另一种看法。改的时候报节点号,不报「第几行第几列」。改完定义号和字面量号还在,所以页面可以指着同一格说:这一笔是 replace-value。

失败时也靠这个。命令不合法,board:commit! 不去写,JSON 里 ok 为假,位保持原样。没有「文本已经改了,运行的还是旧的」这种裂开。


这课用了 Aura 的哪一层

用满的是工作区这一层:set-code 一次,query :find / query :children 定位,mutate:replace-value 与 mutate:atomic-batch 写入,节点编号保持稳定,(current-source) 把工作区印回文本。

没用的也值得写上。没有 agent:spawn,没有多 Agent 管线,没有 JIT,也没有在课上长出新节点。bits.aura 里的进制和像素是普通函数。双向预览证明的是:自修改的最小闭环可以小到一个字符串节点,并且小到一个孩子能看见。

更大的系统课可以两条路并存。位、编码、固定的小机器状态,继续钉字面量。栈、堆、指令序列、链接和进程,节点个数会变,那时再改树。


代码在哪

课程仓库:cybrid-systems/guoguo。

文件 做什么
aura/board-seed.aura 装进工作区的七个定义
aura/board.aura 钉住字面量,并用 replace-value 改它们
aura/bits.aura 位、进制、颜色、像素,不碰工作区
aura/serve.aura 本机预览服务
web/index.html 孩子点的那一页

本地打开:

1
2
cd guoguo
make run

浏览器访问 http://127.0.0.1:8765。第 2 课是 http://127.0.0.1:8765/?lesson=2。


上一篇说:代码,自己进化。果果让人先看见进化的最小一步。改的是正在跑的那一个字,编号还在,画面和程序一起变。