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 附着在方法声明上。语法树不丢弃任何源文本信息。

第二,一切按源码顺序排列publicstatic 前面?树里也是。参数列表的写法?原样保留。语法树就像一张结构化的照片,像素级别的忠诚。

什么时候用语法树

语法树适合回答"结构性问题":

  • 这个文件里有哪些类、哪些方法?
  • 某个方法的参数叫什么名字,写在什么位置?
  • 这行代码在源文件的第几行第几列?
  • 重构时,我要删掉哪个节点、插入到哪里?

一句话:你需要知道代码"长什么样"的时候,去找语法树。

二、操作树:语义绑定后的"意义"树

语法树告诉你代码的形态,操作树告诉你代码的意义

拿同一段代码 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
控制流 iffor 结构 IConditionalOperationILoopOperation 等语义等价
可变性 不可变,但是可以创建新树(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 提供的一个访问者基类。它会为每一种操作类型(比如 IInvocationOperationIBinaryOperationIConditionalOperation)调用对应的 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 的仓库 看看,或者直接来讨论。

posted @ 2026-05-11 13:30  形而下晓寒  阅读(23)  评论(0)    收藏  举报