当前位置: 首页 > news >正文

手写一门脚本语言 PlayScript:从词法到解释执行的编译器前端实战

手写一门脚本语言 PlayScript:从词法到解释执行的编译器前端实战

编译原理实战系列 · 第 1 篇
对应宫文学《编译原理》极客时间课程:02 词法分析、03–05 语法分析与表达式优先级、09/13 面向对象与多态、10 闭包、11–12 语义分析与类型检查。

一、引言:为什么要亲手写一遍编译器前端?

很多人学编译原理时,停留在“龙书/虎书”的公式与自动机定理上,真要动手写一个能跑的语言组件,往往不知从何下笔。我自己的体会是:编译器唯一正确的学习方式,就是亲手实现。当你把一段文本,经过词法、语法、语义,最后真正“跑”出结果,那些抽象概念——DFA、递归下降、符号表、闭包、虚分派——才会从纸面落到指尖。

本篇我们参照课程主线,纯手写一门名为PlayScript的小型脚本语言解释器,覆盖编译器“前端”全部环节:

  • 词法分析:手写 tokenizer,用正则/DFA 思想切分 Token;
  • 语法分析:递归下降 parser,正确处理二元表达式的优先级与结合性,构建 AST;
  • 语义分析:符号表 + 类型检查,捕获未声明变量、参数个数不匹配、类型不匹配;
  • 解释执行:树遍历求值,实现词法作用域、闭包、面向对象(继承与多态)。

全部代码用纯 Python 3 实现(无需安装任何第三方库),真实运行在云主机(Ubuntu 24.04)上。本文所有输出,都是m1这台机器上真实采集的 stdout,没有任何编造。


二、核心概念:四个阶段,一条流水线

PlayScript 的编译流水线非常清晰,每一阶段只依赖上一阶段的产物:

源码字符串 │ lexer.tokenize (词法:字符流 → Token 流) ▼ Token 流 │ parser.Parser.parse (语法:Token 流 → AST) ▼ AST │ semantic.Analyzer (语义:作用域 + 类型检查) ▼ AST(已校验) │ interpreter.Interpreter(运行:树遍历求值) ▼ 运行结果 / 报错

回想课程里强调的“前端/后端”划分:前端负责理解程序(它是什么),后端负责优化与翻译(它怎么高效执行)。我们这一篇聚焦前端,执行方式选用最简单的“树遍历解释器”,好处是能最直接地体现语义与运行时模型,而不被寄存器分配等话题带偏。

PlayScript 支持的关键字:var function return if else while for class new this print,外加字面量true/false/null。运算符覆盖算术+ - * / %、关系< <= > >=、相等== !=、逻辑&& || !与赋值=


三、分步代码讲解

3.1 词法分析:用“前瞻”区分单/双字符运算符

词法分析的本质,是按正则文法把字符流切成一个个词素(Token)。手写 tokenizer 的关键技巧是状态推进 + 前瞻一个字符。下面这段代码展示了最体现 DFA 思想的两处:双字符运算符的判定,以及数字/字符串状态的吸收。

# lexer.py(节选)TWO_CHAR_OPS={'==','!=','<=','>=','&&','||'}deftokenize(source):...# 双字符运算符:先看两个字符two=c+peek(1)iftwoinTWO_CHAR_OPS:tokens.append(Token('OP',two,line))i+=2continueifcin'+-*/%=<>!':# 单字符运算符tokens.append(Token('OP',c,line));i+=1;continue# 数字:持续吸收数字,遇 '.' 且后接数字则进入浮点ifc.isdigit():whilei<nandsource[i].isdigit():i+=1ifi<nandsource[i]=='.'andi+1<nandsource[i+1].isdigit():i+=1whilei<nandsource[i].isdigit():i+=1...# 字符串:遇到 '"' 进入,支持 \" \n \t \\ 转义ifc=='"':...

这里peek(1)就是 DFA 中“根据下一个输入决定状态转移”的工程化表达。===的处理顺序(先判双字符后判单字符)也至关重要,否则会把==误拆成两个=

3.2 语法分析:用“分层函数”编码运算符优先级

二元表达式的优先级与结合性,是递归下降 parser 的经典难点(对应课程 03–05)。我们的做法是:把表达式按优先级从紧到松拆成多个函数,高层函数只调用更紧的底层函数——优先级就这样“自然涌现”。

# parser.py(节选)defadditive(self):# + - (最低优先级之一)left=self.multiplicative()whileself.check('OP','+')orself.check('OP','-'):op=self.advance().lexeme right=self.multiplicative()# 右侧只解析更紧的层left=A.Binary(op,left,right,self.ln())returnleftdefmultiplicative(self):# * / %left=self.unary()whileself.check('OP','*')orself.check('OP','/')orself.check('OP','%'):op=self.advance().lexeme right=self.unary()left=A.Binary(op,left,right,self.ln())returnleft

因为additive在需要右操作数时调用multiplicative,所以1 + 2 * 3会被解析成1 + (2 * 3)——优先级正确。同一层用while循环“吸干”连续的同优先级运算符,于是a - b - c变成(a - b) - c,即左结合

赋值则要右结合,我们用递归自身实现:

defassignment(self):expr=self.logical_or()ifself.check('OP','='):self.advance()value=self.assignment()# 递归 → 右结合ifisinstance(expr,A.Var):returnA.Assign(expr.name,value)ifisinstance(expr,A.Member):returnA.Set(expr.obj,expr.name,value)raiseParseError(...)returnexpr

这样a = b = c会被解析为a = (b = c),符合多数语言的语义。此外,调用与成员访问被做成后缀call()里循环处理(.),因此f().g(1).h也能正确解析。

3.3 语义分析:符号表 + 类型检查

语义分析在 AST 上做静态检查(课程 11–12)。我们用一个作用域栈记录每个名字的类型,并在进入函数/类/块时压栈、离开时弹栈。类型用字符串表示:int/float/number/string/bool/object/any。对于“两端类型都已知且明显冲突”的运算,我们直接报错;含any(未知/动态)的运算则保守放行,避免误报。

# semantic.py(节选)def_check_binary(self,op,lt,rt,line):ifop=='+':ifltinNUM_TYPESandrtinNUM_TYPES:return'number'iflt=='string'andrt=='string':return'string'iflt=='string'andrtinNUM_TYPES:raiseSemanticError(f"第{line}行:类型错误,字符串不能与数字相加(+)")...

第一遍先收集全局函数与类的签名(参数个数、方法名),从而支持“先调用后定义”以及方法分派检查;第二遍再逐个语句做声明检查。未声明变量、函数参数个数不匹配都在此被拦截。

3.4 解释执行:闭包、this 与多态虚分派

解释器对 AST 做深度优先遍历求值。最值得讲的是三个运行时模型:

词法作用域与闭包(课程 10)Environment是一条链,查找变量沿链向上。函数对象捕获定义时的环境closure,所以内部函数能访问外部局部变量——这正是闭包的本质。

# interpreter.py(节选)classFunction:def__init__(self,decl,closure,name):self.decl=decl;self.closure=closure# 捕获定义环境defcall(self,interpreter,args):env=Environment(self.closure)# 新环境挂在闭包之下forp,ainzip(self.decl.params,args):env.define(p,a)...

面向对象与 this(课程 09)BoundMethod在“方法被访问”时,把this注入方法的环境。调用obj.foo()时,实际是BoundMethod(method, obj).call(...),于是方法体内this指向obj

继承与多态(课程 13)ClassDef.find_method沿继承链向上查找方法,调用方只看“对象实际属于哪个类”——这就是虚分派(动态分派),多态由此自然产生。

classClassDef:deffind_method(self,name):ifnameinself.methods:returnself.methods[name]ifself.superclassisnotNone:returnself.superclass.find_method(name)# 沿父类链查找returnNone

四、真实运行效果(m1 云主机采集)

我把 7 个示例放进examples/,用run_all.shm1上一键运行,下面是真实的 stdout(含语义报错):

$cd/root/compiler/front&&bashrun_all.sh====examples/01_fib.ps====--- 运行 examples/01_fib.ps ---55====examples/02_closure.ps====--- 运行 examples/02_closure.ps ---12314====examples/03_oop.ps====--- 运行 examples/03_oop.ps ---28.262028.26203====examples/04_undeclared.ps====--- 运行 examples/04_undeclared.ps --- 错误:第2行:未声明的变量'x'====examples/05_arity.ps====--- 运行 examples/05_arity.ps --- 错误:第5行:函数'add'需要2个参数,实参给了1====examples/06_logic.ps====--- 运行 examples/06_logic.ps ---4501234====examples/07_type_error.ps====--- 运行 examples/07_type_error.ps --- 错误:第3行:类型错误,字符串不能与数字相加(+)

逐一解读:

  • 斐波那契fib(10) == 55,验证了递归与表达式优先级(fib(n-1)+fib(n-2)-+更紧)。
  • 闭包计数器:连续调用c()得到1 2 3,再新建c2()得到1,最后c()给出4——证明两个计数器持有互相独立的环境,闭包捕获生效。
  • 继承与多态Circle(3).area() == 28.26Rectangle(4,5).area() == 20;随后用基类变量s先指向圆、再指向矩形,s.area()分别动态分派出28.2620,多态成立;c.r == 3表明字段访问正常。
  • 三类语义错误:未声明变量、参数个数不匹配、字符串+数字类型不匹配,全部在运行前被静态分析拦截并给出精确行号。

另外,解释器也提供交互式 REPL(持久环境,前面行定义的变量/函数在后续行仍可用):

$ python3 repl.py PlayScript REPL(输入exit退出,ctrl-D 结束) ps>var x=1+2*3;..print(x);7..var f=function(n){returnn * n;};..print(f(5));25

五、难点解析

1) 二元表达式的优先级与结合性。这是手写 parser 最容易翻车的地方。口诀是“高层调低层 = 优先级,同层 while 循环 = 左结合,递归自身 = 右结合”。把*/%放在比+-更“里层”的函数里,优先级就解决了;赋值=value = self.assignment()递归得到右结合。

2) 闭包。关键一句话:函数是“代码 + 定义时环境”的打包。很多初学者误以为闭包需要特殊的“捕获列表”,其实只要函数对象持有对外部Environment的引用,查找变量时沿链而上,闭包就自然成立。解释器里Function.closure就是那个被打包的环境。

3) 多态虚分派。面向对象语言的多态,并不靠“记住类型”实现,而是靠“调用时按对象实际类型查方法表”。find_method从实例的真实类出发向上查找,因此s.area()s指向不同子类时自动走到不同实现——这就是虚分派,也是多态的运行期心脏。


六、小结与下一步

我们纯手写实现了 PlayScript 的解释器前端:词法分析用前瞻区分运算符、语法分析用分层函数编码优先级、语义分析用作用域栈做类型检查、解释执行用环境链实现闭包、用方法表查找实现多态。所有代码在云主机m1上真实跑通,输出如上,无任何虚构。

源码结构/root/compiler/front/):

文件职责
lexer.py词法分析(tokenizer)
parser.py递归下降语法分析 + AST 构建
ast.pyAST 节点定义
semantic.py符号表 + 类型检查
interpreter.py树遍历解释执行(闭包 / OOP)
repl.py运行入口 + 交互式 REPL
examples/*.ps7 个示例程序
run_all.sh一键运行全部示例

下一步,可以沿两条线深入:一是把“树遍历解释”升级为“字节码虚拟机”(课程后端),体会指令式执行与栈式机器的差异;二是为语义分析引入更完整的类型推导(如 Hindley–Milner 的简化版),让any的地方也能给出更精准的检查。这正是《编译原理》后段“中端/后端”要解决的问题——我们下一篇继续。

完整源码已同步到本地D:/D/compiler-work/code/front/,可在云主机m1/root/compiler/front/上复现全部运行结果。

http://www.jsqmd.com/news/1338717/

相关文章:

  • 火绒安全软件深度配置与排错:从HIPS原理到实战应用指南
  • 2026年储能行业必看:船型开关厂家这样选,省心又可靠
  • 2026年全国发电机回收商家哪家专业 靠谱机构推荐 - 奔跑123
  • S32DS工程创建实战:从RTD-SDK配置到代码框架解析
  • 数据一致性对比实战——千万字段级的数据校验,怎么对
  • G-Helper终极指南:如何用不到10MB的工具彻底解放华硕笔记本性能
  • 【旧衣服回收上门取件怎么收费?2026年最新行情+避坑指南】 - 快递物流资讯
  • 2026年湖州酒店玻璃隔断口碑推荐:本地业主严选6家优质方案,装修避坑指南 - geo交流
  • 终极炉石传说游戏增强指南:55项功能提升你的游戏体验
  • 权限不足问题深度解析:从身份验证到资源访问的系统性排查指南
  • 从录音到母带:专业翻唱制作全流程技术拆解
  • 北京奔驰专属改装门店:韩少改装,十余年深耕奔驰一站式升级服务 - 国麟测评
  • Godot 4 TileMap分层与Y-Sort:2D游戏角色遮挡渲染终极方案
  • Matlab R2022b 安装与配置全攻略:从下载到性能优化
  • 蓝绿、灰度、金丝雀到底怎么选?我在云服务器上把它们全部实测了一遍
  • Arduino Uno R3 从零到精通的完整学习路径与实战指南
  • 硬件工程师进阶:从电路设计到指令集架构的认知跨越与实践指南
  • 2026年常州车牌识别电动门电话优选指南:3个关键点帮你精准选择 - geo交流
  • HsMod:炉石传说终极增强插件,55项功能彻底改变你的游戏体验
  • 汕头瓷砖空鼓松动不用全砸!全屋瓷砖翘边、起拱、渗水完整维修科普 - 宅安选房屋修缮
  • 食品经营许可证遗失怎么登报挂失?办理要求、渠道及完整操作指南 - 信息快递
  • 轻量级殡葬服务App开发与运营实战
  • 【武威市】2026CPPM采购经理报考指南|正规机构甄选产业适配全攻略 - 中采供培
  • 【2027最新】基于SpringBoot+Vue的作业管理系统管理系统源码+MyBatis+MySQL
  • IT工程师职业发展:技术深度与沟通广度的动态平衡策略
  • SpringDoc实战:高效生成API文档的现代解决方案
  • VS Code Python环境管理扩展实战指南
  • 【兰州市】2026CPPM采购经理报考指南|正规机构甄选产业适配全攻略 - 中采供培
  • 襄阳瓷砖空鼓松动不用全砸!全屋瓷砖翘边、起拱、渗水完整维修科普 - 宅安选房屋修缮
  • 老旧小区门禁改造:四种技术路线怎么选?