02 词法与语法分析(前端)
本册目标:搞清楚源代码文本如何变成语法树——Lexer 如何扫描 Token、LanguageParser 如何用递归下降 + 优先级爬升建树、红绿树为何能做到编辑时 O(1) 复用、Syntax API 长什么样、语法错误如何恢复。
一、要点速览
- 词法:
Lexer(4785 行)逐字符扫描,产出SyntaxToken序列;空白与注释作为Trivia挂在 Token 上,不进语法树主体。 - 语法:
LanguageParser(11539 行)是手写递归下降分析器;二元表达式用优先级爬升(precedence climbing)而非传统算符优先文法表。 - 红绿树:语法树分两层——内部"绿节点"(
GreenNode,不可变、无父指针、全局缓存)与对外"红节点"(SyntaxNodefaçade,按需创建)。这是 IDE 高性能编辑的关键。 - 错误恢复:
SyntaxParser.EatToken系列在缺 Token 时插入"缺失节点"并继续解析,保证一棵完整(但不完美)的树总能产出。 - 所有节点的种类用
SyntaxKind枚举(ushort)标识,是贯穿前端的总开关。
二、词法分析(Lexer)
2.1 入口与文件
文件(src/Compilers/CSharp/Portable/Parser/) | 行数 | 职责 |
|---|---|---|
AbstractLexer.cs | — | 词法器骨架(缓冲区、位置管理) |
Lexer.cs | 4785 | C# 词法器主体:普通 Token、指令(#if…)、XML 文档注释 |
QuickScanner.cs | 433 | 快速路径:常见 Token(标识符、运算符)的免分配扫描 |
2.2 主循环:Lex()
Lexer.cs:235,词法器按"模式"分派(普通代码 / 预处理指令 / XML 注释的多种子模式):
publicSyntaxTokenLex(LexerModemode){_mode=mode;switch(_mode){caseLexerMode.Syntax:caseLexerMode.DebuggerSyntax:returnthis.QuickScanSyntaxToken()??this.LexSyntaxToken();// L245caseLexerMode.Directive:returnthis.LexDirectiveToken();}// ...XML 文档注释的各种模式(L250-276)}性能设计值得注意:普通代码 Token 先走QuickScanner.cs:193的QuickScanSyntaxToken()——一个手写的、只为"最常见 Token 形态"服务的快速路径,失败才回退到完整的LexSyntaxToken()(Lexer.cs:297)。这是编译器里典型的"快慢双路径"优化。
2.3 Token 的解剖:TokenInfo
Lexer.cs:84定义了扫描一个 Token 时的临时信息结构:
internalstructTokenInfo{internalSyntaxKindKind;// SyntaxKind.IdentifierToken / NumericLiteralToken / ...internalSyntaxKindContextualKind;// 上下文关键字(如 async)在语法阶段的"第二身份"internalstringText;internalstringStringValue;internalstringIdentifierName;...}ContextualKind值得记住:async、partial、var这类上下文关键字在词法阶段仍是普通标识符,只是额外登记了"可能是关键字"的候选身份,真正决策推迟到语法/绑定阶段。
2.4 Trivia:空白与注释的去处
语法树的主体只包含有语义的节点;空格、换行、注释、预处理指令统称Trivia,按规则挂到 Token 上:
- 一个 Token 最多带1 个 trailing trivia(本行内其后的空白/注释);
- 其后直到下一个 Token 之前的所有 trivia 都算它的leading trivia;
- 预处理指令(
#if、#pragma)也是结构化 trivia(StructuredTriviaSyntax),因此 IDE 折叠/着色能精确定位。
为什么这样设计?——①语法结构不受排版影响,树可以任意格式化后再打印;②重构重写节点时 trivia 随 Token 走,最大程度保留用户格式。
三、语法分析(LanguageParser)
3.1 总入口
方法(Parser/LanguageParser.cs) | 行号 | 作用 |
|---|---|---|
ParseCompilationUnit() | 298 | 整个文件入口 →CompilationUnitSyntax |
ParseStatement() | 6464 | 语句分派(if/for/while/表达式语句…) |
ParseSubExpression(Precedence) | 8889 | 一切表达式的总入口 |
LanguageParser继承自SyntaxParser.cs(1111 行),后者封装了"从词法器流式取 Token"的基础设施:EatToken()(SyntaxParser.cs:435)、PeekToken、前看缓冲等。
3.2 递归下降 + 优先级爬升
C# 的语句/声明部分是典型的手写递归下降(一个ParseStatement大 switch)。表达式则采用优先级爬升,核心在LanguageParser.cs:8724的Precedence枚举:
enumPrecedence:uint{Expression=0,// 最宽松,接受一切表达式Assignment,Lambda=Assignment,// "=> 与赋值同级、右结合"(语言规范的直接体现)Ternary,Coalescing,ConditionalOr,ConditionalAnd,LogicalOr,LogicalXor,LogicalAnd,Equality,Relational,Shift,Additive,Mutiplicative,Unary,Cast,PointerIndirection,AddressOf,Primary_UNUSED,// 基础表达式按 ad-hoc 方式解析}运算符 → 优先级的映射在GetPrecedence(SyntaxKind)(L8748)。解析循环(ParseSubExpressionCore,L8901)的工作方式:
- 先解析一个操作数(基础表达式:字面量、标识符、括号…);
- 看下一个 Token 是什么运算符,查
GetPrecedence得newPrecedence; - 若
newPrecedence高于(或同级且右结合)当前precedence,就把刚解析的操作数当左操作数,递归ParseSubExpression(newPrecedence)解析右侧(L9052–9057 的BinaryExpression分支即此); - 循环直至遇到优先级不够的运算符,返回。
读代码建议:在ParseSubExpressionCore(L8901)打断点,编译a = b + c * d,单步看优先级如何驱动结合方向——这是理解本节最快的方式。
3.3 为什么不用语法生成器(ANTLR/Yacc)?
- C# 语法里有大量上下文依赖(泛型 vs 比较运算符的歧义、查询表达式、模式匹配),手写代码能自然表达"试试看"式的回溯(
isDeclaration试探等); - 错误恢复策略需要精细控制(见 3.4),生成器难以定制;
- 性能:手写解析器可以针对热路径做
QuickScanner这类特化。
3.4 错误恢复:残缺输入也要长出完整的树
语法错误的处理原则是绝不抛异常中断,而是:
EatToken(SyntaxKind kind)(SyntaxParser.cs:463/518):期望的 Token 不在时,产出一个IsMissing的占位 Token 并报告诊断(如 CS1002 应输入;),解析继续;- 坏 Token 被"吞掉"并挂为 skipped trivia,使后续正确代码仍能进入树;
- 因此任何乱码输入都能得到一棵结构完整(局部带 Omitted/Skipped 节点)的语法树——这是 IDE 在残缺代码上仍能提供补全/绑定的前提。
四、红绿树:Roslyn 最著名的工程决策
4.1 两层节点
| 层 | 基类 | 特征 |
|---|---|---|
| 绿节点(内部) | GreenNode(src/Compilers/Core/Portable/Syntax/GreenNode.cs:17,internal abstract) | 不可变;没有父指针;只存宽度不存绝对位置;引用相等即结构相等(配合节点缓存) |
| 红节点(对外 API) | SyntaxNode(src/Compilers/Core/Portable/Syntax/SyntaxNode.cs:22,public abstract partial) | 持父指针与绝对位置(Span);按需从绿节点包装生成,本身轻量 |
C# 特化的红节点基类是CSharpSyntaxNode(Syntax/CSharpSyntaxNode.cs:25):
publicabstractpartialclassCSharpSyntaxNode:SyntaxNode,IFormattable绿节点版本在Syntax/InternalSyntax/(如InternalSyntax/SyntaxToken.cs475 行),解析器(Parser)全程只操作绿节点;你用 API 拿到的SyntaxNode都是事后包装的红 façade。
4.2 为什么这样能 O(1) 复用
树不可变 + 绿节点无父指针 → 任何未修改的子树在"编辑后新树"里被原样共享:
编辑前: CompilationUnit ─ classDecl ─ methodDecl ─ body ─ stmt1, stmt2 编辑 stmt2: CompilationUnit'─ classDecl' ─ methodDecl'─ body' ─ stmt1(共享), stmt2'(新)ReplaceNode只需沿着路径新建 O(树高) 个绿节点,其余全部复用;配合Microsoft.CodeAnalysis.CSharp的节点缓存(InternalSyntax/CSharpSyntaxNodeCache.cs),常见小子树(短标识符、小字面量)甚至是跨编辑共享的。IDE 每个按键触发一次全量"重建语法树"因此变得廉价——这是 Roslyn 能支撑现代 IDE 体验的基石。
红节点则是另一重缓存:parent、Span等派生信息只在被查询时才计算。记住口诀:绿管结构(不可变、可共享),红管导航(父指针、位置)。
4.3 SyntaxKind:前端的总开关
Syntax/SyntaxKind.cs:6:
publicenumSyntaxKind:ushort用ushort而非int:每个绿节点都要存一个 Kind,内存减半在百万节点的树上很可观。树里所有"种类"——SyntaxKind.ClassDeclaration、SyntaxKind.IdentifierToken、SyntaxKind.WhitespaceTrivia——共用这一个枚举。
五、Syntax API 使用者视角速写
读懂前两节后,这些 API 的行为就有了着落:
vartree=CSharpSyntaxTree.ParseText(@"class C { void M() { } }");varroot=(CompilationUnitSyntax)tree.GetRoot();// 红节点入口varcls=root.Members.OfType<ClassDeclarationSyntax>().Single();varm=cls.Members.OfType<MethodDeclarationSyntax>().Single();m.Span// 绝对位置(红节点派生)m.Identifier// SyntaxToken:绿色数据的红包装m.Body.OpenBraceToken.LeadingTrivia// 挂在 { 上的前导 triviavarm2=m.WithIdentifier(SyntaxFactory.ParseToken("N"));// 产生新树,旧树不变遍历/改写基础设施(全部由红绿树结构免费获得):
CSharpSyntaxWalker(Syntax/CSharpSyntaxWalker.cs)—— visitor 遍历;CSharpSyntaxRewriter(Syntax/CSharpSyntaxRewriter.cs)—— visitor 改写(返回新树)。
六、实验:语法树转储
用本地构建产物(roslyn-src/Binaries/Debug/Dlls/,或main/packages中被覆盖过的 2.10 包)写一个 30 行的控制台程序:
// 引用 Microsoft.CodeAnalysis.dll 与 Microsoft.CodeAnalysis.CSharp.dllvartree=CSharpSyntaxTree.ParseText("class C { int x = a + b * c; }",CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest));tree.GetRoot().WriteTo(Console.Out);// 打印规范化源码(Trivia 被剥离的体现)foreach(vartokintree.GetRoot().DescendantTokens())Console.WriteLine($"{tok.Kind()}'{tok.Text}' leading={tok.LeadingTrivia}");观察三件事:①WriteTo输出与原文的空白差异(Trivia 不属于结构);②a + b * c的树形(BinaryExpression(Add, a, BinaryExpression(Multiply, b, c)));③每个 Token 的 trivia 分布。把LanguageVersion换成CSharp5再跑,体会语言版本在语法层就生效(某些语法直接报错)。
七、代码位置索引
| 内容 | 位置(相对roslyn-src/src/Compilers/CSharp/Portable/) |
|---|---|
Token 信息结构TokenInfo | Parser/Lexer.cs:84 |
词法主入口Lex() | Parser/Lexer.cs:235 |
完整扫描LexSyntaxToken() | Parser/Lexer.cs:297 |
快速路径QuickScanSyntaxToken() | Parser/QuickScanner.cs:193 |
语法总入口ParseCompilationUnit() | Parser/LanguageParser.cs:298 |
语句分派ParseStatement() | Parser/LanguageParser.cs:6464 |
表达式入口ParseSubExpression() | Parser/LanguageParser.cs:8889 |
| 优先级枚举 / 映射 | Parser/LanguageParser.cs:8724/:8748 |
取 Token 基础设施EatToken系 | Parser/SyntaxParser.cs:435-533 |
绿节点基类GreenNode | ../Core/Portable/Syntax/GreenNode.cs:17 |
红节点基类SyntaxNode | ../Core/Portable/Syntax/SyntaxNode.cs:22 |
| C# 红节点基类 | Syntax/CSharpSyntaxNode.cs:25 |
| C# 绿 Token | Syntax/InternalSyntax/SyntaxToken.cs |
| 节点缓存 | Syntax/InternalSyntax/CSharpSyntaxNodeCache.cs |
SyntaxKind枚举 | Syntax/SyntaxKind.cs:6 |
| 遍历/改写器 | Syntax/CSharpSyntaxWalker.cs、Syntax/CSharpSyntaxRewriter.cs |
| 语法树实现(红 façade 总目录) | Syntax/CSharpSyntaxTree.cs及同目录各*Syntax.cs |
下一册:03-符号与语义绑定.md——语法树只回答"长什么样",绑定阶段回答"是什么意思"。