AST:从字符串到 C++ 可操作的结构
本节阅读量:上一节说清楚了这套小语言的规则:
|
|
接下来要写 lexer 和 parser。动手之前,先看清楚我们想得到什么。
源码一开始只是文本:
|
|
人能看出来它的意思:
|
|
但 C++ 程序不能只靠“看起来像”。我们要把这个结构存进对象里:
|
|
这就是这里要用的 AST。
AST 是 abstract syntax tree 的缩写。第一次看到不用记英文,先把它理解成:
|
|
AST 解决什么问题
你可能会问:既然源码就是字符串,为什么不直接在字符串上算?
对最简单的 (+ 40 2),硬写字符串处理也许能凑出来。比如找到 40 和 2,把它们相加。但字符串只是一排字符:
|
|
程序真正的意思是结构:
|
|
字符顺序和程序结构不是一回事。
嵌套表达式会让这个问题更明显:
|
|
如果只看字符串,你要自己数括号,才能知道:
|
|
如果变成 AST,结构马上清楚:
|
|
有了 AST,前后两段工作就分开了:
|
|
这也是 AST 的价值:
|
|
第一章的 AST 也很小,只有两种节点:
|
|
它们的规则是:
|
|
例如 (+ 40 2) 对应:
|
|
AST 是模块之间的约定
从代码架构上看,AST 不只是 parser 的输出。它还是解释器和编译器共同使用的一份结构。
这几块核心代码的关系可以先这样看:
|
|
这样分层有一个好处:parser 只管把源码读成结构,解释器和编译器只管消费这份结构。以后语言加新功能时,我们会先扩展 AST,再分别补解释器规则和编译器规则。
对应到 C++ 代码
代码在:
|
|
先看共同基类:
|
|
这里的意思是:
|
|
enum class ExprKind 可以先理解成一张固定的分类表:
|
|
我们不用字符串 "integer" 或 "add" 来猜节点类型,而是让 C++ 帮我们限制:ExprKind 只能取这些已经列出来的值。
kind 是给后面的 AST 消费者用的。解释器拿到一个 Expr 时,可以先看它是 integer 还是 add,再决定按哪条规则求值。
virtual std::string dump() const = 0 的意思可以先读成:
|
|
这一节不用深入学完整的 C++ 多态规则。这里只要知道:parser 返回的是通用的 Expr,但真正的节点可能是 IntExpr,也可能是 AddExpr。
整数节点:
|
|
它只保存一个整数:
|
|
加法节点:
|
|
它保存两个子表达式:
|
|
unique_ptr 和 std::move
为什么左右子表达式要用指针?因为 AddExpr 的左右两边都是“表达式”,而表达式的具体类型不固定。它可能是 IntExpr,也可能是另一个 AddExpr。
所以不能直接在 AddExpr 里写:
|
|
Expr 只是共同基类,而且有纯虚函数 dump(),不能被直接创建成普通对象。
我们真正想保存的是:
|
|
这里用 std::unique_ptr<Expr>,是为了把 AST 的父子关系写进类型里:
|
|
构造函数里会看到 std::move:
|
|
这里的 lhs(std::move(lhs)) 可以读成:
|
|
这里必须写 std::move,因为 unique_ptr 不能复制,只能移动。可以先这样记:
|
|
parser 里创建加法节点时,也会看到同样的交接:
|
|
可以按顺序读成:
|
|
所以 (+ 40 2) 的 AST 所有权关系可以画成:
|
|
这一章先记住三句话:
|
|
dump 是用来观察结构的
运行:
|
|
输出:
|
|
这个输出不是给最终用户看的,而是给学习和调试看的。它让你确认:
|
|
如果运行嵌套加法:
|
|
会看到类似:
|
|
这说明 AST 的左右子表达式也可以继续是 Add。
下一节开始,我们再看源码文本怎样一步步变成 parser 能读的 token。