ADCakeyuan's blog

手写一门编程语言(一):在写第一行代码之前,先画设计图纸

Favicon 800x800.png
Published on
//
9 分钟读完
/
TL;DR

造语言的第一步不是写代码,是定规矩:拒绝清单(不做 ASI、不做类、不做优化)加五个语义决定(0 为真、严格加法、深相等、or 返回操作数、中文标识符)。一张 EBNF 文法表就是全部骨架。

我花了一个周末,用 TypeScript 从零手写了一门编程语言,名字叫 Cha(茶)。词法分析器、递归下降解析器、静态作用域解析、树遍历求值器,整条流水线都是手写的,零依赖,100 个测试用例,代码在 github.com/huqinyuan923-hue/cha-lang。

先看一眼它长什么样:

var 杯子 = ["龙井", "普洱", "铁观音"];
fn 冲泡(茶名, 次数) {
    return "第 ${次数} 泡 ${茶名}";
}
for (var i = 1; i <= len(杯子); i += 1) {
    print(冲泡(杯子[i - 1], i));
}
// 第 1 泡 龙井
// 第 2 泡 普洱
// 第 3 泡 铁观音

中文变量名是一等公民,模板字符串可以任意嵌套,函数是闭包。就这些——它不是用来替代任何语言的,它是一门玩具语言。

这篇是系列第一章。第一章不写代码,只做一件事:把不做什么想清楚。

为什么写一门没人用的语言

我发现每个写代码的人心里都藏着一个问题:「我天天用的语言,到底是怎么跑起来的?」这个问题用读书回答,读的时候懂,合上书就忘。用造一个回答,忘不掉。

但造语言的第一个坑不是技术,是定位。网上大量的教程(包括那本著名的 Crafting Interpreters)会带你实现一个「教学语言」,然后你得到一个和教程一模一样的东西,除了变量名。所以动工前我给自己定了几条:

  1. 它必须真的能跑程序。不是伪代码,不是只支持加法的 demo——闭包、递归、数据结构、报错,一个都不能少,否则后面没得写。
  2. 它必须有至少三个「教程里没有」的决定。这些决定就是这一系列文章的血肉:不是「跟着抄」,而是「我选了 A,代价是 B,理由是 C」。
  3. 每一章结束都必须能运行。不攒大招,每一步都是完整可用的语言。

同时我列了一张拒绝清单,动工后每次手痒都回来读一遍:

  • ❌ 不做自动分号插入(ASI 是无底洞,JS 为它付出了二十年的骂名)
  • ❌ 不做类和继承(v1 先把函数和闭包做对,类留给 v2)
  • ❌ 不做性能优化(树遍历求值器慢得理直气壮,字节码 VM 是第八章的对照组)
  • ❌ 不追求语法兼容任何现有语言(不做就是不做,半做最难受)

语法取样:大括号、缩进,还是括号

第一个真正的决定:语法外形。

  • Python 式缩进:好看,但词法器要处理 INDENT/DEDENT 栈,第 2 章的篇幅会被它吃掉一半
  • Lisp 式括号:解析器最简单,但对中文读者来说阅读门槛陡增
  • C 家族大括号:解析器工作量中等,读者零学习成本

我选了大括号。理由很务实:这个系列想讲清楚的是「语言是怎么跑起来的」,不是「解析缩进的九九八十一难」。把篇幅留给更值得讲的东西——比如模板字符串插值,那才是第 2 章真正的高潮。

五个一定会被问到的语义决定

1. 0 是真值

if (0) print("0 是真的");   // 会打印!

这是跟 Lox(Crafting Interpreters 的教学语言)学的:只有 false 和 nil 为假。好处是规则一句话说完;代价是 0 or 99 返回 0 而不是 99。我接受这个代价,因为它逼着你写显式的条件——而「逼你写显式条件」本身就是这门语言的口味。

2. 加法是严格的

print("1" + 2);   // 报错:加法需要两个数字或两个字符串;混排请用 "${...}" 插值

JavaScript 的 + 一个人干了三件事(加法、拼接、隐式转换),所以才有 "1" + 2 === "12" 这种面试题。Cha 的 + 只能数字加数字、字符串加字符串。想混排?模板字符串伺候。报错信息里我直接把写法告诉你——报错是语言的脸面,这句话后面会反复出现。

3. 相等是深比较

print([1, [2, 3]] == [1, [2, 3]]);   // true

JS 的数组比较比的是引用,写 == 十有八九是 bug。玩具语言没有引用心智负担的必要——数组、map 直接递归比内容。代价是性能和循环引用(后者记在路线图上)。

4. and/or 短路,并返回操作数本身

print(nil or "默认值");   // "默认值"

Python 风格。a or b 不是返回布尔值,而是返回「决定结果的那个操作数」。这让 or 天然成了默认值运算符。

5. 中文标识符是一等公民

var 总价 = 0;
var 购物车 = ["茶", "点心"];

这是全文最没有技术含量、但最有情绪价值的决定。判定规则只是往 isAlpha 里加两个 CJK 区段,一行代码;但第一眼看过去,这门语言就「是我的」了。

图纸:一张文法表

整个语言的骨架,一张表就能画完(这是递归下降的好处:文法长什么样,代码就长什么样):

program      → declaration* EOF ;
declaration  → fnDecl | varDecl | statement ;
fnDecl       → "fn" IDENTIFIER "(" params? ")" block ;
varDecl      → "var" IDENTIFIER ("=" expression)? ";" ;
statement    → ifStmt | whileStmt | forStmt | returnStmt | block | exprStmt ;
 
expression   → assignment ;
assignment   → target ("=" | "+=" | ...) assignment     (* 右结合 *)
             | conditional ;
conditional  → logic_or ("?" expression ":" expression)? ;
logic_or     → logic_and ("or" logic_and)* ;
equality     → comparison (("==" | "!=") comparison)* ;
term         → factor (("+" | "-") factor)* ;
factor       → unary (("*" | "/" | "%") unary)* ;
unary        → ("!" | "-") unary | call ;
call         → primary ( "(" args? ")" | "[" index "]" | "." IDENT )* ;
primary      → NUMBER | STRING | "true" | "false" | "nil"
             | IDENT | "(" expression ")" | array | map | fnExpr ;

优先级藏在文法的「层」里:越靠上的规则优先级越低,assignment 最松,call 最紧。第三章会讲这个结构怎么直接翻译成代码。

交付物与自查

第一章的「可运行里程碑」比较特殊:它是一个空仓库和一张图纸。自查清单是——

  • 能用一段话说清这门语言是什么、不是什么
  • 拒绝清单里每一条都知道为什么拒绝
  • 文法表覆盖了后面五章要用到的全部语法
  • 五个语义决定各写了一句「代价是什么」

下一章进入真正的第一行代码:词法分析器。字符流怎么变成 token 流、模板字符串 ${} 的插值为什么是整个项目里最难的十行代码、以及一个我至今没修的词法歧义(留了个设计题给未来的自己)。

本系列代码全部开源在 cha-lang,每篇文章对应的代码都在 git 历史里,可以按 commit 对照读。