语法树,到底是一棵什么形状的树?
以前学编译原理时,我一直有个疑问。
教材一讲语法树,几乎总是从这种例子开始:
text
A + B
然后画成:
text
+
/ \
A B
复杂一点,再写:
text
A + B * C
画成:
text
+
/ \
A *
/ \
B C
这些例子当然没错。
但我当时真正卡住的,不是运算符优先级。
而是另一件事:
这么一个"一个节点分成左右两个孩子"的结构,到底怎么表示一个完整程序?
真实源码里根本不只有 A + B。
它还有:
- 一个源文件里的很多顶层声明
- 一个类里的很多成员
- 一个函数里的很多参数
- 一个代码块里的很多语句
- 一层一层不断嵌套的调用、分支和循环
所以我当时真正没想通的是:
如果脑子里只有"二叉树"或者"固定几叉树"这种形状,那它根本装不下完整源码。
后来真正去看 Clang 的 AST,我才发现,问题不是语法树难,而是教材给人的第一个形状太容易把人带歪。
1. 真正的问题,不是树,而是"固定叉数"
很多人第一次看到语法树,会默认把它理解成:
text
一个节点
├── 左孩子
└── 右孩子
或者稍微放宽一点:
text
一个节点
├── 第1个孩子
├── 第2个孩子
└── 第3个孩子
也就是说,脑子里想的还是一种固定叉数的树。
这正是误解的起点。
因为固定叉数的结构,确实很难自然表示完整源码。
举几个最直接的例子:
- 一个源文件里可能有几十个函数
- 一个类里可能有十几个字段和方法
- 一个函数调用可能有任意多个参数
- 一个代码块里可能有几十条语句
如果你脑子里想的是"每个节点最多只能分成两个孩子"或者"每个节点预先固定分成几个孩子",那当然会觉得根本装不下。
所以当时真正让我困惑的,不是语法树为什么是树。
而是:
为什么教材一直拿固定叉数的局部结构,去解释一个根本不是靠固定叉数组织起来的整体结构。
2. A + B 只是一个局部节点,不是整棵树的通用形状
A + B 这个例子之所以会长成:
text
+
/ \
A B
不是因为语法树规定每个节点都只能有两个孩子。
而是因为加法运算这个节点,刚好就有两个操作数。
也就是说,这只是一个二元表达式节点的局部形状。
它只能说明一件事:
对于
+这种运算,它下面会挂两个子表达式。
但它完全不能推出另一件事:
整棵语法树都长这样。
这两件事根本不是一回事。
教材最大的问题,就是太容易让初学者把这两件事混在一起。
3. 完整源码真正需要的,不是二叉树,而是"嵌套列表"
后来我才真正理解:
完整源码要能表示出来,节点下面就不能是固定几个叉,而必须是一组有顺序的子节点列表。
也就是说,语法树更准确的基本结构应该是:
text
节点
├── 节点类型
├── 节点自身属性
└── children: [子节点, 子节点, 子节点, ...]
这里真正关键的不是"树"这个字。
而是:
每个节点都可以继续带着一个子节点列表,而列表里的每个元素又是同样的节点对象。
这才是它能递归装下整个程序的原因。
所以如果一定要说我后来真正看懂了什么,那就是:
语法树不是"固定叉数的树",而是"递归嵌套的列表对象"。
4. 用源码结构看,就很容易明白
比如下面这段代码:
c
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
return add(1, 2);
}
它更接近这样的结构:
text
TranslationUnit
├── FunctionDecl add
│ ├── Parameter a
│ ├── Parameter b
│ └── CompoundStmt
│ ├── VariableDecl result
│ │ └── BinaryOperator +
│ │ ├── DeclRefExpr a
│ │ └── DeclRefExpr b
│ └── ReturnStmt
│ └── DeclRefExpr result
└── FunctionDecl main
└── CompoundStmt
└── ReturnStmt
└── CallExpr add
├── IntegerLiteral 1
└── IntegerLiteral 2
这棵树最上面是整个源文件。
它下面不是"左孩子右孩子",而是一个顶层声明列表。
函数节点下面也不是固定两个叉,而是:
- 参数列表
- 函数体
函数体下面继续是语句列表。
语句里面再继续套表达式节点。
一直到最底层的 a + b,才会出现教材最爱画的那个二元运算小局部。
所以真正的关系应该倒过来理解:
A + B不是语法树的标准形状。它只是完整语法树最底层、最局部的一种节点形状。
5. 如果换成 JSON,就更不容易误解
语法树其实更像这样:
json
{
"type": "TranslationUnit",
"children": [
{
"type": "FunctionDecl",
"name": "add",
"children": [
{"type": "Parameter", "name": "a"},
{"type": "Parameter", "name": "b"},
{
"type": "CompoundStmt",
"children": [
{},
{}
]
}
]
},
{
"type": "FunctionDecl",
"name": "main",
"children": []
}
]
}
这里真正重要的是两件事:
- 每个节点都有自己的类型和属性
- 每个节点都可以继续带一个有序子节点列表
所以一个类有十几个成员,没问题。
一个函数有很多参数,没问题。
一个代码块里有几十条语句,也没问题。
因为它们本来就不是靠"固定几个叉"来表示的。
它们是靠:
节点对象 + 子节点列表 + 递归嵌套
来表示的。
6. 我后来真正理解的,不是"树",而是"列表"
所以现在回头看,我当时真正的疑问其实可以压成一句话:
二叉树或者固定几叉树,装不下完整源码。
如果一直拿这种形状去理解语法树,就会天然想不通:
- 一个类的很多成员怎么放
- 一个函数的很多参数怎么放
- 一个代码块的很多语句怎么放
- 一个源文件的很多声明怎么放
直到后来我把它理解成"递归嵌套的列表对象",整件事才一下通了。
所谓语法树,本质上不是:
text
每个节点往左右分叉
而是:
text
每个节点都带着自己的信息
每个节点下面再挂一个有序子节点列表
列表里的每个元素又继续是同样的节点
这套结构递归下去,才足以表示一个完整源文件。
7. 总结
教材拿 A + B 讲语法树,本身没有错。
错的是如果它没有及时补一句:
这只是一个二元表达式节点的局部形状,不是整棵语法树的通用结构。
一旦少了这句话,初学者就很容易把语法树误解成二叉树,或者误解成某种固定几叉树。
而真正能表示完整源码的,不是"固定叉数的树"。
而是:
节点对象 + 有序子节点列表 + 递归嵌套。
如果你非要我把这个理解再压短一点,那就是:
完整源码不是靠二叉分叉装进去的,而是靠嵌套列表装进去的。