Roslyn 的两棵树:语法树与操作树
Roslyn 的两棵树:语法树与操作树
一个记录"代码长什么样",一个记录"代码是什么意思"。
用 Roslyn 做代码分析或者编译器开发,你绕不开两棵树:语法树(Syntax Tree)和操作树(IOperation Tree)。初学者最容易踩的坑就是把它们混在一起用——该看语法的地方拿去查语义,该做语义决策的地方却对着语法节点硬猜。
这篇文章不是 Roslyn 的 API 文档,而是我在做 Jazor(一个把 C# 编译成 JavaScript 的编译器)的过程中,反复跟这两棵树打交道之后的一些理解。如果你也在做类似的代码分析或编译器工作,希望能帮你少走一些弯路。
一、语法树:全保真的源码快照
语法树是 Roslyn 对源代码做词法和语法分析之后产出的那棵树。它的特点是完全保真——源码里每一个字符、每一个空格、每一个注释、每一个换行,在语法树里都有对应的节点或者 Trivia。
拿这段代码举例:
// 计算平方
public static int Square(int x)
{
return x * x;
}
这棵语法树大致长这样:
MethodDeclaration
├── LeadingTrivia: "// 计算平方\r\n"
├── Keyword: "public"
├── Keyword: "static"
├── ReturnType: "int"
├── Identifier: "Square"
├── ParameterList
│ └── Parameter
│ ├── Type: "int"
│ └── Identifier: "x"
├── Body
│ └── ReturnStatement
│ ├── Keyword: "return"
│ └── BinaryExpression
│ ├── Left: IdentifierName "x"
│ ├── Token: "*"
│ └── Right: IdentifierName "x"
注意两个细节:
第一,注释也在树里。// 计算平方 作为 LeadingTrivia 附着在方法声明上。语法树不丢弃任何源文本信息。
第二,一切按源码顺序排列。public 在 static 前面?树里也是。参数列表的写法?原样保留。语法树就像一张结构化的照片,像素级别的忠诚。
什么时候用语法树
语法树适合回答"结构性问题":
- 这个文件里有哪些类、哪些方法?
- 某个方法的参数叫什么名字,写在什么位置?
- 这行代码在源文件的第几行第几列?
- 重构时,我要删掉哪个节点、插入到哪里?
一句话:你需要知道代码"长什么样"的时候,去找语法树。
二、操作树:语义绑定后的"意义"树
语法树告诉你代码的形态,操作树告诉你代码的意义。
拿同一段代码 x * x 来说。在语法树里它就是一个 BinaryExpression,左边是 x,运算符是 *,右边是 x。但 x 到底是什么类型?* 到底是整数乘法、浮点乘法还是用户自定义的运算符重载?乘法表达式的结果类型又是什么?
这些在语法树里全都不知道。
操作树不一样。Roslyn 在生成操作树之前,会先走完一整套语义分析流程:
- 符号绑定:
x是哪个变量?它声明在哪里?类型是什么? - 重载决议:如果
*有多个重载,哪一个被选中了? - 类型推断:返回值的类型是什么?有没有隐式转换?
- 扩展方法展开:那个看起来像实例方法的调用,实际上是不是扩展方法?
全做完之后,x * x 在操作树里就变成了:
IBinaryOperation (BinaryOperatorKind.Multiply)
├── Type: System.Int32
├── Left: IParameterReferenceOperation
│ ├── Symbol: "x" (parameter)
│ └── Type: System.Int32
└── Right: IParameterReferenceOperation
├── Symbol: "x" (parameter)
└── Type: System.Int32
每一个节点都带着确切的类型信息和确切的符号引用。你再也不用自己猜 x 是什么——操作树已经帮你查好了。
什么时候用操作树
操作树适合回答"语义性问题":
obj.Method()到底调的是哪个重载?是基类方法还是派生类重写?x + y的结果类型是什么?有没有发生隐式转换?- 这个
foreach循环遍历的是数组、IEnumerable<T>还是自定义集合类? - 模式匹配
x is { Length: > 0 }展开之后到底是什么样的条件检查?
一句话:你需要知道代码"是什么意思"的时候,去找操作树。
三、两棵树的关系:一座桥
语法树和操作树不是两个平行的世界。它们之间有一条明确的路:
语法树节点 → SemanticModel.GetOperation() → 操作树节点
SemanticModel 就是那座桥。你用 Compilation.GetSemanticModel(syntaxTree) 拿到它,然后对任意语法树节点调用 GetOperation(),就能拿到对应的操作树节点。
但这里有两点要注意。
第一,不是每个语法节点都有对应的操作节点。 比如 if 语句的 if 关键字本身——它在语法树里是一个 token,但你没法对它调用 GetOperation(),因为关键字不产生语义操作。只有那些真正"做事情"的语法结构(方法体、表达式、语句)才有对应的 IOperation。
第二,操作树节点不直接持有语法引用,但它保留了语法位置。 你可以在 IOperation 上通过 operation.Syntax 回到语法树节点,但这个引用主要是用来定位源码位置的(比如报错时指出"第几行第几列"),不应该用来做语义决策。如果你发现自己在操作树遍历的过程中频繁通过 .Syntax 回到语法树去查东西,那通常意味着你走错了方向——你应该通过操作树节点自身的类型系统和符号引用来做判断。
四、两棵树的核心区别:一张表
| 语法树 | 操作树 | |
|---|---|---|
| 来源 | 词法 + 语法分析 | 符号绑定 + 语义分析 |
| 保真度 | 完全保真(含注释、空格、格式) | 丢弃 Trivia,只保留语义 |
| 类型信息 | 只知道语法上的类型标注 | 每个节点都有确切的 Type |
| 重载决议 | 不知道调了哪个重载 | 已经做完,指向具体方法符号 |
| 隐式转换 | 没有 | 节点之间会插入 IConversionOperation |
| 控制流 | 有 if、for 结构 |
有 IConditionalOperation、ILoopOperation 等语义等价 |
| 可变性 | 不可变,但是可以创建新树(With* 方法) |
不可变 |
| 典型用途 | 代码格式化、重构、语法检查 | 代码生成、语义分析、编译器 lowering |
有一件事值得单独拿出来说:操作树不关心代码的写法,只关心代码的效果。 比如:
// 写法一
var result = list.Where(x => x > 0).Select(x => x * 2);
// 写法二:查询表达式
var result = from x in list where x > 0 select x * 2;
在语法树里,这两种写法完全不同——一个是方法调用链,一个是查询表达式。但在操作树里,它们会生成完全一样的 IOperation。因为 Roslyn 在语义分析阶段会把查询表达式 Lower 成方法调用,所以操作树拿到的是底层等价形式。
这也是操作树在做编译器时的巨大优势:你不需要处理语法的各种写法变体,你只需要处理一种规范的语义形式。
五、在 Jazor 里,我是怎么用这两棵树的
Jazor 的核心工作是把 C# 代码编译成 JavaScript。这是典型的"从一个语言的语义生成另一个语言的代码",所以我的主路径自然落在操作树上。
具体来说,项目里有两条线各司其职:
AstConverter —— 管模块结构,用语法树。
AstConverter 负责把一个 C# 类的整体结构翻译成 JavaScript 模块。它要回答的问题是:这个类有哪些字段、哪些方法、哪些成员类?哪些是 public(需要 export)?方法的参数叫什么名字?
这些问题都是结构性的,不涉及语义。静态字段变成 let 声明,静态方法变成 function 声明——这个映射不需要知道方法体里具体做了什么,只需要知道方法存在、叫什么名字、有哪些参数。所以 AstConverter 里大量使用 foreach (var member in classSymbol.GetMembers()) 和 switch (member) 这种基于语法符号的模式来分类处理。
// AstConverter 中的典型模式:基于语法结构做分发
switch (member)
{
case IFieldSymbol field:
await ConvertModuleField(members, field, cancellationToken);
break;
case IMethodSymbol func:
await ConvertModuleMethod(members, func, cancellationToken);
break;
case INamedTypeSymbol @class when IsRuntimeMemberClass(@class):
AppendModuleClass(members, @class, ...);
break;
}
SemanticWalker —— 管方法体逻辑,用操作树。
一旦进入方法体内部,事情就变了。方法体里是一系列语句和表达式,我需要知道每一个操作具体在干什么——这个变量赋值的类型是什么?那个方法调用解析到了哪个重载?foreach 在遍历什么类型的集合?中间有没有隐式转换?
这些问题语法树回答不了。所以我在这里换操作树:
// 从语法节点拿到语义模型,再拿到操作树
var operation = GetSemanticModel(blockSyntax).GetOperation(blockSyntax);
var walker = new SemanticWalker(classSymbol, ModuleDeclaredNames, cancellationToken);
var argument = CreateImportAwareArgument(Sense.FunctionBody);
// SemanticWalker 遍历 IOperation,产出 ESTree AST
walker.Visit(operation, argument);
SemanticWalker 继承自 OperationVisitor,这是 Roslyn 提供的一个访问者基类。它会为每一种操作类型(比如 IInvocationOperation、IBinaryOperation、IConditionalOperation)调用对应的 Visit* 方法,我在这些方法里把 IOperation 翻译成 JavaScript 的 AST 节点。
为什么这个分工是对的?
因为模块结构的"维护成本"低——类、方法、属性的声明方式就那么几种,语法树直接告诉你是什么就够了。而方法体语义的"复杂度"高——同一个 foreach 在操作树里可能是数组遍历、IEnumerable<T> 遍历或者自定义迭代器遍历,三种情况需要完全不同的 JavaScript 输出。让语法树去区分这些,你得自己重新实现一遍 Roslyn 的绑定逻辑,那是不合理的。
分层的收益是,每一层只做自己该做的事。 AstConverter 不需要理解 foreach 的各种语义变体,它只需要知道"这个方法有个 body,交给 SemanticWalker 处理"。SemanticWalker 不需要关心模块怎么组装、import 怎么排列,它只需要把 IOperation 准确地变成 JavaScript AST。
六、选哪棵树:方向感
说了这么多,其实可以总结成一个简单的判断框架。
向上走,看语法树。 你想知道源码是什么样子——格式、注释、声明的顺序、变量名拼写——语法树是唯一权威来源。格式化和重构工具几乎都在这一层工作。
向下走,看操作树。 你想知道源码在运行时会产生什么效果——调了哪个方法、类型怎么转换、异常可能从哪里抛出来——操作树是编译器替你做完所有脏活累活之后的成果。代码生成和分析工具应该在这一层工作。
如果你想写一个 Roslyn Analyzer(代码检查器),那你大概率两种树都会用到:用语法树的节点位置来定位问题,用操作树的类型和符号信息来判断问题是否真的存在。
如果你想写一个编译器或者转译器(就像 Jazor 那样),那操作树是你的主力输入。不要试图基于语法树做语义翻译——你会发现自己在一个一个地重新实现重载决议、类型推断、隐式转换检测,而这些东西 Roslyn 已经做好了。
七、一个容易忽略的点:两棵树都是不可变的
语法树和操作树有一个共同的设计约束:不可变。你不能拿到一个节点然后修改它的属性——node.Type = ... 这种写法压根不存在。
这不是说"你改不了树"。你可以改,但方式是通过创建新的节点来替换旧的。语法树有 With* 系列方法(比如 node.WithExpression(...) 返回一个新节点),操作树则根本没有修改 API——你只能遍历和读取。
这个设计对编译器开发的影响是:你不能"边遍历边改树"。如果你需要做 lowering(比如把 foreach 展开成 for),你必须在遍历老树的同时构造一棵全新的目标 AST,而不是在老树上直接替换。Jazor 的 SemanticWalker 就是这么做的——它遍历 IOperation,输出的是全新的 ESTree 节点,跟原来的操作树没有任何共享结构。
八、小结
Roslyn 的两棵树,本质上对应了两个不同的"视角":
- 语法树是你的眼睛看到的源码——包含所有格式细节,忠实记录每一个 token、每一个空格、每一个注释。
- 操作树是编译器的大脑理解的语义——绑定符号、决议重载、推断类型、识别模式之后的"净化版本"。
初学 Roslyn 的时候,很容易一上来就去翻语法树做所有事情。但随着项目变复杂,你会越来越频繁地需要操作树——因为它把你从"猜代码在干什么"的困境里解放出来。
在 Jazor 里,模块结构和源位置由语法/符号信息辅助处理,方法语义由 IOperation 驱动 SemanticWalker lowering。RazorVue 还通过官方 Razor Source Generator 完成后的 final Compilation 接入这条语义管线;它不读取 Razor IR,也不重新解析生成的 C# 文本。
如果你也在用 Roslyn 做什么有趣的事情,欢迎去 Jazor 的仓库 看看,或者直接来讨论。

浙公网安备 33010602011771号