Aura 的第一个应用,双向预览同一份正在跑的程序
Aura 发布之后,第一个拿给人用的应用是 果果编程课。上课的人是孩子。页面上有一盏灯、一排开关卡片、红绿蓝三条颜色,还有一张 8×8 的小画。
这些东西和旁边的 Aura 程序是同一份数据。翻一张卡片,程序里的
0 和 1
变。改程序里的字,再按一下,卡片跟着翻。真值是正在跑的工作区里的七个字符串字面量,不是浏览器里另存的一份状态。
这就是双向预览。它用的是 Aura 最细的那一刀:按节点编号做
mutate:replace-value。
这一课在演示什么
上一篇说过,现在的 Agent 多半在「生成文本 → 外面跑一下 → 再生成」里打转。精度靠 diff,反馈靠整段重编,改完也不知道哪个节点还是原来那个。
果果把这件事缩到一个孩子看得懂的动作上。
工作区里钉着七个名字:
1 | (define *lamp* "1") |
灯是 *lamp*。卡片是 *bits*,张数是
*width*。颜色是
*red*、*green*、*blue*。小画的八行挤在
*pixels* 里。页面读这些字,也把这些字印回文本框。
所以孩子看到的不是「界面对一下模型」。他看到的是:程序就是这些开关的写法,写法就是正在跑的那几个节点。
一次翻卡片,实际走了什么
页面不保存开关。点击第 0 张卡片,只发一行:
1 | POST /api |
服务端分成三截。
先算。 bits:command
看出正文不是一段程序,交给
bits:line。bits:do-flip
在一份新状态里把那一位从 0 改成 1,或从
1 改成
0。这一截是普通计算,不碰工作区。进制、字母、颜色、像素也都在
aura/bits.aura 里,纯函数。
再写。
算出的状态要变回七个字符串。board:commit!
拿它们和已经钉住的七个节点逐个比。文本一样的跳过。变了的收成一批:
1 | (mutate:atomic-batch |
翻一张卡片,通常只有 *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! 对七个名字各走一遍:
(query :find "*bits*")打到定义名索引,得到这个define的节点号。(query :children 那个号)的第一个孩子是定义的值,也就是"01011"那个LiteralString。- 确认它读出来是字符串,把这个孩子的编号放进
*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 | cd guoguo |
浏览器访问 http://127.0.0.1:8765。第 2 课是
http://127.0.0.1:8765/?lesson=2。
上一篇说:代码,自己进化。果果让人先看见进化的最小一步。改的是正在跑的那一个字,编号还在,画面和程序一起变。