代码如何变成行为:语言、编译器与 DSL
理解解析、语义检查、编译、解释与 JIT,用有界表达式语言练习允许节点、资源限制和错误契约。
解释代码执行阶段,并实现仅接受有界整数、加法和乘法的教学解释器。
代码能被运行,依赖的不只是它看起来像某种语言。字符要被解析成结构,结构要满足语言的规则,最后还要由某种执行机制产生行为。理解这些阶段,能分清语法错误、类型错误与运行时错误,也能判断一段来自模型的表达式能不能执行。
我们设计一个只允许整数、加法和乘法的小表达式语言。它用于教学计算规则,不接受函数调用、文件操作或任意 Python 程序。这种受限语言可以称为领域特定语言(DSL)。
语法、语义和运行环境
语法规定文本怎样组成合法结构,例如括号怎样配对、运算符怎样连接操作数。语义规定结构的含义,包括名字如何绑定、值怎样计算、类型怎样约束操作。语法合法并不等于业务上允许,也不等于运行时一定成功。
编程范式描述组织计算的方式。命令式代码表达状态和步骤,函数式思路强调函数组合与数据变化方式,对象式组织把数据和行为放在对象关系里。许多语言支持多种思路,不能只凭范式给语言速度或维护成本排名。
运行环境还包含库、系统接口、内存管理与资源权限。相同源码在不同版本或依赖条件下可能表现不同。分析错误时应记录语言与运行时版本,而不是只贴出一段代码。
编译器通常处理哪些阶段
词法分析识别标记,语法分析把标记组合为结构,如抽象语法树(AST)。语义检查处理类型、名字和其他语言约束。编译器可能进一步生成中间表示、优化,再生成目标代码。
这些阶段是理解工具链的框架,不是所有实现都严格使用同一顺序或文件形式。AST 省略某些表面语法细节,中间表示则通常为分析与变换服务。LLVM 的语言前端教程能帮助理解这一分工,但不能据此推断每种语言都采用 LLVM。
编译成功只证明对应检查通过。缺少文件、权限不足、外部服务失败或逻辑错误,仍可能在运行时出现。静态类型也不能证明来自网络的数据真实,运行边界依然需要校验。
解释与 JIT 怎样执行
解释器可以读取某种程序表示并执行其含义,程序表示不一定是原始源码。实现也可能先编译到字节码,再由运行时执行。即时编译(JIT)可以在运行期间把部分表示编译为机器代码。
因此“编译型”和“解释型”不适合被当成两种永不重叠的语言属性。更准确的问题是:这个实现先检查什么、何时生成哪种表示、谁执行它,以及启动、优化和运行成本怎样分配。
这些机制不能直接推出某个应用更快。数据规模、热点、内存分配、调用边界和执行环境都可能影响结果。需要性能结论时应测量对应工作负载,而不是用语言标签代替证据。
给 DSL 写出允许的边界
先定义输入最大长度、允许的字面量、运算符和数值范围,再设计解析与错误行为。这里使用 Python AST 作为解析器,但只接受精确的整数常量,以及加法和乘法节点。所有其他节点都拒绝。
这意味着函数调用、名字、属性访问和列表等语法不会执行。负数通常会被解析为一元负号加常量,本例没有允许一元运算,因此也会拒绝。这是刻意的语言边界,不是 Python 不支持负数。
不要把“已经转成 AST”当成安全沙箱。AST 只是结构,安全来自显式允许的节点、资源限制和执行方式。也不要把允许列表检查完后又交给 eval,让另一个执行路径重新引入不需要的能力。
执行一个有界表达式
示例把输入限制为 80 个字符,整数常量的绝对值最多 1000,中间结果的绝对值最多 1000000。递归函数只解释允许的节点,计算顺序由解析后的树决定,乘法优先级不会靠字符串替换猜测。
import ast
def calc(text):
if len(text) > 80:
raise ValueError("too long")
def run(node):
if isinstance(node, ast.Constant) and type(node.value) is int:
if abs(node.value) > 1000:
raise ValueError("too large")
return node.value
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mult)):
left, right = run(node.left), run(node.right)
result = left + right if isinstance(node.op, ast.Add) else left * right
if abs(result) > 1000000:
raise ValueError("too large")
return result
raise ValueError("unsupported expression")
return run(ast.parse(text, mode="eval").body)
assert calc("2 + 3 * 4") == 14
try:
calc("f(1)")
raise AssertionError("call accepted")
except ValueError:
pass
print(calc("2 + 3 * 4"))
输出是 14,函数调用被拒绝。mode="eval" 在这里指定解析一个表达式,并没有调用 Python 的 eval 函数。精确检查 type(value) is int 也排除了布尔常量。
代码是一个有限的教学解释器,未提供完整的用户错误界面。非法语法可能抛出 SyntaxError,不支持的结构抛出 ValueError。实际 API 应把这些错误映射为稳定类别,并检查原始输入类型与请求大小;不能直接把异常堆栈暴露给用户。
扩展语言时先扩展契约
增加除法前,要决定整数除法还是分数结果、除零怎样处理、舍入如何定义。增加变量前,要决定变量来源、名字范围、缺失值与版本。每项能力都会扩大输入和执行边界,不能只在树遍历里加一个分支就结束。
适合写进文档的信息包括:合法输入、优先级、允许范围、错误类别、格式版本与升级行为。保存过的表达式应带版本,否则语言规则变化后可能得到不同结果。
练习:验证边界而不扩大权限
加入非法括号、布尔值、浮点数、负数、属性访问、超过长度和中间结果过大的测试。它们应稳定失败,已有加法、乘法和括号表达式继续得到正确结果。
随后只扩展一个明确需要的能力,例如有界的一元负号。先定义规则和失败用例,再修改解释器。验收时确认没有新增名字解析、函数调用、文件或网络能力,并记录这次语言版本变化。
资料来源与延伸阅读
资料核验:2026-11-10。本文为原创工程讲解,教学示例不代表线上实测。