写这种“用 Python 自己写一个编译器”的小项目算是程序员圈子里比较硬核又容易上头的一类自驱任务。CNSH 编译器 v1.0Python 版就是这样一个没有借助任何现成解析器生成工具纯手工从词法分析一路写到实际执行器的作品。它实现的是一套自定义的脚本语言 CNSH——虽然语法非常简单但“能跑”和“能编译”之间隔着大量细节工作。这篇文章会把整个项目的设计过程、核心模块拆解、关键代码逻辑和踩过的坑完整复盘一遍。无论你是想给业务系统加一个内置脚本引擎还是单纯想搞懂“编译器到底是怎么回事”这份记录都值得参考。整个实现只用 Python 标准库没有任何第三方依赖拿到就能跑。1. 项目概述与设计思路1.1 为什么要自己写一个编译器现在市面上的脚本引擎不算少像 Lua、Python 内嵌、JavaScript 引擎QuickJS 那类都能拿来用。但真到了特定场景你会发现“通用脚本语言”往往并不好用。CNSH 这个项目最初的需求非常聚焦业务侧需要一种非常小、但可以灵活描述规则的语言用来做配置化扩展。用 JSON 表达不够直观用完整 Python 又显得过度设计、权限也不好控制。所以目标就定了设计一门极简的动态类型脚本语言名字暂定 CNSH语法层面混合了类 C 的语句风格和简化版的表达式写法。不需要定义类、不需要继承、不需要模块系统。语法上只需要变量、数值运算、字符串拼接、条件、循环、函数和少量内置函数。把范围收窄到这种程度不是因为做不了复杂功能而是作为一个 v1.0 项目得先把编译器的主链路跑通源码读取 → 词法分析 → 语法分析 → 抽象语法树AST→ 解释执行。这整条链路是任何编译器、解释器的核心骨架只要这条链路在后面随便叠加新语法特性都只是给相应阶段增加工作量而已。1.2 语言功能范围与目标形态CNSH 语言在设计初期定下几条硬规则直接决定了后面实现的工作量只有两种基本类型数值类型统一用浮点数存储和字符串类型。布尔值直接用数值 1 和 0 代替保持解释器内部结构足够简单。语句必须用分号结尾。不要自动插入分号机制——那是给有强大静态工具支持的编译器准备的手写解释器加上分号可以省掉大量解析歧义。变量可不经声明直接赋值。支持if / else、while循环支持函数定义与调用支持递归。内置函数只做四个print用于输出len取字符串长度to_num转数值to_str转字符串。这套语言范围目标就是能够完成“写一个可以执行的小算法”这件事。我在内部做过一个小测试用 CNSH 写一个递归函数算斐波那契数列再配合循环做九九乘法表完全跑得动。这就足够证明这个编译器不是一个空壳。1.3 为什么选择 Python 实现两个理由开发效率和解读成本。编译器实现有一条很经典的路线用“自举”的方式用编译器自己的语言去写编译器。但这条路对 v1.0 来说障碍太高尤其是调试阶段Bug 层层叠加根本无从下手。Python 作为宿主语言词法分析里的正则、语法分析里的递归函数、AST 节点里那些简单对象定义都写起来非常顺手再加上 Python 自己就带垃圾回收和优秀的容器类型让实现者可以集中精力处理编译逻辑本身。另外一个原因就是“可展示性”。给别人讲解时Python 代码读起来接近伪代码读者不需要先去学一门偏门的元语言就能听懂核心逻辑。所以选 Python 不是因为它性能强悍而是因为它是最合适的教学与快速开发语言工具。2. 整体架构与核心模块拆分2.1 一条源码走过的完整生命周期整个编译器按数据流水线的方式来组织源码首先是普通文本然后进入词法分析器。词法分析器的职责是把字符串切成一个个 Token。比如源码中写let x 10 2;词法分析器会生成let关键字 Tokenx标识符 Token运算符 Token10数值 Token运算符 Token2数值 Token;分号 Token从源码到 Token 列表之后进入语法分析阶段。语法分析器读取 Token 列表按照语法规则构建一棵树形的结构即抽象语法树 AST。比如10 2就会变成一个“二元运算节点”左边是数值 10右边是数值 2操作符是加号。最后解释器执行器对 AST 进行深度遍历计算出每个节点的值并维护环境变量表。整个过程大体上就三步。这三步各写各的模块互相之间没有纠缠这就是编译器项目最重要的架构原则阶段分离。2.2 模块划分与数据结构设计项目文件不多但模块边界要清楚。整个项目的主要文件拆成这几个模块名职责关键数据结构token.pyToken 定义与类型常量Token 类、TokenType 枚举常量lexer.py词法分析Token 列表ast.pyAST 节点定义若干节点类parser.py语法分析递归下降解析器interpreter.py解释执行环境栈、函数表main.py入口调度与文件读取命令行参数Token 是最底层的结构。一个 Token 就是三个字段类型、文本、所在行号。类型用来帮助语法分析器判断当前读到的是什么。行号非常关键后面做错误提示时全靠它准确定位。AST 节点用简单的类来表示每个节点持有自己需要的子节点执行器只需要检查节点类型然后分别处理即可。2.3 关于“编译”和“解释”的概念严格来说CNSH 项目目前是一个解释器而不是传统意义上的编译器。因为代码并不会被翻译成目标平台机器码或字节码而是直接遍历 AST 求值。但这并不意味着“编译器”这三个字是噱头。从广义角度看编译器是“把一种语言翻译成另一种表示形式”的程序。从源码到 AST 这一步已经完成了翻译工作。而且 AST 可以再继续向下翻译为字节码或机器指令很多语言的实现都是先走到 AST再决定是用解释器还是生成更底层的代码。CNSH 后续如果要优化完全可以在 AST 之后接入一个字节码生成器和虚拟机。当前用“CNSH 编译器”这个名字是为了强调“从词法到语法到执行的完整编译工具链”这一事实内部的执行方式属于实现细节。3. 核心模块实现与关键代码解析3.1 词法分析器实现细节词法分析是整个项目最基础的模块也是最容易让新手翻车的地方。我的实现思路是用一个大循环维护一个当前位置指针把所有字符分成几类。大致逻辑是# lexer.py 核心逻辑简写 import re TOKEN_SPEC [ (NUMBER, r\d(\.\d*)?), (IDENT, r[a-zA-Z_][a-zA-Z0-9_]*), (OP, r|!||||\|\||[\-*/%!]), (STRING, r\.*?\|.*?), (LPAREN, r\(), (RPAREN, r\)), (LBRACE, r\{), (RBRACE, r\}), (SEMI, r;), (COMMA, r,), (SKIP, r[ \t\n]), (MISMATCH, r.), ] def tokenize(source_code): tokens [] pos 0 line 1 while pos len(source_code): match None for token_name, pattern in TOKEN_SPEC: regex re.compile(pattern) match regex.match(source_code, pos) if match: text match.group() if token_name SKIP: line text.count(\n) elif token_name MISMATCH: raise SyntaxError(f无法识别的字符: {text} (行 {line})) else: token_type token_name tokens.append(Token(token_type, text, line)) pos match.end() break if not match: pos 1 return tokens这段逻辑看起来清爽但实际版本比这复杂。比较坑的一点是正则匹配顺序。比如和如果把的规则放在前面解析a 1时就会先匹配成一个然后把后面的1再解析出问题。所以必须把更长的操作符规则放前面。另一个坑是字符串里可能出现分号或运算符必须把字符串的匹配规则放在最前面一部分否则字符串中的内容会被提前拆散。词法分析完成后这个 Token 列表就作为后续解析器的输入。如果 Token 列表出现了异常字符必须在词法阶段直接报错不能拖到语法解析阶段。3.2 语法分析器与递归下降语法分析是编译器里最让人头秃的模块。因为如果语法定义得不够清晰递归下降写法会产生大量分支混乱、互相缠绕的情况。CNSH 的语法分析器走的是经典的递归下降路线。程序设计上每一类语法成分对应一个解析函数。比如语句级解析函数直接按前缀判断如果当前 Token 类型是IDENT再往后看一个 Token如果是就是赋值语句。如果当前 Token 类型是if则进入 if 语句解析。如果当前 Token 类型是while进入循环解析。以此类推。表达式解析采用优先级分层的方式实现。算术表达式分四层逻辑层、||比较层、!、、、、加减层、-乘除层*、/、%每一层写一个函数层层调用这样写出来不仅代码干净而且天然支持操作符优先级。下面是核心代码骨架def parse_additive(self): left self.parse_multiplicative() while self.current_token.type in (PLUS, MINUS): op self.current_token.type self.advance() right self.parse_multiplicative() left BinOpNode(left, op, right) return left def parse_multiplicative(self): left self.parse_primary() while self.current_token.type in (MUL, DIV, MOD): op self.current_token.type self.advance() right self.parse_primary() left BinOpNode(left, op, right) return left这里有个很严重的常见错误很多人在这一层把parse_primary当成parse_multiplicative的递归调用来写结果导致1 * 2 3这类表达式被错误地解析成(1 * (2 3))。我最初也踩过这个坑后来画了一下运算优先级树才搞清楚乘除法要先算所以它的函数调用的是“更底层”的主表达式解析而加减法函数才调用乘除法函数。正确的层级是加减法调用乘除法乘除法再调用主表达式解析。3.3 AST 节点设计与解释执行器AST 节点不需要花哨用普通类保存必要属性即可。定义方式是class NumberNode: def __init__(self, value): self.value value class BinOpNode: def __init__(self, left, op, right): self.left left self.op op # 运算符文本如 self.right right class AssignNode: def __init__(self, name, value_node): self.name name self.value_node value_node class IfNode: def __init__(self, condition, body, else_body): self.condition condition self.body body self.else_body else_body很多时候你会看到某些人花很大力气设计复杂的 Node 继承体系搞十几个类型实际上对这样的小项目来说没必要。简单类就够了关键是执行器能正确识别。执行器内部维护一个环境实际上就是一个栈式的作用域链。最底层的栈是全局环境进入函数时压入一个新的局部环境函数结束时弹出。变量查找从最内层开始往外层找符合常见的词法作用域规则。class Environment: def __init__(self, parentNone): self.vars {} self.parent parent def define(self, name, value): self.vars[name] value def assign(self, name, value): if name in self.vars: self.vars[name] value elif self.parent is not None: self.parent.assign(name, value) else: self.vars[name] value def get(self, name): if name in self.vars: return self.vars[name] elif self.parent is not None: return self.parent.get(name) raise RuntimeError(f未定义变量: {name})解释器本身就是一个大visit方法根据节点类型分发执行逻辑。每次访问节点后返回一个值这个值要么是数值要么是字符串非常统一不用考虑其他类型的传播。函数调用时需要把实参逐个代入形参表然后新建 Environment把形参变量放进去再执行函数体。递归调用就是不断重复这个过程。4. 实操过程与关键环节验证4.1 跑通一个最小测试程序光看设计不够得实际写一个 CNSH 程序并把整个工具链跑一遍。我准备了一个测试文件内容很经典func fib(n) { if (n 1) { return n; } return fib(n - 1) fib(n - 2); } let i 0; while (i 10) { print fib(i); i i 1; }调用入口的方式是命令行传文件路径python main.py test.csh预期输出是从 0 到 10 的斐波那契数列0 1 1 2 3 5 8 13 21 34 55。注意这里有个关键点CNSH 代码里使用了let关键字来声明变量但fib函数内部没有声明任何局部变量参数n直接作为变量使用。这意味着函数调用时形参要和普通变量一样进入局部环境。4.2 调试过程中发现的三个大问题第一个问题发生在词法分析阶段写完后运行测试时大量 Token 类型对不上。原因是正则的OP规则覆盖了圆括号、花括号和分号。圆括号和花括号看起来像字符但它们的语义和运算符完全不同。后来我把括号和分号单独提取成独立规则才彻底解决。第二个问题是表达式优先级写反了导致的计算错误。初次测试2 3 * 4的结果是 20 而不是 14。检查 AST 才发现我乘除法函数调用得不对。修正方法就是把加法层调用的底层函数改成乘除法解析函数问题迎刃而解。第三个问题更加隐蔽。if语句里只有一个条件分支时正常工作一旦加上else分支else 里的变量居然在条件为真的时候也发生了赋值。后来排查是解释器执行 if 节点时对else_body的判断逻辑写反了——我在执行if (condition)为真之后仍然执行了 else 分支。修掉这个分支逻辑之后一切正常。4.3 整数除法与类型处理的取舍CNSH 设计成了所有数值类型统一存浮点所以5 / 2的结果是 2.5。但如果用户写一个类似 C 语言的int期望可能就会抱怨结果不对。这个问题在设计早期就决定过了语言不做显式类型声明不做类型推断数字类型全部以浮点数形式保存。这带来一个直接好处执行器所有运算只需要判断字符串之外的情况都当作数值处理即可。不用在整数和浮点之间来回转换也不用担心溢出。很多动态语言的简单解释器采用这种做法目的是降低实现的复杂度代价就是某些场景下对整数语义不够精确。对于脚本化配置、规则描述这类场景来说完全够用。4.4 内置函数扩展流程内置函数的扩展成本非常低。以len函数为例在解释器的函数查找逻辑里加一个判断if name len: return len(interpreter.visit(node.args[0]))需要注意参数位置和返回值类型。如果 args 这个列表是执行时求值过的值列表而不是 AST 节点那么更简洁。在实现中函数调用的执行是先把所有参数表达式算出来得到值列表然后去查找是否系统内置函数或用户函数。如果是内置函数直接对这个值列表做处理。新增一个内置函数就两步加判断写处理逻辑。全程不需要碰词法分析器和语法分析器。这就是架构分层带来的实际收益。5. 常见问题与排查经验实录5.1 问题速查表这些坑都是实际调测试时遇到的整理成一个速查表方便对照。现象可能原因解决办法数字表达式总是错误词法规则顺序不对被拆成和将长操作符规则放在前面字符串中有引号导致解析崩溃字符串规则和引号规则冲突字符串匹配必须在最前面处理变量赋值丢失环境栈父子关系没接好确认局部环境 parent 指向全局环境函数无法递归函数表保存的是副本而非引用确保 AST 函数节点的子节点完整保存条件语句两个分支都执行if 节点执行分支逻辑错误检查 condition 真假分支的 return 逻辑空白字符串无法标识字符串规则不含转义处理v1.0 不允许字符串跨行和转义需求明确即可结尾缺少分号报错晦涩语法分析器在到达 EOF 时仍在等待报错提示加上“预期分号但到达文件末尾”5.2 分号机制带来的连锁问题当初确定“必须写分号”这件事到了实现中期体会很深。好处是解析器在任何语句结束时都能非常明确地知道边界坏处是用户一旦漏掉分号报错位置会有点迷惑。比如源码let a 5\nlet b 6;词法分析完全正常但语法分析器在解析完5之后下一个 Token 是let而当前解析函数期待的是分号。此时报错只能报“意外的 let”还不能自动提示用户是否缺少分号。这个问题可以通过增加一个轻量级的错误恢复机制优化比如在报错信息里自动猜测“是否缺少分号”。但在 v1.0 中我只做了提示“解析错误行 X 处预期分号”。这对已经了解语法的人来说足够用了。5.3 作用域与变量生命周期作用域处理是解释器中最容易写出隐性 Bug 的地方。我用链式环境来模拟嵌套作用域。函数体内部新建一层 Environment其父级是全局环境。这样函数内部可以读取全局变量但函数内部定义的局部变量不会污染全局。这里有一个隐藏问题函数内部对全局变量做赋值操作时由于我在assign方法里有“如果当前层找不到就向父层传递”的逻辑全局变量的值会被直接修改。这在某些场景下是好事但也是一个陷阱——如果一不小心在函数里用了和全局变量同名的局部变量做初始化全局变量会被覆盖。CNSH v1.0 采取了最简策略赋值即修改。后续版本如果要支持严格局部变量需要引入let声明语义即凡是带let的声明都强制在当前环境创建新变量不带let的赋值则按作用域链查找修改。这个设计已经在 v2.0 的计划中。6. 性能观察与后续扩展方向6.1 当前性能表现CNSH 解释器目前完全没有做任何优化。执行速度比原生 Python 慢几十倍是肯定的但这并不是缺陷因为它的定位是“面向业务脚本的轻量解释器”。对于几万条以内的简单计算任务这种速度完全无感。但对于嵌套循环特别深、计算量特别大的算法任务比如循环 100 万次累加就会明显卡顿。如果要优化首选的方案就是 AST 解释循环加节点缓存减少每个节点的属性访问开销。其次是引入字节码虚拟机把 AST 编译成扁平结构线程一压一压。不过这些优化都是 v1.1 之后的事情了目前首要任务还是要保持逻辑正确和代码可读。6.2 字节码方向的设计规划一旦 AST 结构稳定接字节码后端是水到渠成的事情。大致步骤是新增一个编译函数遍历 AST。为每个节点生成对应的指令比如LOAD_CONST、LOAD_NAME、STORE_NAME、BINARY_ADD等。为函数单独生成指令段。写一个简单循环从指令和操作数栈执行程序。字节码相对 AST 一遍遍递归调用来说执行速度会有数量级提升而且可以引入闭包、生成器等高级特性。最终版本甚至可以把指令序列直接序列化到文件做一些简单的代码加密或分发。不过字节码也不是银弹。它会增加编译层的复杂度所有语法结构都得多一个“代码生成”步骤。做这个改造必须保证测试用例足够全否则很容易出现 AST 和字节码两个执行路径行为不一致。6.3 测试驱动改进的体会整个 v1.0 开发过程中我最受益的一件事是边写边积累测试用例。每修完一个 Bug立刻把复现用例存档。项目最后统计下来有差不多 40 个用例覆盖了词法、语法、运行时各个层面。没有这些用例后面每次改动都可能引入新的隐藏故障。对这种解释器项目手工测试只能覆盖正常路径异常路径和边界条件几乎都是靠回归测试兜住的。这一点给所有想写编译器的朋友提个醒一开始就在项目里建好测试目录养成“修一个 Bug加一条用例”的习惯。7. 个人复盘与实用建议7.1 复盘设计中的两处取舍如果时光倒流我会重新考虑两个设计点。第一个是字符串转义问题。v1.0 的字符串不支持换行和转义字符这意味着print hello\nworld会原样输出反斜杠和字母 n。当时为了省事现在回头看这个限制已经明显影响可用性。也许下一版第一优先不是加闭包而是把字符串的转义支持补上。第二个是错误信息友好度。目前的运行时错误只有“未定义变量”和“除以 0”两个大类定位到具体出错位置的能力还很弱。未来如果做字节码错误信息的栈式追踪也一并纳入设计。7.2 对同样想写编译器的你几条实在的建议写编译器这件事最难的不是算法或者数据结构而是“忘记还没写完的抽象”心态上别把编译器神秘化。它和任何一个处理文本的程序没有本质区别就是读取输入、按规则分析、然后做对应的事情。如果你也想动手做一个我劝你把范围切小再切小。先做表达式求值再做赋值和打印再加函数和循环。每完成一个阶段立刻写一个可运行的演示用例。宁可连续推进几个小版本不要试图一口气搞出个现代化大语言全家桶。CNSH 从零到 v1.0 也就用了两周左右的工作量因为从一开始就严格锁定了功能范围。最后分享一个实在的小技巧测试阶段不要太依赖教科书上的“测试框架思维”编译器用例需要大量针对“坏代码”的测试——比如代码写一半、语法缺分号、类型写错、括号不匹配。把“这几乎不会发生”的心态反过来反而可以发现一半以上的实现缺口。CNSH 的很多 Bug 也是靠这些“坏输入”测出来的。写完之后看着自己的解释器跑一个递归函数确实很有成就感。这套代码虽然很简陋但它证明了完整工具链可以完全从零写出来这种底层的掌控感是直接用第三方库替代时体会不到的。