8.1 静态类型检查与动态类型检查
类型系统首先回答的不是“这门语言酷不酷”,而是一个很务实的问题:哪些程序事实能在运行前确认,哪些事实必须等值真正流过某个操作时才能检查?静态类型检查尝试在执行前拒绝不合法程序。动态类型检查则把一部分检查推迟到运行时,例如值真正参与 +、成员访问或函数调用边界时再验证。
这两个词本身不等于“先进”或“落后”。真正的差别在于:错误何时暴露、责任落到哪里、编译器在前端阶段到底握有多少可靠事实。
| 问题 | 静态检查 | 动态检查 |
|---|---|---|
| 错误何时发现 | 运行前 | 运行中 |
| 哪些事实能更早支持优化 | 类型更早已知 | 更多事实要等运行时出现 |
| 前端掌握什么 | 明确的类型事实 | 运行前保证较少 |
| 失败方式 | 编译期拒绝 | 运行时抛错或陷阱(trap) |
尽早拒绝会改变整个流水线
看这样一段程序:
let x: int = "hi"
print(x + 2)在静态系统里,它在代码生成之前就已经出问题了。类型检查器知道 x 被声明成 int,也知道 "hi" 是 string,所以能立刻拒绝这次赋值。这件事很重要,因为后面的阶段不必再猜 x + 2 是否有意义。常量折叠、重载解析、IR 降低都会因此得到更强的前提。
在动态系统里,赋值本身可能先通过,因为变量可以持有带运行时标签的值。真正的错误要等执行流走到 x + 2 时才出现,运行时才会发现 string + int 在这门语言里不合法。程序甚至可能先跑一段,再在某个具体输入下失败。
错误责任落点也是设计的一部分
类型检查不仅是“发现错”,还包括“把错归到最有修复价值的位置”。
例如 takesInt("x")。静态检查通常会把主错误指向调用点,因为它已经同时知道形参与实参的类型。动态检查则可能把错误落在函数入口,因为运行时合同是在函数真正接收到参数时才执行的。两者都说得通,但它们给开发者带来的调试体验并不一样。
同样的差别也会出现在条件分支和成员访问里:
| 程序形状 | 静态系统常见归责(blame) | 动态系统常见归责 |
|---|---|---|
| 参数类型不对 | 调用点 | 被调用函数边界 |
| 分支结果类型不一致 | 分支汇合点 | 后续消费该结果的位置 |
| 字段不存在 | 成员访问点 | 运行时属性查找 |
这也是为什么静态系统的报错常常更早、更窄。因为编译器更早拥有事实,所以能更靠近根因出手。
可达性(reachability)与灵活性的交换
动态检查有一个很现实的优点:如果某段代码永远没有执行到,它可能也永远不会触发那里的类型错误。这对反射式系统、脚本环境、插件机制,或者刻意强调运行时灵活性的语言来说很有吸引力。但代价是,有些错误会活得更久,只会在特定输入和特定路径下暴露。
静态检查则相反。它不等路径真的发生,就先对程序结构做推理。这样通常更有利于重构安全、编辑器能力和基于类型的优化,但它也可能拒绝一些“运行到某些路径时其实不会出事”的程序。
所以更好的比较方式不是“谁更聪明”,而是“哪一层应该为这个承诺负责”。如果语言想把保证前移,就让前端承担更多责任;如果语言想保留运行时开放性,就把更多检查留到执行阶段。
一个边界例子
看下面这段代码:
if debug {
takesInt("x")
}如果运行时 debug 为 false,动态系统可能永远不抱怨。静态系统仍会拒绝它,因为这条错误调用已经出现在程序文本里。这里没有谁“算错了”,只是策略不同:一个是在看程序结构,一个是在看真正走到的路径。