语法树,到底是一棵什么形状的树?

3 阅读6分钟

语法树,到底是一棵什么形状的树?

以前学编译原理时,我一直有个疑问。

教材一讲语法树,几乎总是从这种例子开始:

A + B

然后画成:

    +
   / \
  A   B

复杂一点,再写:

A + B * C

画成:

    +
   / \
  A   *
     / \
    B   C

这些例子当然没错。

但我当时真正卡住的,不是运算符优先级。

而是另一件事:

这么一个“一个节点分成左右两个孩子”的结构,到底怎么表示一个完整程序?

真实源码里根本不只有 A + B

它还有:

  • 一个源文件里的很多顶层声明
  • 一个类里的很多成员
  • 一个函数里的很多参数
  • 一个代码块里的很多语句
  • 一层一层不断嵌套的调用、分支和循环

所以我当时真正没想通的是:

如果脑子里只有“二叉树”或者“固定几叉树”这种形状,那它根本装不下完整源码。

后来真正去看 Clang 的 AST,我才发现,问题不是语法树难,而是教材给人的第一个形状太容易把人带歪。

1. 真正的问题,不是树,而是“固定叉数”

很多人第一次看到语法树,会默认把它理解成:

一个节点
├── 左孩子
└── 右孩子

或者稍微放宽一点:

一个节点
├── 第1个孩子
├── 第2个孩子
└── 第3个孩子

也就是说,脑子里想的还是一种固定叉数的树。

这正是误解的起点。

因为固定叉数的结构,确实很难自然表示完整源码。

举几个最直接的例子:

  • 一个源文件里可能有几十个函数
  • 一个类里可能有十几个字段和方法
  • 一个函数调用可能有任意多个参数
  • 一个代码块里可能有几十条语句

如果你脑子里想的是“每个节点最多只能分成两个孩子”或者“每个节点预先固定分成几个孩子”,那当然会觉得根本装不下。

所以当时真正让我困惑的,不是语法树为什么是树。

而是:

为什么教材一直拿固定叉数的局部结构,去解释一个根本不是靠固定叉数组织起来的整体结构。

2. A + B 只是一个局部节点,不是整棵树的通用形状

A + B 这个例子之所以会长成:

    +
   / \
  A   B

不是因为语法树规定每个节点都只能有两个孩子。

而是因为加法运算这个节点,刚好就有两个操作数。

也就是说,这只是一个二元表达式节点的局部形状。

它只能说明一件事:

对于 + 这种运算,它下面会挂两个子表达式。

但它完全不能推出另一件事:

整棵语法树都长这样。

这两件事根本不是一回事。

教材最大的问题,就是太容易让初学者把这两件事混在一起。

3. 完整源码真正需要的,不是二叉树,而是“嵌套列表”

后来我才真正理解:

完整源码要能表示出来,节点下面就不能是固定几个叉,而必须是一组有顺序的子节点列表。

也就是说,语法树更准确的基本结构应该是:

节点
├── 节点类型
├── 节点自身属性
└── children: [子节点, 子节点, 子节点, ...]

这里真正关键的不是“树”这个字。

而是:

每个节点都可以继续带着一个子节点列表,而列表里的每个元素又是同样的节点对象。

这才是它能递归装下整个程序的原因。

所以如果一定要说我后来真正看懂了什么,那就是:

语法树不是“固定叉数的树”,而是“递归嵌套的列表对象”。

4. 用源码结构看,就很容易明白

比如下面这段代码:

int add(int a, int b) {
    int result = a + b;
    return result;
}

int main() {
    return add(1, 2);
}

它更接近这样的结构:

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,就更不容易误解

语法树其实更像这样:

{
  "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. 我后来真正理解的,不是“树”,而是“列表”

所以现在回头看,我当时真正的疑问其实可以压成一句话:

二叉树或者固定几叉树,装不下完整源码。

如果一直拿这种形状去理解语法树,就会天然想不通:

  • 一个类的很多成员怎么放
  • 一个函数的很多参数怎么放
  • 一个代码块的很多语句怎么放
  • 一个源文件的很多声明怎么放

直到后来我把它理解成“递归嵌套的列表对象”,整件事才一下通了。

所谓语法树,本质上不是:

每个节点往左右分叉

而是:

每个节点都带着自己的信息
每个节点下面再挂一个有序子节点列表
列表里的每个元素又继续是同样的节点

这套结构递归下去,才足以表示一个完整源文件。

7. 总结

教材拿 A + B 讲语法树,本身没有错。

错的是如果它没有及时补一句:

这只是一个二元表达式节点的局部形状,不是整棵语法树的通用结构。

一旦少了这句话,初学者就很容易把语法树误解成二叉树,或者误解成某种固定几叉树。

而真正能表示完整源码的,不是“固定叉数的树”。

而是:

节点对象 + 有序子节点列表 + 递归嵌套。

如果你非要我把这个理解再压短一点,那就是:

完整源码不是靠二叉分叉装进去的,而是靠嵌套列表装进去的。