Knight 是一种专为「实现简便」而设计的极简编程语言,其核心理念与主流语言截然不同:不是让程序员写代码更容易,而是让语言实现者写编译器更容易。这种「反向设计」思维使得 Knight 的编译器实现具有独特的简洁性与优雅性,本文将深入解析其 IR 设计、字节码生成策略与求值机制。
波兰表示法与递归下降解析
Knight 采用波兰表示法(Polish Notation,PN),即函数位于参数之前,这与 Lisp 的前缀表达式类似但有本质区别。在传统语言中,我们需要处理运算符优先级和结合性,而在 Knight 中这完全不需要 —— 每个函数都有固定的元数(arity),解析器只需读取一个 token,然后根据该 token 的元数解析对应数量的子表达式即可。
以一个猜数游戏为例,其结构清晰展示了 PN 的解析逻辑:; = secret RANDOM 被解析为二元函数 ; 接收两个子表达式:赋值操作 = 和后续的 OUTPUT IF 表达式。这种自顶向下的解析模式使得递归下降成为最自然的实现方式,无需复杂的运算符优先级表或移进归约分析器。
函数命名采用独特的「首字符标识」机制:词法函数(如 OUTPUT、RANDOM)由首字符唯一确定,跟在后面的任何大写字母均被忽略。这意味着 OUTPUT、OUT、OFOOBARBAZ 指代同一个函数。符号函数(如 +、-、;)则只消耗单个字符,+++ 会被解析为三个独立的加法操作。
类型系统与不可变设计
Knight 仅包含六种类型:整数(Integer)、字符串(String)、布尔(Boolean)、空值(Null)、列表(List)和代码块(Block)。所有类型均为不可变(immutable),这一设计决策显著简化了编译器的内存管理 —— 任何「修改」操作都返回新值而非就地修改。
整数类型要求实现至少支持 32 位有符号整数范围(-2147483648 到 2147483647),这足以满足大多数教学场景。规范明确指出整数溢出属于未定义行为(Undefined Behaviour,UB),这意味着实现可以自由选择更大的整数类型而无需处理边界情况。负整数到列表的转换同样被标记为 UB,避免了处理负数位展开的复杂性。
字符串采用纯文本表示,不支持转义序列。若需换行,直接在源码中换行即可:OUTPUT "hello\nworld" 会输出真正的换行。这种设计使得字符串处理代码大幅简化,代价是程序员需要手动处理换行符。列表推导同样简洁:通过 +@integer 可将整数各位数字转为列表,通过 +@string 可将字符串拆解为单字符列表。
Block 是 Knight 独有的延迟执行机制。通过 BLOCK 函数可以将任意表达式封装为 Block 对象,之后通过 CALL 执行。由于 Knight 没有局部变量,Block 只能访问全局变量,这使得闭包实现变得 trivial—— 只需保存抽象语法树(AST)本身,无需维护额外的作用域链。
上下文感知的参数求值
Knight 的函数求值采用「上下文」机制:每个函数对其参数施加特定的求值要求。这些上下文包括:
integer 上下文:参数被求值后转换为整数。例如 + 函数的第一个参数为 integer 上下文,会将参数强制转换为整数后进行加法运算。
string 上下文:参数被转换为字符串。OUTPUT 函数要求其参数为 string 上下文,自动完成类型转换并输出。
unchanged 上下文:参数被求值但不做类型转换,直接传递给函数。赋值函数 = 的第二个参数即为 unchanged 上下文。
unevaluated 上下文:参数完全不求值,直接作为 AST 节点传递。这是实现短路求值的关键:IF 函数的第二、第三个参数为 unevaluated,只有在条件为真 / 假时才求值对应分支;WHILE 函数的两个参数均为 unevaluated,确保循环条件在每次迭代前重新求值。
这种上下文分离的设计使得编译器可以在解析阶段确定参数的处理方式,而无需在运行时进行复杂的类型推断。
有意未定义行为:工程权衡的典范
Knight 规范的独特之处在于大量使用未定义行为(UB)。这一设计乍看之下违反直觉 ——UB 通常被视为语言设计的失败 —— 但在 Knight 的设计目标下却极为合理:让语言尽可能易于在各种宿主语言中实现。
规范中明确标记为 UB 的场景包括:整数溢出、负整数转列表、除零、使用未初始化变量、字符串包含非法字符等。规范甚至明确指出:「遇到 UB 时,整个程序无效;实现可以做任何想做的事(忽略错误、崩溃、自定义扩展行为均可)。」
这种设计将「正确性检查」的负担从实现者转移到程序员。对于能够轻松检测的错误(如除零),实现可以选择抛出异常;对于难以检测的错误(如标准输出被关闭),实现可以完全不处理。在 Brainf*ck 那样不支持异常捕获的语言中,这种设计使得 Knight 仍能实现。
Rust 实现提供了 strict-compliance 特性来检测所有 UB,这是教学和调试的利器,但普通使用时可以选择跳过这些检查以换取性能。
字节码生成与求值策略
当前多数 Knight 实现采用 AST 解释器模式:解析器生成 AST,解释器递归遍历 AST 并求值。这种实现虽然不如字节码高效,但鉴于 Knight 的极简特性和「教学语言」的定位,完全足够。
若要实现真正的字节码编译器,Knight 的设计同样友好。由于没有局部变量,字节码无需处理栈帧的复杂分配;全局变量可以通过简单的索引表访问。固定元数意味着函数调用可以严格对应到固定的参数个数,简化了调用约定的设计。
_block 和 _call 的实现是编译器的核心挑战。BLOCK 只需返回其参数的 AST 节点(不求值),而 CALL 需要再次遍历该 AST。编译时,Block 可以表示为跳转标签,调用时只需跳转到对应位置并创建一个新的执行上下文。
实现参数建议
基于 Knight 规范,以下是实现编译器时的关键参数建议:
整数范围:至少支持 32 位有符号整数;若使用 64 位整数,需决定溢出时采用饱和、抛异常还是返回 UB。
字符串编码:ASCII 子集是强制要求;UTF-8 扩展为可选。实现时应明确文档化支持的字符集。
变量命名:长度限制 1-127 字符,变量数量至少支持 65535 个。
求值顺序:所有参数必须从左到右求值,这是 ; 函数(顺序执行)正确工作的基础。
Block 上下文:Block 只能作为 :, BLOCK, CALL, ,, =, WHILE, &, |, ;, IF 的特定参数使用,其他场景均为 UB。
小结
Knight 语言的设计展现了一种独特的语言哲学:通过向程序员施加负担来减轻实现者的复杂度。波兰表示法消除了优先级解析的需要,固定元数简化了函数调用,上下文机制明确了类型转换规则,而大量的 UB 则为实现者提供了灵活空间。
这种设计使得 Knight 能够在从 sed 到 Python、从 Prolog 到 x86 汇编的数十种语言中实现 —— 这本身就是一个工程奇迹。对于编译器学习者而言,Knight 是理想的实验平台:规范清晰、实现多样、核心概念集中于解析与求值两大环节。
资料来源:Knight Language Official Repository (https://github.com/knight-lang/knight-lang)