1. 诊断信息的设计
一句话总结: 诊断的职责不是报错而是帮助修复,位置、代码帧、严重级别与可操作的建议共同决定诊断质量。
诊断信息(diagnostic)是编译器与开发者最重要的沟通界面。一条好的诊断包含四要素:精确的位置(文件、行、列、跨度)、出错的代码片段(带指示器)、简洁的说明文字(预期与实际),以及(理想情况下)修复建议。位置必须指向真正的错误源头而非症状——1 + "a" 的错误应指向 "a" 而非整个表达式。Rust 的 rustc、Swift 的编译器都以诊断质量著称,并为此建设了专门的渲染层。
error[E0308]: mismatched types
--> src/main.rs:4:18
|
4 | let y: u32 = x + 1.5;
| ^ expected `u32`, found `f64`
|
help: use `as` to cast if you intended the result
|
4 | let y: u32 = (x + 1.5) as u32;
| ~~~~~~~~~~~~~~~
@dataclass
class Span:
file: str
line: int
col: int
length: int
@dataclass
class Diagnostic:
severity: str # error / warning / note
code: str # E0308
message: str
span: Span
suggestions: list[str] = field(default_factory=list)
def render(diag, source):
line = source[diag.span.line - 1]
caret = " " * (diag.span.col - 1) + "^" * diag.span.length
return (f"{diag.code}: {diag.message}\n"
f" --> {diag.span.file}:{diag.span.line}:{diag.span.col}\n"
f"{diag.span.line:>4} | {line}\n"
f" | {caret}")
| 维度 | 差诊断 | 好诊断 |
|---|---|---|
| 位置 | 指向整行 | 精确到字符跨度 |
| 说明 | 只说「类型错误」 | 说明预期与实际 |
| 建议 | 无 | 给出可应用的补丁 |
| 关联 | 孤立一条 | 附带相关位置的 note |
诊断设计的核心原则是「一次只聚焦一个问题,但尽量收集所有问题」。错误信息中的措辞也有讲究:避免「非法」「无效」这类空泛词,改说「这里的 f64 与预期的 u32 不匹配」并给出证据链。诊断基础设施(Span、Diagnostic、渲染器)应在编译器早期就建好,后续所有阶段——词法、语法、语义、优化——共用同一套框架。
2. 错误恢复策略
一句话总结: 错误恢复让语法分析器在出错后跳到一个可同步点继续,争取一次编译报告尽量多的错误,而不是「一个错就停」。
语法分析器遇到非法 token 时不能直接崩溃,否则一次编译只能报一个错误。错误恢复(error recovery)的策略分几档:最简单是 panic mode——跳过 token 直到分号、右大括号等同步点再继续;其次是「插入/删除/替换」的修补(如缺分号时自动补);复杂系统还会把错位 token 重新对齐到最近的合法位置(如 rustc 用「错误类型驱动」的恢复)。恢复后要继续给出正确的语法树,供语义分析继续工作。
class ErrorRecoveringParser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
self.errors = []
def sync(self, stop_set):
"""panic 模式: 跳过直到同步 token。"""
while self.pos < len(self.tokens):
t = self.tokens[self.pos]
if t.kind in stop_set or t.kind == "EOF":
return
self.pos += 1
def expect(self, kind):
tok = self.peek()
if tok.kind == kind:
self.pos += 1
return tok
self.errors.append(
Diagnostic("error", "E0001", f"expected {kind}",
tok.span))
self.sync({";", "}", "EOF"}) # 跳到同步点
return None # 返回占位节点
| 恢复策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Panic 模式 | 跳到同步点 | 简单稳健 | 可能漏报中间错误 |
| 单 token 删除 | 删掉非法 token | 精细 | 可能连锁误删 |
| 补全修补 | 插入缺失 token | 恢复最自然 | 易产生幻影 |
| 错误产生式 | 为常见错造规则 | 精准 | 规则膨胀 |
恢复策略与语言文法强相关:C 家族用分号与右花括号同步,Python 用换行与缩进同步,嵌套括号语言需要配平括号的恢复。恢复得好的编译器会「一路跌跌撞撞却继续前进」,报告十几个错误;恢复得差的会从第一个错误开始连锁误报,把开发者淹没在噪声里。工程上常用「错误预算」——一个区域内错误太多就整体跳过,防止虚假错误滚雪球。
3. 语义错误与静态检查
一句话总结: 语法恢复只是第一步,类型错误、未定义名字与不可达代码等语义问题同样需要恢复与分级,让一次编译暴露尽量多问题。
语法之外还有语义错误:未定义变量、类型不匹配、重复定义、不可达代码。语义检查器面对错误代码时同样要「继续检查其余部分」,因此需要合理回退——类型推断失败时把类型降级为「错误类型」(error type),让下游检查继续而不连环报错;名字解析失败时在符号表里放一个「占位符号」,避免对同一名字重复报错。错误类型是编译器内部的特殊标记,任何与其交互都只产生一条主错误。
class ErrorType: # 特殊标记: 类型未知且已报错
_singleton = None
ERROR_TYPE = ErrorType()
class SemAnalyzer:
def __init__(self):
self.gamma = {}
self.errors = []
def visit_binop(self, node):
t1 = self.visit(node.left)
t2 = self.visit(node.right)
if t1 is ERROR_TYPE or t2 is ERROR_TYPE:
return ERROR_TYPE # 不叠加报错
if t1 != t2:
self.errors.append(Diagnostic(
"error", "E0308",
f"cannot add `{t1}` and `{t2}`", node.span))
return ERROR_TYPE
return t1
def visit_var(self, node):
if node.name not in self.gamma:
self.errors.append(Diagnostic(
"error", "E0425", f"undefined name `{node.name}`",
node.span))
self.gamma[node.name] = ERROR_TYPE # 占位, 去重
return self.gamma[node.name]
| 错误类型 | 恢复手段 | 目的 |
|---|---|---|
| 未定义名字 | 符号表占位 | 防重复报错 |
| 类型错误 | 返回 ErrorType | 让下游继续 |
| 重复定义 | 保留第一个 | 语义歧义最小化 |
| 不可达代码 | 跳过并给 warning | 不打断流程 |
静态检查还包括告警(warning)这类「合法但可疑」的信号:未使用变量、恒真条件、可疑的空指针解引用。编译器通常区分「错误阻止生成代码」与「告警不阻止」两级,有些语言(Rust)把部分告警提升为 deny 属性配置。语义恢复的核心工程原则是「错误类型传染但只报一次」——把错误标记传播开去保持分析流程完整,同时用 dedup 机制保证每个问题只有一条诊断。
4. lint 与静态分析
一句话总结: lint 在编译语义之外增加约定与质量规则,静态分析则用数据流与控制流查找缺陷,两者共享 IR 与基础设施。
lint 工具(Clang-Tidy、ESLint、rust-clippy)不是编译器,但深度复用编译器基础设施:词法、语法、AST、类型信息、控制流图。lint 规则覆盖风格(命名、格式)、正确性(可疑运算)、性能(无谓分配)与可维护性。静态分析(数据流分析、污点分析、符号执行)则更进一步,在 CFG 上传播信息查找真实缺陷——未初始化变量、空指针路径、资源泄漏。
# 一条 lint 规则的骨架: 匹配 AST 模式并产出诊断
class LintRule:
name = "no-constant-condition"
severity = "warning"
def check(self, node, ctx):
if (node.kind == "if"
and node.cond.kind == "bool_lit"):
ctx.report(
Diagnostic("warning", "C001",
"condition is always true/false",
node.cond.span))
# 数据流检查: 未初始化变量 (简化)
def uninitialized_use(cfg):
defined = set()
for block in cfg.blocks:
for stmt in block:
if stmt.is_def: defined.add(stmt.var)
elif stmt.var not in defined:
yield Diagnostic("warning", "C002",
f"`{stmt.var}` may be uninitialized",
stmt.span)
| 分析类别 | 例子 | 运行时机 |
|---|---|---|
| 风格 lint | 命名、缩进 | 单文件快查 |
| 类型推断检查 | 可疑隐式转换 | 语义分析后 |
| 数据流 | 未初始化、泄漏 | CFG 上迭代 |
| 污点分析 | 用户输入流入危险函数 | 整程序/跨函数 |
lint 与静态分析的工程要点是「宁可漏报,不可误报」:误报(false positive)会摧毁开发者对工具的信任,因此规则普遍偏向保守,且提供 suppress 机制(行内注释、配置文件)。现代做法是把 lint 规则与编译器诊断统一到同一套 Diagnostic 框架里,让 IDE、CI 与命令行共享渲染与过滤逻辑。clippy 之于 Rust、pyflakes 之于 Python,都是「编译器 IR + 规则集」的复利。
5. 编译器驱动与增量编译
一句话总结: 编译器驱动编排各阶段与缓存,增量编译复用上次的结果只重编变更部分,是大型工程编译体验的生命线。
编译器驱动(driver)负责编排:读取命令行、调度预处理/词法/语法/语义/优化/代码生成/汇编/链接、收集所有阶段的诊断、决定退出码。增量编译(incremental compilation)让驱动记忆上次构建的产物与依赖图,源码文件未变的部分直接复用缓存。rustc、Swift、GCC(通过 ccache/预编译头)都实现增量编译,其核心是文件指纹、依赖图(谁依赖谁)与按模块缓存中间产物。
class IncrementalDriver:
def __init__(self, cache_dir):
self.cache_dir = cache_dir
self.fingerprints = {} # file -> hash
def needs_rebuild(self, source_file):
new = hash_file(source_file)
old = self.fingerprints.get(source_file)
return new != old
def build(self, files):
dirty = [f for f in files if self.needs_rebuild(f)]
if not dirty:
return "up to date"
# 只重新编译变更文件, 未变者复用缓存的 .o
for f in dirty:
self.compile(f) # 产出缓存并更新指纹
self.link(files)
| 策略 | 缓存粒度 | 命中条件 |
|---|---|---|
| 文件指纹 | 单文件 .o | 文件内容未变 |
| 依赖图 | 模块 IR | 依赖链未变 |
| 查询缓存 | 单个编译查询 | 相同查询重复 |
| 分布式编译 | 远程 worker | 同指纹共享 |
增量编译的难点在依赖追踪:头文件/模块变化会传染给所有依赖者,因此编译器用细粒度依赖(哪个声明变了才失效)而非整文件失效。rustc 的增量编译按「查询(query)」粒度缓存——每个编译阶段都是一个可缓存查询,任何上游查询结果未变就直接复用下游。驱动还要处理并行(多线程编译模块)、缓存失效与调试信息的稳定性(未变部分不得引起二进制抖动)。
6. 修复建议与错误码
一句话总结: 诊断升级的方向是「不仅指出问题,还给出可落地的修复」,错误码与稳定的诊断 ID 让文档、抑制与自动修复可寻址。
好诊断的最后一公里是修复建议(suggestions)与稳定的错误码(error code)。rustc 的 --diagnostic-lint 系列、Swift 的 fix-it、ESLint 的 --fix 都把「建议」做成可自动应用的补丁——诊断携带一段替换文本,IDE 一键应用。错误码(如 E0308、TS2322)把诊断变成可检索的 ID:查文档、在 CI 里按码过滤、对特定码做 suppress 都有明确对象。
@dataclass
class FixIt:
span: Span
replacement: str
class Suggest:
"""常见错误的修复建议生成 (示例)。"""
def on_type_mismatch(self, found, expected, span):
if found == "f64" and expected == "u32":
return FixIt(span, f"({span.text} as u32)")
return None
def on_typo(self, name, candidates):
closest = min(candidates, key=levenshtein(name))
return FixIt(name_span, closest)
# 自动应用修复: 直接改源文本
def apply_fixits(source, diagnostics):
out = []
pos = 0
for diag in diagnostics:
fix = diag.fixit
if fix is None:
continue
out.append(source[pos:fix.span.start])
out.append(fix.replacement)
pos = fix.span.end
out.append(source[pos:])
return "".join(out)
| 机制 | 作用 |
|---|---|
| 稳定错误码 | 文档检索、CI 过滤、抑制 |
| fix-it 补丁 | 一键应用、IDE 建议 |
| 拼写建议 | 近似名候选(编辑距离) |
| lint 级别配置 | per-rule 开关与 deny 提升 |
修复建议要避免「自信的错误修补」:只对高置信场景给自动修复(类型转换、拼写纠正、漏写分号),低置信时只给提示不加补丁。错误码的分配要稳定且语义化——同一类错误永远同一码,版本迭代不能随意重编。成熟编译器的错误码体系几乎成为一种领域语言:开发者看到 E0308 就知道「类型不匹配,去查文档的该码页面」,而 lint 工具的 rule ID 则让团队可以在配置里统一开关与提权。
7. 与语言服务的集成
一句话总结: LSP 把编译器改造成语言服务,在 IDE 里提供实时诊断、补全、跳转与重构,增量编译与缓存是其底座。
语言服务器协议(LSP)让编译器能力进入 IDE:打开文件即得诊断、补全、定义跳转、引用查找与重命名。这要求编译器不仅能「批处理编译」,还能「按需增量分析」——编辑器每次击键都可能触发一次诊断刷新,因此查询式增量架构(rustc、GCC 的 libgccjit、基于树的 swift-frontend)成为语言服务的基础。Tree-sitter 等增量解析库让语法高亮与结构分析低延迟。
IDE → LSP JSON-RPC → 编译器语言服务
textDocument/didChange → 增量重新解析该文件
textDocument/publishDiagnostics → 实时诊断回推
textDocument/completion → 基于符号表与类型补全
textDocument/definition → 跳到定义 (含跨文件)
textDocument/rename → 作用域感知的重命名
# 概念: 增量重分析 + 缓存, 支撑 IDE 实时诊断
class LanguageService:
def __init__(self, compiler):
self.cache = {} # file -> (hash, parse_tree)
def on_change(self, uri, new_text):
if self.cache[uri][0] == hash(new_text):
return [] # 内容未变, 无新诊断
tree = self.compiler.parse(new_text)
self.cache[uri] = (hash(new_text), tree)
diags = self.compiler.analyze(uri, tree)
return diags # 推给编辑器
def completion(self, uri, pos):
tree = self.cache[uri][1]
scope = self.compiler.scope_at(tree, pos)
return [name for name in scope.symbols]
| 能力 | 依赖 | 延迟要求 |
|---|---|---|
| 实时诊断 | 增量解析 + 增量语义 | <100ms |
| 补全 | 符号表 + 类型 | <50ms |
| 跳转/引用 | 全工程索引 | 按需 |
| 重命名 | 作用域与引用图 | 秒级 |
语言服务集成把编译器从「批处理工具」变成「交互式助手」:同一套诊断框架、错误码与修复建议既在命令行也在 IDE 呈现,保证体验一致。代价是编译器要为「部分文件、部分工程」的分析负责——未打开的文件用缓存或 on-demand 索引。语言服务与增量编译共享的核心洞察是:编译的很多工作是可缓存、可复用的查询,而「正确失效」是这一切的成败关键。
8. 总结
一句话总结: 错误恢复与诊断把「编译失败」转化为「可操作的反馈」,恢复策略、诊断设计、lint、增量与语言服务共同构成开发者体验工程。
| 主题 | 核心结论 |
|---|---|
| 诊断设计 | 位置/代码帧/建议,一次聚焦一个问题 |
| 错误恢复 | panic 同步点 + 修补,一次编译报尽量多错 |
| 语义恢复 | ErrorType 传染但只报一次,占位去重 |
| lint 与静态分析 | 复用编译器 IR,宁漏报不误报 |
| 驱动与增量 | 指纹 + 依赖图缓存,只重编变更部分 |
| 修复建议 | fix-it 补丁与稳定错误码,低置信只提示 |
| 语言服务 | LSP 复用诊断,增量分析支撑实时体验 |
编译器的工程质量最终由开发者体验裁定,而体验的一半来自错误恢复与诊断。把 Span、Diagnostic、错误恢复、增量缓存与语言服务做成编译器的一等公民,而不是事后补丁,是成熟编译器(rustc、Swift、GCC)的共同路径。对语言实现者而言,「报好一个错误」与「生成正确代码」同等重要——因为绝大多数使用者接触编译器的第一面,就是诊断信息。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。