资讯动态

用Go语言实现支持宏的迷你解释器:从词法分析到求值

发布时间:2026/10/1 7:38:06 来源:尧图企业网站定制
不绕弯子了。用Go语言实现一个解释器这事儿我刚上手时觉得是个大活后来发现它其实是被“编译原理”这四个字吓住了。真正落地的时候你只需要做好三件事把源码切成Token把Token拼成一棵树再把树递归地跑一遍。而“宏系统”这个名字听起来玄乎本质上就是在这棵树上做一层“代码改写代码”的操作。这篇文章就用Go语言写一个支持宏定义的迷你解释器把词法分析、语法分析、求值器、宏展开这几个环节全部撸一遍让你看完就能自己跑一个能算数、能分支、能定义宏的小语言。适合两类人看。第一类是对解释器原理好奇、想写点东西验证理解但不想一上来啃大部头的开发者第二类是听说过宏、想知道“宏到底怎么落地”的程序员。这篇我把代码拆开讲带注释、带踩坑记录你跟着走完不仅能跑通一个带宏的算术语言还能往里面加自定义语法、加内置函数把它变成你的“私房语言”。1. 项目初衷为什么是“Go语言 宏系统 解释器”这个组合1.1 解释器没有想象中那么难我见过很多同行一提到“写解释器”就露出又敬又怕的表情觉得那是编译器大佬的领域。等你真的写出来一个小解释器你会发现它跟编译器完全是两码事。编译器要把源码翻译成机器码过程复杂涉及优化、寄存器分配、指令调度解释器则简单得多它只需要“看一段源码算出对应的结果”中间的树形结构在内存里现造现用不折腾底层。这次我选择做一个Lisp风格的迷你语言。为什么是Lisp风格因为Lisp的语法极其规整所有的计算都是“括号里放一个操作符后面跟一堆参数”比如(add 1 2)就是12。这种S表达式天然就是一棵树解析起来不用处理运算符优先级不用管括号匹配之外的分号、缩进简直是为手写解释器而生的实验田。换句话说Lisp语法帮我们把“语法分析的复杂度”压到了最低让我能把精力放在更关键的宏系统上。你可能会问那真实世界里的语言这么多谁没事用括号写代码别急解释器的核心框架是通用的。词法分析、语法分析、求值、宏展开这套流程换到任何语言都跑得通。你在Lisp上把逻辑理顺了将来去看Python解释器、JS引擎的实现会发现骨架都是一样的只是每个环节的复杂度不同。1.2 宏系统能给语言带来什么宏这个概念很多写应用代码的人接触得少容易跟“函数”搞混。两者最直观的区别是函数操作的是“值”宏操作的是“代码”。普通函数double(x)要先算出参数x的值再返回两倍的结果而宏是在代码执行之前把你写的调用形式改写成另一段代码然后再去执行改写后的代码。举一个马上能看明白的例子。假设我的迷你语言里只有if没有unless如果条件为假则执行某分支。要是没有宏你写“条件不成立才执行某某逻辑”的时候只能写成(if (not c) 执行A 执行B)这种别扭形式。有了宏你可以自己定义一个(defmacro unless (condition then else) (if condition else then))定义完之后写(unless false 1 2)就会先被宏展开成(if false 2 1)再交给求值器算出结果2。这个过程里宏相当于帮你扩展了语言本身。这就是宏最常见的价值它不依赖语言提供者就能让你往语言里添加新的控制结构、新的语法糖甚至改变表达方式。很多方言和嵌入式DSL就是这样长出来的。1.3 选Go实现的技术理由为什么用Go而不是Python、Rust这类语言我自己的理由有四个。第一Go的语法简单写这种偏算法、偏数据结构的项目时脑子里想的是逻辑而不是语法细节。第二Go编译后是单个可执行文件分发和测试都方便写完挂到GitHub上别人go build就能玩不用配一堆环境。第三Go的切片和接口组合用在这种AST遍历场景非常顺手节点类型用接口构造遍历时用类型断言分派代码结构清晰可读性很好。第四也是我个人体会最深的一点Go的标准库干净没有太多魔法用Go写解释器你会对内存的分配、参数的传递有更直观的感受而不是依赖框架替你擦屁股。当然也要承认Go在元编程这件事上没有Lisp原生语言那么方便比如它没有天然支持“代码即数据”的表示形式。但我们的解释器本来就是在内存里构建AST的AST本身就是“可操纵的数据结构”所以这个问题并不致命反而更能体现“AST就是宏发挥威力的舞台”这个核心思想。2. 整体架构与核心设计思路2.1 流水线的三段论词法、语法、求值任何一个解释器哪怕功能再少也逃不过这条流水线源码字符串 - Token流 - AST - 求值结果。这条流水线在脑子里跑通你就懂了解释器的一半。第一阶段叫词法分析面向的是字符。输入是一长串文本输出是一串有意义的“词法单元”简称Token。比如输入(add 1 2)词法分析器会产出左括号、标识符add、数字1、数字2、右括号这五个Token空格和换行这种空白字符直接丢弃因为它们在语法上没有意义。第二阶段叫语法分析面向的是Token。它根据语言的语法规则把这些Token组合成一棵树也就是抽象语法树AST。(add 1 2)这棵树的根节点是一个调用节点它的子节点是标识符add、数字1、数字2。AST把源码的结构显式地表示出来了谁是谁的操作数、谁是谁的子树一目了然。第三阶段是求值面向的是AST。求值器从根节点出发递归地往下走遇到数字就返回数字遇到标识符就在环境里查变量的值遇到调用节点就先求出每个子表达式的结果再执行对应的操作。递归走完之后得到最终的返回值。这里还得提一个容易忽略的点AST必须是“纯粹的数据”不能掺杂任何执行动作。很多初学者写解释器时喜欢在解析的过程中顺便计算结果这样当时很方便但加了宏系统之后就会踩大坑。因为宏要改写代码改写的是“还没执行的代码”如果代码在执行过程中被解析掉了你根本碰不到它。所以架构上一定是先完整构建AST再走求值这个顺序不能乱。2.2 AST是万物之源我越来越觉得理解解释器的一个关键心法就是“一切围绕AST转”。AST不只是中间产物它是整个解释器的枢纽。宏系统改的是AST求值器吃的是AST类型检查、优化、调试信息这些将来要加的功能操作的也是AST。基于这个想法AST节点的设计就非常讲究。我把它定义成一个接口具体的节点类型是实现接口的结构体。Go里接口和结构体的组合天然适合这种多态分派后续做遍历也好做做模式匹配也好做。这次迷你语言只需要四种节点整数节点存一个int值。布尔节点存一个bool值。符号节点存一个字符串名字对应变量名或函数名。列表节点存一个[]Node切片表示一个表达式比如(if a b c)或者(add 1 2)。有趣的是列表节点在语法上既可以是“函数调用”也可以是“特殊形式”还可以是“宏调用”。到底按哪种身份处理是在求值阶段根据列表第一个符号去环境里查了才知道的。这种延迟判断给了宏系统很大的弹性宏可以定义出长得像函数调用、但行为完全不同的语法结构。2.3 宏展开插在哪一环宏展开的正确位置在“AST构建完成之后、求值开始之前”。我把它实现成一个独立函数输入一棵AST输出一棵新的AST新AST里的宏调用已经被替换成了展开体。为什么不能放在词法阶段做因为宏定义本身也是源码的一部分词法分析器看到的只是字符它根本不知道“哪个符号是宏”。只有在AST建好之后解释器才知道用户先定义了unless然后调用了unless。这些东西是语法层面的知识必须留到AST阶段才能回答。为什么不能放到求值阶段做求值阶段已经沿着树递归往下执行了每个节点都在产生“结果”。一旦在某一步发现这个调用是个宏就得停下来、把子节点改写、再从头求值。这种“执行到一半突然改写代码再重来”的逻辑非常容易出错状态管理一乱结果就崩了。所以我的架构里宏展开是一个独立的遍历步骤。它会先完整地处理这棵AST把能展开的宏调用全部替换成展开后的节点替换完成后纯求值器就只负责执行已经“纯洁化”的AST。这样做的好处调试宏问题的时候尤其明显你可以在展开前打印AST展开后再打印一遍对比结果问题出在哪一环一目了然。3. 核心环节实现从源码到AST再到求值3.1 词法分析把字符流切成Token流词法分析器我习惯直接手写不用工具生成。因为我们的语法简单范围可控手写一个状态机也就是百来行代码的事还能让你对处理细节更有掌控感。Token的类型我定义了这么几类左括号、右括号、整数、符号、布尔真、布尔假、表示结束的EOF。事实上布尔值我先当符号处理在解析阶段再判断也可以一开始就单独拆出来怎么写都不算错。我选择了拆出来因为后续遍历AST时要快速判断节点类型。我的Token结构体长这样type TokenType int const ( TokenLParen TokenType iota TokenRParen TokenInteger TokenSymbol TokenTrue TokenFalse TokenEOF ) type Token struct { Type TokenType Text string Line int Col int }Line和Col在调试时帮了大忙。很多同学写解释器不记行列号等报错的时候只有一个干巴巴的“语法错误”完全没法定位。加上行列号报错信息就能写成“第2行第4列意外的右括号”这个体验相差很大。词法分析的代码要点就一个用for循环扫字符跳过空白遇到(和)各自生成Token遇到数字就把连续的字符全部读进来转成整数遇到字母或 - * / 这类符号字符就把连续的字符读进来当作符号。我这里把操作符 - * /也当成普通符号解析阶段再特殊处理这样词法分析器就能保持简单。这里有个我曾经踩过的坑数字后面紧跟左括号的情况。比如输入123(abc)按严格语法这是错误但我的词法分析器会把123读成一个数字然后读到(生成左括号Token两个Token挨在一起语法分析阶段可能会报“数字后面不该跟左括号”。这类边界问题宁可报错早一点也不要让混乱的Token流混进语法分析器。3.2 语法分析递归下降构建AST语法分析我用了最直观的递归下降法。因为我们处理的是S表达式规则极其简单一个Token流里遇到左括号就一直解析子表达式直到匹配到右括号遇到数字或符号就直接生成叶子节点。type Parser struct { tokens []Token pos int } func NewParser(tokens []Token) *Parser { return Parser{tokens: tokens} } func (p *Parser) Parse() (Node, error) { tok : p.peek() switch tok.Type { case TokenInteger: p.pos n, _ : strconv.Atoi(tok.Text) return IntLit{Val: n}, nil case TokenTrue: p.pos return BoolLit{Val: true}, nil case TokenFalse: p.pos return BoolLit{Val: false}, nil case TokenSymbol: p.pos return Symbol{Name: tok.Text}, nil case TokenLParen: return p.parseList() case TokenRParen: return nil, fmt.Errorf(第%d行第%d列: 意外的右括号, tok.Line, tok.Col) case TokenEOF: return nil, fmt.Errorf(意外结束缺少右括号) } return nil, fmt.Errorf(无法解析的Token: %s, tok.Text) } func (p *Parser) parseList() (Node, error) { p.pos // 跳过左括号 var elems []Node for p.peek().Type ! TokenRParen { if p.peek().Type TokenEOF { return nil, fmt.Errorf(括号未闭合) } n, err : p.Parse() if err ! nil { return nil, err } elems append(elems, n) } p.pos // 跳过右括号 return List{Elems: elems}, nil }AST节点定义很简单type Node interface { String() string } type IntLit struct{ Val int } type BoolLit struct{ Val bool } type Symbol struct{ Name string } type List struct{ Elems []Node }每个节点实现String方法方便打印和调试。这一步千万别偷懒后面调试宏展开时AST的打印函数简直是救命稻草。我一开始没写后来发现宏展开完根本看不出树变成什么样只能瞎猜补上打印函数之后调试效率立刻上来了。3.3 求值器初版AST直接执行求值器是解释器的心脏。它做的事情是输入一个AST节点把它换算成一个“值”。为了让迷你语言支持变量我引入了一个环境Env的概念本质上是一个嵌套的符号表。变量查找从内层环境逐层往外层找这是最核心的闭包基础。type Env struct { vars map[string]Node parent *Env } func NewEnv(parent *Env) *Env { return Env{vars: make(map[string]Node), parent: parent} } func (e *Env) Set(name string, val Node) { e.vars[name] val } func (e *Env) Get(name string) (Node, bool) { for env : e; env ! nil; env env.parent { if v, ok : env.vars[name]; ok { return v, true } } return nil, false }求值函数的核心逻辑func Eval(n Node, env *Env) (Node, error) { switch node : n.(type) { case *IntLit: return node, nil case *BoolLit: return node, nil case *Symbol: if v, ok : env.Get(node.Name); ok { return v, nil } return nil, fmt.Errorf(未知变量: %s, node.Name) case *List: return EvalList(node, env) } return nil, fmt.Errorf(未知节点类型: %T, n) }EvalList需要处理几种情况。如果列表为空返回错误如果列表第一个元素是符号if就当作条件分支处理如果是define就在当前环境里定义变量如果是defmacro就定义宏否则把列表第一个元素当作函数名目前支持内置函数add、sub、mul、gt等依次求值参数再执行运算。这里最关键的设计决策是把“特殊形式”的处理逻辑放在求值时判断而不是在语法分析阶段判断。这样做的好处是语法分析保持通用你随时可以在求值器里增加新的特殊形式。坏处是求值器会越来越胖将来要做编译优化时需要把这部分逻辑重新梳理。但对我们这个迷你解释器来说这个取舍非常划算。4. 宏系统的设计与实现4.1 宏的表示代码即数据宏的核心思想是“代码即数据”。在Lisp里代码本来就是列表结构所以宏定义天然就是把一段代码存起来等调用时再拼进新的代码。在Go这种本身不是“代码即数据”的语言里我们依然可以利用AST来完成同样的事宏定义就是存一个模板AST外加说明“参数名有哪些”。我设计了这样几个结构type Macro struct { Name string Params []string Body Node // 宏体的AST表示展开模板 } type Env struct { vars map[string]Node macros map[string]*Macro parent *Env }宏定义的形式我用一个特殊形式来实现语法如下(defmacro unless (condition then else) ...body...)用户在源码里写下这行后求值器发现列表第一个元素是defmacro就会解析出宏名、参数名列表以及展开体模板然后把它注册到当前环境的macros表里。之后遇到以unless开头的列表节点时宏系统会识别出这是一个宏调用取出模板绑定参数替换符号输出一棵新AST。4.2 宏展开遍历AST并替换宏展开函数Expand是整个宏系统的核心。它接收一个AST节点返回一个“展开后的AST节点”。逻辑分两种情况如果当前节点是列表先看看列表的第一个元素是不是一个已注册的宏名字如果是就执行展开如果不是就继续递归展开列表里的每一个子节点。展开的步骤看起来很像“参数替换”。假设我们有宏unless模板是(if condition then else)参数列表是(condition then else)。当调用(unless false 1 2)时取调用列表的第二个、第三个、第四个元素作为参数值然后去复制模板AST复制过程中看到condition符号就替换成false看到then替换成1看到else替换成2。替换结果就是(if false 1 2)把它返回整个宏调用就完成了。这里有一个极其重要的细节复制模板AST的时候绝对不能直接修改原模板。模板是宏定义的一部分如果展开一次就把模板里的符号改了第二次展开同一个宏就会拿到一个被污染过的模板结果完全错误。我刚实现的时候在这里栽了跟头宏第二次展开的结果完全不对就是因为我在原地修改了Body节点。后来我加了一个Clone函数所有替换都在克隆出来的节点上进行问题立刻消失。Clone的实现也不复杂递归复制每个节点就行func Clone(n Node) Node { switch node : n.(type) { case *IntLit: return IntLit{Val: node.Val} case *BoolLit: return BoolLit{Val: node.Val} case *Symbol: return Symbol{Name: node.Name} case *List: newElems : make([]Node, len(node.Elems)) for i, e : range node.Elems { newElems[i] Clone(e) } return List{Elems: newElems} } return n }替换过程本质上是“基于克隆的遍历替换”。我实现一个Substitute(node, params, args)函数如果node是符号且名字等于某个参数名就返回对应的参数AST的克隆如果是列表就递归替换每个子元素其他节点原样返回。这个函数和Clone合在一起就是宏展开的核心机制。4.3 宏展开时绕不开的坑变量捕获宏有一个著名的问题叫“变量捕获”。如果你定义一个宏模板里用了某个临时变量名而调用宏的地方恰好也有一个同名变量那展开后的代码可能会错误地引用到宏内部的临时变量。举个经典例子(defmacro swap (a b) (let (tmp a) (set a b) (set b tmp)))这里宏内部用了tmp这个临时变量。如果调用宏时用户作用域里恰好也有一个tmp变量展开后就会互相干扰结果完全不符合预期。这被称为“不卫生的宏”。Lisp里有一套复杂的方案来解决卫生性问题其中最粗暴也有效的办法是“gensym”——生成一个独一无二、永远不可能和用户变量重名的符号。在我的迷你语言里我暂时没有实现变量和set所以这个问题不会暴露。但做设计的时候要提前想清楚如果将来要给语言加上变量绑定和赋值宏系统就必须引入“生成唯一符号”的机制或者在语法上禁止用户变量名和宏内部临时变量名冲突。我的建议是先实现一个简单的gensym展开宏时凡是在模板里以tmp:开头的符号都替换成一个像tmp_1234567890的唯一名字。这种小改动能在未来省掉无数深坑。5. 实操过程与完整代码走读5.1 工程目录与核心类型代码组织就一个包简单直接。目录结构如下tinylang/ ├── go.mod ├── lexer.go ├── parser.go ├── ast.go ├── env.go ├── eval.go └── macro.gomain函数负责读入源码字符串调用词法分析、语法分析、宏展开、求值这几步然后把结果打印出来。整个流程在main里看起来非常清爽一眼就知道解释器是怎么串起来的。ast.go里除了节点类型我一直建议把String方法做足。每个节点返回类似(if 1 2 3)的文本表示调试宏展开的时候往终端一打树形结构一目了然。5.2 核心函数走读Lexer、Parser、Eval词法分析的核心循环在lexer.go里。这里我提一个容易忽略的细节整形解析要处理负数和多位数。我的示例里把负数简化为“负号开头加数字”但你也可以把-当作一元运算符处理。从工程角度讲一开始只处理非负整数就够了能绕开一堆边界问题等语言框架跑通之后再加也不迟。Parser部分前面已经贴了核心代码这里说一个经验解析过程中遇到EOF时一定要给出“括号未闭合”的报错而不是返回一个空节点。很多解释器在输入不完整时静默失败导致用户找不到错误原因这非常不友好。报错信息尽量带行列号这是最低限度的“程序员友好度”。Eval部分我多提一个特殊形式的设计思路。我在EvalList里用switch判断第一个符号符号是if、define、defmacro时走特殊分支否则走内置函数分支。这个结构后续扩展起来很方便想加一个begin顺序执行想加一个let局部绑定都在这个switch里加分支即可。5.3 把宏展开嵌入主流程一个完整的执行周期是这样的func Run(source string) (Node, error) { tokens, err : Lexer(source) if err ! nil { return nil, err } parser : NewParser(tokens) ast, err : parser.Parse() if err ! nil { return nil, err } env : NewEnv(nil) if err : DefineBuiltins(env); err ! nil { return nil, err } expanded : Expand(ast, env) return Eval(expanded, env) }你会发现宏展开被放在Parse之后、Eval之前。这个位置是我经过几次调整后确定的理由前面说过宏展开需要AST但不需要“执行结果”。如果把展开放在Eval内部宏调用时改来改去求值器的递归逻辑会变得非常绕调试时根本不知道当前看的是展开前还是展开后的树。Expand的遍历逻辑有一个关键点对每个列表节点先判断第一个元素是否是宏名是则展开展开后的结果可能仍然包含宏调用宏嵌套的情况所以要对展开结果再递归调用一次Expand直到没有宏为止。这个“先替换再递归”的顺序很重要我最初的版本是先递归展开子节点再去判断宏调用导致宏嵌套时外层宏根本没被识别出来排查了半天才发现是展开顺序反了。宏嵌套的例子比如先定义了double-wrap然后它内部调用reverse而reverse也是个宏。调用(double-wrap x)时展开一次得到(reverse x)这棵树里还有宏所以必须再Expand一轮。如果Expand函数只展开一层就不再递归嵌套宏就永远展不开。所以Expand在设计上必须同时处理“当前节点展开”和“子节点递归展开”两件事。5.4 跑通一个带宏的完整样例我拿一个实际样例来走一遍完整流程这样你感受会更具体。假设源码内容如下(define x 10) (defmacro unless (condition then else) (if condition else then)) (unless ( x 5) 100 200)从第1行开始词法分析器产出Token流第2行defmacro在求值阶段被识别注册了一个名为unless的宏参数是condition、then、else模板是(if condition else then)第3行是一个宏调用。宏展开阶段遍历(unless ( x 5) 100 200)发现第一个元素是符号unless在环境里查到了宏定义于是把( x 5)绑定到condition100绑定到then200绑定到else然后复制模板并做替换。模板里condition被替换成( x 5)then被替换成100else被替换成200。展开结果变成(if ( x 5) 200 100)。注意这里的替换是“AST替换”不是“字符串替换”。Go代码在替换时直接把上次封装的( x 5)这个List节点插到了新List的对应位置。如果我用字符串替换就得担心括号、引号、转义问题各种错。这就是我反复强调AST是万物之源的真正含义——宏操作的永远不是文本而是已经结构化的语法树。最后求值(if ( x 5) 200 100)条件( x 5)结果为真于是取200作为结果。整段源码最终执行结果就是200完美符合unless的语义条件为真时执行else分支。6. 常见问题与排查技巧6.1 宏展开结果“消失”了我遇到过的最诡异的bug是宏展开完结果节点莫名其妙少了一层。后来发现是Copy和Substitute混用了。有个地方在Substitute里误用了原地修改导致公共模板被改写。排查时打印展开前后的AST一下就看出来模板被污染了。这个问题提醒我一个原则“宏的模板在定义之后永远不可变所有代换都在克隆体上进行。”把这个原则定死能省掉一整个类别的内存别名和引用共享问题。6.2 宏定义与顺序有关还有一个常见问题是宏定义的顺序。如果源码里宏定义出现在调用之后展开阶段就会查不到宏定义报“未定义的宏”。这是因为我的环境是逐步构建的宏注册发生在求值阶段但展开阶段在求值之前。怎么办最简单的方法是让展开和求值交替进行展开过程中遇到defmacro时就把宏注册到环境里而不是等到整个AST展开完再求值。我的做法是在Expand里遇到defmacro参数列表时先注册宏再继续处理后面的节点。这样宏定义和使用的前后顺序就必须保持“先定义后用”这其实是符合直觉的。如果想允许“先调用后定义”就需要做两遍扫描第一遍走AST收集所有defmacro注册到全局环境第二遍才做展开和求值。这个方案逻辑更干净但会牺牲一点“定义和使用交错”的灵活性。根据我的经验迷你解释器用“顺序定义”就够了老老实实先定义宏再调用报错也好定位。6.3 调试解释器的三板斧调试解释器有个很朴素的方法印出中间状态。我建议给三个环节都加上调试开关。词法分析后打印Token流语法分析后打印AST宏展开后打印AST。任何一步结果不对立刻能定位到是哪一环出了问题。具体操作上我给Run函数加了一个Dump布尔参数开着的时候每个阶段结束后fmt.Println( Token )、fmt.Println( AST )加上go的go test配合测试用例改一次跑一次。写解释器这种有大量递归结构的项目单元测试比断点调试友好太多。断点遇到复杂递归嵌套很容易看花眼而“格式化打印整棵树”的方式是全局视角一眼扫过去就知道结构对不对。还有一个小技巧给每种节点类型前面加个前缀标记比如整数节点打印成1#Int符号打印成x#Sym看打印结果时不会被类型混淆。写在最后这段实践给我的三点体会第一宏系统远没有想象中神秘它的本质是“结构化代码的替换”前提是你有一个足够灵活的AST。第二Go语言写解释器意外地顺手尤其是类型断言配合递归遍历让AST处理变得非常直白比起C语言时代的指针地狱开发体验好太多。第三解释器项目虽然小但五脏俱全词法分析、语法分析、求值、宏展开每一步都能独立测试、独立调试用来训练系统思考能力非常值得。最后再给你一个扩展方向给这个迷你语言加上函数定义和调用加上匿名函数再试试实现一套简单的类型系统。等你把宏和函数调用结合起来会发现它能玩出花来比如自己实现一个when、一个for-loop甚至一套简单的面向对象语法糖。按我踩过坑的经验只要你记住“先构建AST再求值、宏展开用克隆体、调试就打印中间状态”这三条后续加任何功能都不容易把代码写崩。你完全可以拿着这棵代码骨架长出自己的语言。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑