造语言的第一步不是写代码,是定规矩:拒绝清单(不做 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)会带你实现一个「教学语言」,然后你得到一个和教程一模一样的东西,除了变量名。所以动工前我给自己定了几条:
- 它必须真的能跑程序。不是伪代码,不是只支持加法的 demo——闭包、递归、数据结构、报错,一个都不能少,否则后面没得写。
- 它必须有至少三个「教程里没有」的决定。这些决定就是这一系列文章的血肉:不是「跟着抄」,而是「我选了 A,代价是 B,理由是 C」。
- 每一章结束都必须能运行。不攒大招,每一步都是完整可用的语言。
同时我列了一张拒绝清单,动工后每次手痒都回来读一遍:
- ❌ 不做自动分号插入(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]]); // trueJS 的数组比较比的是引用,写 == 十有八九是 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 对照读。
继续阅读 · 相关文章
根据文章内容的语义相似度自动推荐