Featured image of post 17 个词画出一朵玫瑰

17 个词画出一朵玫瑰

一门图灵完备的绘图语言:后缀式栈机、静态栈分析,和七行代码画出的三瓣玫瑰。

1
2
3
4
# 每轮固定转 1/47 圈,半径按 1+0.45·sin(3θ) 起伏 —— 走满一圈正好闭合
0.021277 turn
0.021277 i * 3 * sin 0.45 * 1 + 55 * fwd
0.0008 i * color

七行,画出一朵三瓣玫瑰。

这是一门语言的完整程序。语料只有 17 个词,但它图灵完备——jz 加回跳足以表达任意控制流。

打开一个 html 文件就能用:左边写程序,按 Ctrl+Enter 重跑,画布上出现图案。单文件、离线、零依赖。


17 个词

词作用
数字 / p0…p5 / i压入常量、参数滑杆、当前轮数
+ - / mod neg二元算术;neg 取负
> < sign比较(返回 0/1);sign 取符号
sin cos输入是圈不是弧度:0.25 → 90°
rand伪随机 0…1,种子固定 ⇒ 可复现
fwd back沿朝向前进/后退(画线)
turn right转向:turn 0.25 = 左转 90°
jump画笔瞬移到 (x, y),不画线
N st / N ld存入 / 取出 N 号寄存器(N = 0…7)
pen color width抬落笔、色相 0…1、笔宽
jmp N / jz N跳到第 N 行;jz 在栈顶为 0 时跳
pause / halt交还控制权(动画靠它)/ 停机

栈是唯一的数据通道,所以约定是后缀式:写 0.25 turn,不写 turn 0.25。

跳转用代码行号。跳到注释行或空行时会自动顺延到后面第一条真指令。

程序会被反复执行——这就是动画的来源,i 是已经跑过的轮数。


设计上真正需要决定的三件事

写这门语言的过程中,有三处不做决定就写不下去。

1. 循环的进度谁提供?

不是 while,而是「程序反复执行 + i 计数」。

好处是写参数方程时不用自己维护计数器。代价很实在:每轮的步长必须一样。用 i 递增角度画不出闭合曲线——每轮走的角不同,永远回不到起点。

这个坑是我自己写示例时踩到的,注释留在了「三瓣」示例里。

2. 循环里的状态放哪?

最初只有栈。而栈每轮必须清空,否则运行时会一圈圈涨。

结果:累加器根本写不出来。

所以加了寄存器:5 0 st 存、0 ld 取。

栈保持"每轮归零"的干净语义,跨轮状态一律走寄存器。分析器因此能给出很强的保证:程序停止时栈必须是空的。

3. 怎么保证这些约定不被写坏?

靠 analyze():对编译出的指令做前向数据流分析。

一条指令被不同路径以不同栈深到达,就重新入队(上限 6 个栈深,再多就是无限增长)。

判据是程序停止时栈深为 0。这一条覆盖三种坑:栈被弹穿、分支不均衡、循环里偷偷堆值。

被拒绝时给的是可读的诊断:

1
2
✗ 栈不平衡:第 1 行(栈在循环里无限增长(同一条指令被越来越深的栈反复到达))。
  每个词都要自己把栈收干净,跳转的两个分支不能一个多一个少。

编译期还有一道 peephole:N fwd 折成一条带参指令(fwd#N),既让指令数少一半,也消掉了"两个字面量在分支里叠在栈上"的歧义。


验证

1
2
3
node verify.mjs                      # 63 项断言
node verify-browser.mjs              # 27 项断言(真机 Chromium)
node tools/make-samples.mjs --check  # 逐个示例:编译 → 栈分析 → 真跑一遍
  • 确定性:同一种子逐位一致,不同种子不同图 ⇒ 任何观察都可复现
  • 敌意输入:空程序、只有注释、除零、取模零、寄存器槽越界、1e9 大数 —— 都不崩
  • 性能:约 2400 万条指令/秒(单线程);界面默认每秒执行 600 条
  • 示例是机械生成的:生成器先编译、做栈分析、真跑 8000 步,全部通过才写回 loom.html

最后这条是被逼出来的做法——在此之前我手工改示例错了五次。

验证抓到的 8 个真实缺陷

#缺陷症状
1跳转目标被折成 ["lit", idx]jz 拿到 0 而不是目标行,条件跳转全部失效
2跳转占位指令多占一个槽位目标整体错位,i 永不递增
3跳转参数是字符串 "0"Number.isFinite("0") 为 false ⇒ 回边从未被分析
4末端判定读的是执行前的栈深1 fwd 这类正常程序被误判不平衡
5分析器只记"首达"栈深每轮偷偷堆值的程序被放行
6缺"每轮入口栈深一致"不变量同上——运行 1500 步后栈涨到 1500
7示例里 i 0.1 color 的栈残留每轮留一个数,栈无限增长
8自动取景用"全部历史外接框"随机游走把视野撑到无穷,画面全黑

第 8 条不是语言问题,是可视化策略问题。用历史包络去取景,对无界图形必然是错的;改成跟随画笔当前位置之后才正常。

第 3 条尤其典型:"0" 和 0 在 JavaScript 里看起来都像 0,但 Number.isFinite("0") 是 false。于是回边从来没被分析过,静态检查等于不存在——而界面一切正常。


它现在能画什么

它只画线。但 17 个词加上反复执行,能出来的东西比想象的多:蕨类、格栅、漂移的涡、以及那朵玫瑰。

参数滑杆 p0…p5 让你不改代码就能调形状——这也是它作为一个"玩的东西"成立的原因。


打开 Loom 玩一下 →


还能往哪走

  • 图形输出:现在只画线。加 fill / stroke 路径模式就能做面
  • 多画笔:pen 已经是状态机,加若干"画笔槽"就能一次画多层
  • 编辑器:行号已经在了,差行内报错高亮;analyze() 已经能给出准确行号,接口是现成的

17 个词听起来很少。

但语言的表达能力从来不是词表决定的,是组合规则决定的。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计