这篇文章来自一次很长的对话。我原本是在设计一门 toy language,聊着聊着,话题滑到了我为什么会喜欢某些语言、讨厌另一些语言,最后滑到了一个我从来没有这么清楚地看见过的东西上。
我想把它记下来,因为那种感觉每隔一段时间就会来一次,而我这次终于给它找到了准确的形状。
一、我以为我在解决的问题
我在写一门语言,叫 anina。它的立场是「C FFI 是立场,不是方便」——没有运行时,没有标准库,libc 就是标准库。它不是一个引擎,是一副透镜:它不提供能力,只提供一种看法,能力全在 C 那边。
设计进行得挺顺。我把语法、类型、模块、作用域一条条写成决策记录,写了三十多条。然后我开始觉得不对劲。
首先是细节开始变得很脏。字符串要不要带结尾的 \0、结构体怎么传参、子字长在 ABI 边界怎么扩展、变参怎么提升……每一条都是真的问题,每一条都要单独想。我开始怀疑:我是不是太天真了?我想要的是像 Lua 那样「短小精悍、概念极少」的一门语言,然后借用现成的生态。会不会这条路根本走不通?
那天晚上我脑子里冒出两个「退路」:
- 改成编译到 WASI 上的 WASM,C ABI 交给 wasi-sdk 去解决。
- 干脆把它做成脚本语言。
二、Lua 的错觉
先说清楚一件事,因为它是我最初想法的错误前提。
Lua 的「小」,和 anina 的「小」,根本不是同一种小。
Lua 小,是因为它有运行时。GC、字符串、table、错误处理、解释器、字节码 VM,这些全都在它里面,只是你没看见。它少的是规则,不是能力。它是一个引擎。
anina 小,是因为它没有能力——能力全在 C 那边。它是一个透镜。
| 少规则 | 能力从哪来 | 代价 | |
|---|---|---|---|
| Lua | ✅ | 自己的运行时 | 你得写一个引擎 |
| anina | ✅ | C 生态 | 你得跨 C ABI |
我想要的「Lua 那种感觉」,一部分其实来自 Lua 是自足的。而我主动放弃了自足。少规则 + 不自足 = 必须跨 ABI。 ABI 就是这条路的入场费,不能既要又要。
三、两条退路,其实都不解决
关于 WASI。 首先一个硬事实:我用的后端 QBE 根本不支持 wasm(它只有 amd64、arm64、rv64)。选这条路意味着自己写一个 wasm 后端。
更重要的是:WASI 的 ABI 本身就是 C ABI。它一样有结构体传参规则、一样有子字长扩展、一样有变参问题。而函数指针在 wasm 里根本不是地址——是 call_indirect 加一张函数表,而且类型必须精确匹配,错了是运行时崩溃。我最好的一部分设计(函数值就是地址)直接作废。
WASI 唯一的真实好处是可移植和沙箱。它不会让我少碰 ABI,它只是把一批 ABI 细节换成另一批。
关于变成脚本语言。 这条是真的不一样——它是对症下药的。脚本语言是自足的:你的整数就是你的整数,没有 ABI 要跨,对齐、varargs、结构体传参这些痛全部消失。代价是:你必须写一个引擎(VM、GC、字符串、表、错误处理……),而且这直接和「没有运行时」的立场冲突。
它不是在解决 anina 的问题,它是变成另一门语言。
四、然后我看了 WAT
这就是转折点。
我去了解了一下 WAT(wasm 的文本格式),突然有一种「这其实就是我想要的编程语言」的感觉。
不是想停止 anina。是一种更奇怪的感觉:我好像早就认识它,只是第一次见到它长这样。
后来我反应过来为什么。因为我研究过 Forth,也看过 Lisp,还试着自己写过一个 Forth 的脚本方言。WAT 就是 Forth 穿了件 Lisp 的马甲。 i32.add 就是一个 Forth 词,栈就是那个栈,后缀就是后缀,而那个 (module ...) 的括号,是 Lisp 借给它的语法。
我不是被 wasm 抓到的。我是被那个早就爱上的东西,换了个面孔抓到的。
五、四个形状,一根轴
那一晚最值钱的发现,是我把四个东西排到了一起看:
| 少规则 | 有类型 | 可扩展 | 能被验证 | 能绑 C | |
|---|---|---|---|---|---|
| Forth | ✅ | ❌ | ✅✅ | ❌ | ❌ |
| 我的方言 | ✅ | ✅ | ? | ❌ | ❌ |
| anina | ✅ | ✅ | ❌ | ❌ | ✅ |
| WAT | ✅ | ✅ | ❌ | ✅ | 部分 |
Forth 的力量来自它是「无类型的、可以长出新语言」,所以人脑必须当类型系统,而且 FFI 无从下手。
我那个方言撞的墙,是在加类型之后出现的:条件语句的两个分支必须返回同一个类型,否则编译期没法确定。我一直在找一种「优雅的语法」来解决它,找不到。
anina 的词法语法被 C 拽成了 C 形——因为它的立场就是当 C 的可读写法。
WAT 免费得到一个校验器(类型检查交给 wasm 引擎了),但它的指令集不可扩展。
我没有在四个兴趣之间飘来飘去。我是在同一根轴上,来回走了很多年。 每走一步,都是因为「这里有一半我受不了」。
还有一件事很好笑:如果我坚持单一的 cell 类型,那堵墙根本不存在。 一个 cell 全是 64 位,+ 永远是同一个操作,两个分支永远同型。我当初没有那个问题,正是因为我没有类型——是加了类型才有的。然后我开始为它设计优雅的标注语法,等于在给一个我本来不需要的东西造外壳。
而那堵墙还有一个更古老的解法:让 if 不产出值。空返回类型的 if,两个分支只要栈效应为零就行,「两个分支返回同一个类型」这个问题,用「不返回任何东西」绕开。
而这恰恰就是 anina 里我早就写下的一条决定:when 是语句,块没有值,不做三元。
我在方言里找不到的答案,我在 anina 里已经写下来过一次了,只是没认出来是同一道题。
六、有底的东西
说说那种完满感是从哪来的。
我后来想清楚了一件事:我爱的不是「小」,是「有底」。
看一幅画的快感,来自对象是无限的、而我的注意力是有限的——对象没有底,所以那份完满是我给的。
WAT 不一样。它有一个底,而我真的走到了那个底。i32.add 吃两个 i32 吐一个 i32,它不会「取决于上下文」,不会「取决于某个平台上实际上会怎样」。它没有一层在下面。
而且这不是巧合。WAT 是为了被穷尽地验证而造出来的,所以它的设计者必须给它一个底。我遇到的不是一个碰巧舒服的小东西,是一个目的就是「可以被完整看透」的东西。
还有一点更罕见:WAT 的规格和它的实现是同一个大小。 大部分东西里,规范(spec)和实物之间永远有道缝——C 的标准和某个具体编译器有偏差,某个语言的手册和它实际跑起来的样子有偏差。那道缝是工程界永恒的痛。 WAT 没有这道缝,因为那些推断规则就是语言本身。
再加上它没有考古层。很多老技术读起来像遗迹——一层层为了兼容叠上去,你必须知道每一层的历史才能读懂。WAT 是从零推导出来的,读起来像演绎。遗迹和演绎,体感完全不同。
所以那种感觉准确的说法是:
一个有底的对象 + 一段已经透明的关系。
它小,是它的功劳;我能看穿它,是我的功劳。两半缺一,都到不了这个位置。
七、一切都破破烂烂的
然后是那个我从来没跟人好好说过的东西。
我进入这个行业开始,几乎每天都伴随一种感觉:一切都是破破烂烂的。 我用过、读过的各种编程语言,CSS 布局,C ABI,各种各样的程序规范——能让我觉得「各有可取之处」的,很少。
我一直以为这是我太挑,或者是我看得太少、见得不够多。
后来我想明白了:它是真的。 而且其中一半是两种不同的东西混在一起。
大多数软件在大多数时候是失败的——这是事实描述,不是刻薄。
但真正重要的是另一件事:那些让我觉得破烂的东西,全都不是「设计失败」,而是「约束下的解」。
- JavaScript 为什么这么多坑?因为它必须让 1995 年以来写下的每一个网页都不坏掉。它的丑,是用「不改你的东西」换来的。
- C ABI 为什么这么烂?因为它根本不是被设计的,是被「所有编译器都得互相能调」逼出来的公约数。它是一个没有作者的结构。
- CSS 布局为什么这么反直觉?因为它要同时满足「内容流」和「二维定位」两种矛盾的模型,还不能广播。
发现了吗——「破烂」是「约束」的影子。
C ABI 从「设计」的角度看是垃圾;从「能让所有编译器全部互相说上话」的角度看,是人类历史上最成功的协议之一。
同一块石头,只是从约束的哪一面看。
夸的和骂的,其实是一条线
不过这里有个反例,它帮我把这件事想清楚了:有些东西我觉得干净,而且它们一点都不小。
我喜欢 TOML,因为它清爽、概念很少。我喜欢 JSON,因为它非常一致。甚至连 XML 我也愿意给好评——它又长又啰嗦,但它的啰嗦是匀的,同一个形状反复用。
这三种不是同一种美德:TOML 是「少」,JSON 是「齐」,XML 是「匀」。但 XML 让我意识到,我夸的不是小——XML 一点都不小。
那三样共享的是另一个东西:整个东西可以被一条规则说完,没有需要你知道历史才能懂的例外。
TOML 的每条规则是局部的,不跨条。JSON 几乎没有东西,但这个「没有」是彻底的——六种类型,一种语法,没有任何特殊情形。XML 啰嗦,但啰嗦得均匀。
而让我难受的那些东西,全是一条规则、外加一地的例外。
所以我的尺子不是「少」,是「匀」。 精确一点说:
我能原谅「缺」,不能原谅「例外」。
JSON 缺注释、缺日期、数字精度还有坑——我都放过它了,因为它是均匀地缺。而一个东西如果不多、但三处特例,我受不了。缺是整齐的,特例是乱的。
为什么格式做得到,系统做不到
这不是运气,是「对手是谁」决定的。
格式没有对手,系统有。 格式处理的是文本,它的输入空间可以被完整描述(就是一串字节),全部语义就是「这段文本是什么意思」。说明白了就完了,没有剩余。
而系统要面对真实世界:几十种机器互不认同、用户什么都干、时间还往回拽。真实世界一推,你就得加一个特例。 每个特例都是现实留下的一道疤。
JSON 要伺候的「现实」:让 JS 能 eval 它。一个主人,做到了,收工。
C ABI 要伺候的「现实」:几十种硬件 ABI,加所有编译器。于是满地是疤。
TOML 要伺候的「现实」:给配置文件用。目标就是保持最小。格式的规格可以是完整的,因为格式里没有「跑起来」这件事。
还有个几乎是证据的例子:XML 里最难看的部分是哪里?命名空间。 而命名空间是后补的(1999 年),不是当初设计的。当初那层——元素嵌套、属性——是匀的;后来为了应付现实补上去的那层,立刻长出了疤。
同一个规范里,原始部分干净,补丁部分难看。 这就是那把尺子最诚实的证据:我不是讨厌 XML,我是讨厌它身上补过的地方。
八、尺子和世界
这就把我放到了一个特定的位置上。
我爱的那些东西——WAT、Forth、那个封闭的小系统——它们的干净有一个共同来源:它们的约束被选得极小、极强、极清晰。
- WAT 的约束是「必须能被穷尽地验证」,于是它不能有考古层、不能有隐式行为。
- Forth 的约束是「一切是一个 cell」,于是它没有类型系统,也就没有类型系统的烂。
它们不是「设计得比 C 好」,是「它们选择了小到可以纯粹的约束」。
所以我爱的不是干净,是约束小到能看穿。而一个系统只要约束够大,就必然长成地质层。这不是谁的错,这是规模的代价。
而我每天难受的根源就是这个:我在一堆「约束大到不可能干净」的东西里工作,心里却有一把「只看约束小的东西」养出来的尺。
尺和世界不匹配。
这份品味有它的固定税:它会把这世界的大部分判为不可接受,而我爱的东西又大多用不上。更扎心的是——这份品味永远不会发明出 C,而 C 是我和世界说话的唯一通道。所以我大概会长期在「我爱的」和「我能用的」之间来回横跳,两边都到不了家。
但我其实已经在处理这件事了。看 anina:口味是纯的,选择是脏的。 我明知 C 的 ABI 丑、\0 丑、varargs 丑,还是选了它当立场。
所以实践上我一点都不极端,我只是心里有一把不肯降的尺。
九、结尾
那位对话者最后说了一句话,我想把它抄在这里,因为它把这件事说完了:
你可以不再当「用这些东西的人」,改当「翻译约束的人」。
你有一项别人少有的能力:你能同时看见「它烂」和「它为什么必须那么烂」。具备这个能力的人,做的事情一般不是抱怨,而是造一个薄而干净的层,把它包住。
WAT 包住了机器,anina 想包住 C。语言这个行业,本质上就是一群忍受不了脏、于是造薄壳的人。
你从入行到现在感到的那种「破破烂烂」,它是真的,它不会消失。但它至少有名字了,也有了一个可以安放它的位置。
我想要的不是「找到那门完美的语言」。我想要的是看清这件事到底是怎么回事。
那天晚上我看清了一点点,而且是在没有骗自己的前提下。
那个「一切都破破烂烂」的感觉,从今天起,大概会换个名字继续陪着我。