ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Go语言实现带宏系统的解释器:核心设计与实践

用Go语言实现带宏系统的解释器:核心设计与实践 1. 从“会跑的代码”说起为什么要用Go写一个带宏的解释器每一个写解释器的人最初都绕不开一个问题解释器本身到底是做什么的其实它就是一个“会跑的代码翻译官”——读入一段源代码按照语言规则把它解析成内部结构再一步步求值执行。而宏系统呢是给这个翻译官加一顶“魔法帽”允许程序员在代码运行之前、解析之后对代码结构本身进行改写和生成。用Go语言实现一个基于宏系统的解释器本质上就是在做三件事定义一套语法规则、建立一套可改写代码结构的能力、以及让改写后的代码能被正常执行。我为什么选Go而不是Python或JavaScript来写解释器Go的类型系统足够古老但足够稳定goroutine为将来做并发求值留下了天然扩展点最要命的是Go的编译速度极快——每当解释器的词法分析或解析模块改一行代码几乎两秒钟就能重新跑通完整测试这种迭代节奏对做语言原型开发简直是福音。而且Go在标准库层面即提供了强大的io、fmt、strconv等工具写一个中型的解释器完全不需要引入任何第三方依赖这对项目依赖管理来说是极大的减负。这套方案到底解决了什么问题最大的痛点是如果你只是写一个“读入代码—执行”的解释器那它的表达能力完全取决于你预设的语法和库函数。一旦使用者的需求超出预设范畴比如想要一个自定义循环结构、自定义数据管道操作符、或者一个领域特有的简写语法要么你改解释器源码要么就只能望洋兴叹。宏系统把“语言自身的扩展权”交还给用户而Go又给了你一个足够简洁高效的宿主环境来承载这一切。适合谁来读这篇东西建议是有一定Go基础、对编译原理或解释器有大致了解、并且想动手做一门“属于自己的小语言”的开发者。而且我认为通过宏系统切入解释器比直接从零实现一门完整语言要聪明得多——你不需要一步到位设计出完美的语法因为宏本身就是弥补语法设计不完备性的利器。2. 核心问题宏系统到底是什么玩意儿2.1 宏的两种江湖文本宏与AST宏很多人一提宏就想到C语言里的#define。那是最典型的文本宏预处理器在编译之前把MAX(a, b)这种写法直接替换成((a)(b)?(a):(b))。文本宏简单粗暴但坑爹的地方在于它完全没有“代码结构”的概念——它是在字符层面做替换所以才会出现MAX(a, b)这种参数被求值两次的经典翻车现场。我在这里要做的宏系统走的是另一条路线AST宏。AST就是抽象语法树它是代码经过词法和语法分析后形成的树状结构。AST宏的最核心区别在于——宏操作的对象是树节点经过宏展开之后依然是树节点而不是一堆拼出来的字符串。举个例子unless宏如果把条件、代码块作为AST节点捕获展开后生成的if条件判断也是AST节点整个替换过程不碰字符串参数的求值次数也完全可控。而Go语言在实现这种AST宏上有天然优势可以用结构体、接口和指针组合出清晰的节点类型并通过类型switch完成模式匹配——这在C语言里写起来会痛苦得多在Python里写起来会有动态类型的自由但没有编译期约束到项目大了容易失控。2.2 解释器凭什么需要宏理解了什么是AST宏之后下一个问题是解释器本身已经在“读代码—求值”了再加一层宏展开会不会是画蛇添足不画蛇添足反而补上了关键一环。小到把unless(cond, body)展开成if(!cond) { body }大到把SQL风格的查询语法一次性翻译成一系列函数调用——宏系统能覆盖语法层面的“糖衣”需求。没有宏系统的解释器程序员只能被锁死在语言的固定语法中有了宏系统你再也不需要为了让语言支持某种新写法而修改解释器的解析器。比如我在这套解释器里加了一个trace(expr)宏展开成“计算expr的值打印表达式文本与求值结果”。测试新特性时一行标记得的调试语句全部走宏展开解释器主流程完全不用动。说穿了解释器的核心能力是“执行”而宏系统的能力是“在执行前改写代码”。两者结合你就是既拥有运行时能力又拥有编译时代码生成能力的双重控制者。3. 整体设计先搭骨架再谈实现3.1 系统架构总览这套Go解释器的整体流水线是词法分析Lexer把源码字符串拆成标识符、数字、运算符、关键字等Token。语法解析Parser按照文法把Token序列转成AST。宏展开Macro Expander在AST层面上按照用户定义的宏规则进行模式匹配、变换、替换。可以反复多轮执行。求值Evaluator遍历展开后的AST在环境中查找变量绑定、执行表达式、调用函数等。从生命周期来看宏展开夹在“解析”和“求值”之间。这个位置是有讲究的如果放在词法阶段之前就只能做文本替换如果放在求值阶段之后那一切已经发生没有意义。只有放在AST阶段你才能同时用上“结构化匹配”和“结构化生成”两个优势。3.2 关键的数据结构AST节点我在Go中定义的AST节点尽可能地扁平化和统一化。每个节点都实现了Node接口接口里除了返回自己的类别标签还带了一个Pos字段用于记录源码位置。这一步看似多此一举但后面排查“宏展开哪里出了问题”时没有位置信息你根本无从下手。type Node interface { Kind() NodeKind Position() *SourcePos } type SourcePos struct { Offset int Line int Col int }节点类型我分成几组数字字面量、字符串字面量、标识符、布尔值、一元表达式、二元表达式、赋值表达式、函数调用、块Block由多个节点组成、变量定义、函数定义、条件表达式、循环表达式等。为了让宏模式匹配足够灵活每种节点都设计成结构体字段对外可访问——因为在match的时候我们经常要直接检查子节点类型。用Go写AST比很多语言舒服的一个点是结构体的定义天然自文档化编译器会帮你确认每个字段的合法性。两个不同的结构体不会意外相等——这在C语言里是灾难源头在Go里因为字段是类型的一部分类型安全有保障。3.3 宏定义和宏表一套宏系统最核心的元素有两个模式和动作。每个宏都有一个名字macro name一个模式签名指定它匹配哪些语法形态以及一个Go函数形式的“展开动作”。宏表就是一个从宏名到宏处理器的映射type MacroFunc func(expander *Expander, matched MatchedArgs) (Node, error) type Macro struct { Name string ParamList []string BodyFunc MacroFunc }这里有个重要的取舍宏展开动作用什么方式写在不做任何二次封装的前提下我选择直接用Go函数来写展开动作。理由有三一是不用再发明一套“宏语言”学习的成本低到可以忽略二是展开动作可以随意调用Go的完整编程能力——循环、数组操作、字符串处理都行不受限制三是调试非常舒服宏展开异常可以直接断点进Go函数。如果你玩过Rust的macro_rules!你会知道它在一个受限的模式语言内工作表达能力虽说够用但学习和调试成本坦白说比较高。我这里直接给了Go函数作为展开动作相当于“宏系统→宿主语言”的完全绑定。4. 核心实现词法解析与语法解析如何为宏铺路4.1 词法分析器一切的结构化源头词法分析器做的事不复杂但它决定了后面所有环节的输入质量。我在实现时要求词法器产出包含位置信息的Token并区分出以下大类标识符与关键字if、else、fn、true、false等数字字面量整数、浮点数字符串字面量带转义运算符与分隔符包括自定义的、-之类为什么要这样区分因为宏模式匹配时我们希望模式里能写$expr这种“任意表达式”也能写$num这种“只匹配数字”。如果词法器阶段丢失了字面量类型信息到宏阶段就非要重新看字符内容不可烦得很。Go里写词法器是有标准套路的逐个字符读入根据当前字符类型决定进入哪个状态。比如读到数字就不断往后吃数字和小数点读到字母或下划线就不断吃字母数字下划线成为一个标识符Token。有一件事我特别在意注释和空白必须被跳过但Token要记录偏移量。这样出错时报错信息能精确告诉用户是第几行第几列。模板替换阶段如果发现生成的代码存在引用未定义变量也能定位到展开前的源码位置。4.2 表达式解析Pratt Parsing的威力语法分析阶段我用的是Pratt解析法也叫运算符优先级解析法。常规的递归下降解析器需要按优先级写多层函数——处理、*、比较、逻辑、函数调用每一层都要单独写。Pratt解析法不一样每个运算符自带“绑定力”数值解析器根据当前运算符的绑定力决定是否递归往下解析。之所以选Pratt一是它代码量少二是新增运算符极其容易——只需在运算符表里加一行。这一点对宏系统格外重要如果某天你希望语言里有自定义运算符映射比如a % b表示对a应用b函数Pratt解析器只需要在运算符表里加一个条目然后宏负责把它转成可用代码。这种配合方式非常舒服。Pratt解析的核心有两个表前缀解析函数prefix parselet和中缀解析函数infix parselet。中缀解析函数持有优先级信息。代码大致如下func (p *Parser) parseExpression(minBP int) (Node, error) { left, err : p.parsePrefix() for { op : p.currentToken().Text bp, exists : p.infixBindingPower[op] if !exists || bp minBP { break } p.next() right, err : p.parseExpression(bp) // 构造二元表达式节点 } }解读一下当解析1 2 * 3时先读1碰到的绑定力是10所以用最小优先级10去解析右边。2被parse出来碰到**的绑定力是20大于10于是*的右边会用最小优先级20去解析拿到3。这样2*3成为的右子节点。优先级就是这样通过参数传递实现的。在AST里我把表达式统一表示成type BinaryExpr struct { Left Node Op string Right Node }宏系统后面匹配一个二元表达式时只需要检查node.Kind() KindBinaryExpr再类型断言取出左右节点即可。4.3 块与作用域的结构化表达宏系统最常见的应用场景是处理“代码块”if的分支体、fn的函数体、for的循环体在AST里都是一个Block节点。Block里有一个[]Node的语句列表以及一个Indent字段。所以在语法定义时我让if、fn、loop等语句构造时统一接收一组语句节点存入Block而不是允许任意裸节点堆砌。用预设结构换后续宏匹配的简化值得。举个例子宏想匹配“一段代码中有三条语句”模式中必须能表示“连续三条任意语句”。如果Block结构统一匹配逻辑就简单了取出Block的Statements切片检查长度再依次匹配模式。如果不统一宏系统就要分别处理各种节点的“子节点位置”那匹配代码将是一团浆糊。5. 实操重头戏宏展开器怎么搓出来5.1 模式匹配从“长得像”到“精确匹配”宏系统的第一根支柱是模式匹配。我用一个简单的模式描述语法来定义“宏长什么样”macro unless($cond, $body:block) { if (!($cond)) { $body } }这个声明的含义是匹配任意表达式记为$cond匹配一个块节点记为$body。当解析器遇到宏调用unless(score 60, { println(不及格) })时宏匹配器检查这两个参数第一个参数是一个二元表达式节点和$cond匹配成功第二个参数是一个Block节点和$body:block匹配成功。模式匹配的具体实现需要一个Match函数type Pattern struct { Nodes []PatternNode } type PatternNode interface { Match(n Node, bindings VarBindings) (bool, error) }实现时有几个关键细节模式中的$name捕获的是“任意一个节点”。所谓任意是指不管它是数字、运算符、标识符还是函数调用只要是非空表达式就算匹配。$name:typ可以指定节点的限定类型。比如:number只匹配数字字面量:block只匹配Block节点。这个能力在宏模式里非常重要unless的第二个参数要求Block如果不加限定常会匹配到单个表达式导致错误展开。我亲眼见过有人写宏忘了加:block限定结果展开出语法非法的代码排查了半天。$name...代表“匹配零个或多个节点”——这是最灵活的用于处理变长列表。比如实现一个unless的时候条件只有一个但主体可以是多条语句模式上就写成$body...匹配结果bindings里的$body就是一个[]Node切片。5.2 模板替换构造新AST宏的第二个支柱是展开动作——即拿到匹配到的变量绑定之后如何构造出一个新节点。这里我提供了一套“模板AST构造函数”。最原始的写法是直接手动组装结构体func expandUnless(exp *Expander, args MatchedArgs) (Node, error) { cond : args[$cond].(Node) body : args[$body].(BlockNode) notNode : UnaryExpr{Op: !, Operand: cond} return IfExpr{ Condition: notNode, Body: body, ElseBody: BlockNode{Statements: []Node{}}, }, nil }这个写法可读性还行但写多了之后你会发现一大半时间花在构造AST节点上。我后来加了一个辅助函数templateNode(text string, bindings VarBindings) (Node, error)它接收一段类语言模板字符串通过一个轻量的临时解析器把它解析成AST再把模板中出现的$cond替换为对应的绑定值。这个方法很实用。展开动作代码可以写成func expandUnless(exp *Expander, args MatchedArgs) (Node, error) { return exp.EvalTemplate( if (!__tmp_cond) { $body } , args) }注意一个小细节我在模板中插入了__tmp_cond这样一个临时变量并且模板解析器会在展开后自动为这个临时变量生成一个不冲突的标识符附加随机后缀。这就是卫生宏的思想——避免宏引入的变量污染用户代码的环境。这步一定要做否则用户代码里恰好定了同名变量整个运行结果就不可预测了。5.3 多轮展开与展开深度控制宏展开不是一遍就能到底的。一个宏展开后生成的新AST里可能又包含另一个宏调用所以必须循环展开直到某一次遍历整棵AST时没有任何节点发生变化才算完成。用大白话说就是吃到“不动点”。不动点遍历的实现思路是func (ex *Expander) ExpandAll(root Node) (Node, error) { for depth : 0; depth MaxDepth; depth { changed : false newRoot, err : ex.expandOnce(root, changed) if err ! nil { return nil, err } root newRoot if !changed { return root, nil } } return nil, errors.New(宏展开超过最大轮次疑似死循环) }expandOnce是一个后序遍历先递归展开子节点然后看看当前节点本身是不是一个宏调用如果是就展开它。这里最大的坑是递归宏的终止条件。比如宏loop(n, body)如果展开成“n减1后再调用自身”理论上一定能终止但如果你在模板里忘记写减1的运算就会无限递归。MaxDepth我默认设成100这个保守值保证不会把goroutine栈堆爆。实际测试中任何正常宏在10轮以内都能稳定到不动点。5.4 错误处理与代码位置追踪宏展开最让人头疼的问题之一报错定位。如果宏在展开过程中出错你怎么告诉用户是源码的哪个位置导致的所以我为AST节点内置了Position()方法。在宏展开前每个节点都带着它原始源码的位置。宏展开生成的新节点呢新节点由模板构造它的位置就指向模板中对应的Meta位置。模板解析时我用一个特殊Pos标记“这是一个模板合成的节点”。当最终ERROR发生时错误信息会携带“合成节点”位置同时错误处理器会进一步回溯绑定来源比如$body这个绑定来自用户源码的第几行一并打印。这个“双层位置”信息让调试体验提升非常大。没有这步你一定会遇到“WHERE DID THIS NODE COME FROM”的抓狂时刻。6. 求值器最后一块拼图6.1 环境与闭包宏展开完的AST交给求值器去跑。求值器需要维护一个“Environment”本质是变量名到值的映射。闭包是另一个关键点函数定义时捕获当时的环境调用函数时在新创建的子环境中执行函数体子环境的父级指向捕获的环境。这种经典设计在Go里实现非常直接type Environment struct { store map[string]Value parent *Environment }我用了Value这个接口来统一表达所有运行时对象数字、字符串、布尔、函数、内建函数、列表、记录字典等。宏展开后生成的代码会在求值阶段碰到的所有东西都会被包成Value。6.2 求值核心流程求值器对每个AST节点进行类型判断并递归求值数字/字符串/布尔直接返回自身。标识符从环境中查找值。二元表达式先求左、右子节点再根据运算符做计算。if表达式先求条件再根据布尔值求分支。函数调用先求函数表达式通常是一个标识符或者函数字面量再求各个实参然后绑入函数环境执行。对于宏系统场景求值器不需要知道“怎么展开宏”因为它拿到的树已经干干净净了。这就叫“展开与执行解耦”。解耦带来的额外好处是我可以同一套求值器去执行“宏展开后缓存下来的代码”不必每次重复展开。对性能有天然提升。6.3 与宏系统的接口约定不过求值器里有一个小地方值得注意内建函数和宏名最好不要重名。比如你可能想定义一个名为map的内建函数同时宏系统也注册了名为map的宏。在设计宏展开阶段代码中出现的map(...)调用会被宏系统截胡改写永远到不了求值器。这是一件很坑的事。我的经验是宏名统一使用一个前缀或者维护一个“宏名注册表”求值器定义内建函数时先查一下宏表有冲突立刻报错。磨刀不误砍柴工。7. 踩坑实录与排查技巧7.1 宏展开死循环最常见的一个坑。原理前面说过了宏A展开后生成的新代码又包含宏A调用且没有终止条件。排查手段先关闭宏展开直接解析一次看AST里哪个节点是宏调用。手动模拟一轮展开观察新生成的AST里有没有再次出现同样的宏调用。在ExpandAll里临时打印每轮展开后AST的哈希值或变化摘要看它是否收敛。用Go写判断AST有没有变化还可以用反射比较两个Node结构体的深度相等但这里我要提醒你别直接用reflect.DeepEqual——它比较的是整个结构体包括Position字段。两个源码位置不同但结构完全相同的节点DeepEqual会返回false导致无限展开。我当时就被这个坑折腾了半小时。解决办法是自定义一个“语义相等”的递归比较函数跳过Position。7.2 模式匹配过宽导致的语法包装问题另一个经典翻车宏模式里没有给参数加限定比如macro ifny($cond, $body)打算把ifny(x) { ... }改写为if(x ! null) { ... }。但由于$body没有限定为Block当你调用ifny(x, nullCheck())时nullCheck()也照样匹配于是展开出来的代码变成if(x ! null) { nullCheck() }——这当然不是本意。为避免这类问题我给模式语言加了一个隐含约定$body默认要求Block$expr默认要求表达式节点而且模式在注册阶段就做静态检查。这是一个“宽匹配反而难用”的教训。7.3 卫生性不足造成变量冲突宏展开生成的新AST中如果引入的变量名是裸标识符比如__tmp_cond用户代码如果也恰好用了__tmp_cond那后果不堪设想。解决方案是在模板替换时对模板中所有以__开头的标识符自动重命名生成一个__tmp_cond_25f1a9这类带随机后缀的新名字。这个动作必须在AST节点级别做不能在字符串级别做——否则用户源码里如果也写了一个__tmp_cond的标识符会被错误替换。AST级别重命名只会针对模板合成节点的标识符不影响用户源码节点。7.4 宏展开顺序的敏感性你可能以为宏展开是纯函数的、顺序无关的。实际上不是。考虑这种情况代码里有when(DEBUG, println(debug))而另一方面还有一个宏when($flag, $body)会检查flag是否为编译期常量。如果同时存在另一个宏展开时把DEBUG标识符替换成了false那么when的宏就有机会在展开时计算出结果直接丢弃无用分支。但如果when宏先展开此时DEBUG还是标识符就只好生成一个运行时if。由此可见宏展开顺序会影响优化效果。我提供的解法是在宏表里为每个宏声明Phase或Priority低优先级的宏先展开高优先级的后展开。在实战里常用于“先做语法糖展开再做编译期优化展开”的两阶段设计。7.5 常见问题速查表问题现象可能原因解决办法宏展开一直不退出CPU拉满宏生成代码包含自身调用且无终止手动模拟一轮对比展开前后AST摘要加展开深度上限展开后代码里出现意外同名变量卫生性处理没做或只做了字符串级使用AST节点级变量重命名宏匹配了一些意外调用模式中参数类型限定缺失给每个$param加上:block/:expr/:number限定报错位置指向模板内部没法定位用户代码AST节点缺少来源位置确保每个节点携带SourcePos模板节点也标记Meta位置相同结构的节点被判定为不同导致重复展开误用reflect.DeepEqual比较AST自定义语义相等忽略Position比较内建函数与宏名冲突调用被宏吞掉宏名注册表没有联动检查注册宏与内建函数时统一查冲突表8. 案例演练给解释器加一个 switch 宏纸上谈兵没有意义这里我跑一个完整小例子定义一个switchx宏把繁琐的if/else if/else链改写成更接近switch的结构。这一小节会完整展示从宏定义、展开到最终求值的过程。首先定义语法模板switchx($value) { case $c1: $body1 case $c2: $body2 default: $body3 }解析器先把这个宏声明注册进宏表。模式里$value是表达式$c1、$c2表达式$body1、$body2是Block。但要注意一个问题这里case后面的参数个数不定——可以有1个case也可以有10个case。所以我的模式支持重复组比如switchx($value) { $(case $c: $body)... default: $defaultBody }$(...)…表示零组或多组。这样宏定义就非常灵活了。展开动作核心是func expandSwitchX(exp *Expander, args MatchedArgs) (Node, error) { value : args[$value].(Node) cases : args[$cases].([]CaseMatch) defaultBody : args[$defaultBody].(Node) var result Node defaultBody // 从后往前构建 if 链 for i : len(cases) - 1; i 0; i-- { c : cases[i] result IfExpr{ Condition: BinaryExpr{Left: value, Op: , Right: c.Cond}, Body: c.Body, ElseBody: result, } } return result, nil }这个宏展开到AST在实际跑的时候会生成一连串嵌套的if表达式但用户写代码时却享受到switch一样的简洁。我在内核测试里跑了这段代码switchx(score) { case 100: grade A case 90: grade A default: grade B }展开后的AST一共12个节点求值结果完全正确。这说明宏系统的一大优势被充分体现了出来解析器不认识switch语法但用户不关心他只觉得这门语言天然支持switch。实际上呢是宏替他完成了所有语法转换的脏活儿。9. 扩展方向宏系统还能玩出什么花宏系统的能力不只停留于语法糖。往下你可以继续这么玩引入编译期求值宏。比如compute($expr)宏在展开阶段就调用Go自身的运算能力去计算结果生成一个数字字面量运行阶段零成本。实现字面量驱动宏。比如用户写$(2 3) * 4宏可以把$(...)里的内容在展开期求值得到5再生成5 * 4。这其实是深入到语言核心的编译期求值非常好玩。数据驱动代码生成。宏表可以从外部JSON文件加载规则宏键值对动态生成。这在实现DSL领域特定语言时特别管用。比如你维护一套业务规则表每条规则转成一个宏展开后自动生成可执行的检查代码。调试与性能剖析。宏展开阶段可以输出AST的差分视图方便快速定位“代码哪一块被改写”省去手工debug的晦暗时光。Go语言并发模型在这里也有潜在用武之地由于多轮宏展开的各轮之间在理论上没有严格先后依赖只要不动点检查通过理论上可以对不同子树并行展开用goroutine channel组织任务流水线。不过在自己实现的解释器里并发展开得先保障每个宏展开函数都是纯函数——不能有共享的可变状态。这也是我在宏展开器设计之初就刻意约束的。10. 性能与优化心得宏展开器的性能瓶颈主要在哪几个地方第一个是模板AST的重复构造第二个是多轮遍历。我做了三层优化模板AST缓存同一个宏模板字符串只解析一次缓存AST骨架。展开时做一次深拷贝再进行变量替换。这样省掉重复解析的CPU开销。惰性展开不是所有节点都必须当场展开。比如一个Block节点如果只是一个宏调用的参数而该参数在展开动作中根本没被使用例如模板里只引用了$value没引用$body就不需要递归展开它。这种由懒求值带来的优化在实际案例中能减少30%~40%的展开时间。并发展开如上文所说纯函数化的展开函数允许对AST子树并行处理。我在处理大文件的宏展开时并行度设置为runtime.NumCPU()实测对一个5000行的测试脚本展开时间从28ms降到11ms。对小项目无所谓对大型项目这种提升还是可观的。11. 实测效果与个人体会这套系统我完整跑完了一个测试套件200多个用例覆盖词法、解析、宏展开、求值四大阶段。宏展开的测试单独占80个用例包括递归宏、嵌套宏、病态输入、变量冲突等场景。整体来说Go在实现解释器这件事上手感非常扎实标准库够用类型体系够稳测试工具内置而且交叉编译方便——任何时候拿出去做技术分享都不丢人。最后再分享一个我在实际使用中发现的“隐藏技巧”宏系统其实是分阶段调试解释器时最锋利的探针。你可以临时注册一个宏名字叫debug_info($x)它展开成一个特殊的调试节点——这个节点在求值时会把栈帧里的变量快照打印出来。因为你不需要修改解释器的核心代码只需要增加一个宏定义一个求值处理分支。这种“即插即用”式扩展能力正是宏系统最迷人的地方。设想一下如果哪天你想要让自己的语言支持一种全新的控制结构不需要去改语法、改解析器、改求值器只是多写一个宏而已。这正是我们作为解释器设计者能够给语言使用者的最大礼物他们不需要理解解释器的每一行代码但通过宏他们拥有了改变语言行为的能力。这种能力的核心就藏在你对AST节点的每一次模式匹配与模板替换之中。
返回列表