编译管线那篇提到 analyzer 是必经环节:不管你写不写类型标注,它都会跑完,产出是每个表达式的 GDScriptDataType。这篇细看 gdscript_analyzer.cpp 的实现策略。读完源码我最想分享的一个发现是:analyzer 的核心矛盾不是"推导出类型",而是"脚本成员之间允许任意顺序互相引用"——_ready 里可以调用定义在文件末尾的函数,成员变量初始化可以引用声明在后面的常量。传统单遍语义分析处理不了这种自由度,GDScript 的答案是两遍。
第一遍:只看"声明形状",不看"实现"
GDScriptAnalyzer::analyze() 的主干大致是:
// modules/gdscript/gdscript_analyzer.cpp, 4.2(删减)
Error GDScriptAnalyzer::analyze() {
resolve_class_inheritance(parser->get_root_node()); // ① 继承链
resolve_class_members(root); // ② 成员签名
resolve_class_body(root); // ③ 函数体
// ...
}
① 解析 extends 指向谁——基类可能是 Node 这样的原生类、另一个 .gd 脚本(preload 或 class_name)、或者 extends "res://foo.gd" 的字面路径。继承链必须最先确定,因为子类成员的类型环境依赖基类的完整接口。
② 遍历所有成员(变量、常量、信号、内嵌类、函数头),只建立签名,不检查函数体。var hp: int = compute() 在这一步记录的是"hp 是 int",右边表达式是什么东西留到第三遍。这一步解决的是前面说的自由度:到函数体检查开始时,类里所有名字的类型都已经在表里了,先写后写无所谓。
③ 逐函数检查函数体。这才是通常语义分析的重头戏。
为什么要强制拆开?考虑一个自引用结构:
var next: Node = null
func find_tail():
var n: Node = self # 用到了自己
while n.next != null: # 用了成员
n = n.next
return _default_depth() # 用了定义在后面的函数
const DEFAULT_DEPTH = 3
func _default_depth():
return DEFAULT_DEPTH # 用了定义在后面的常量
如果按书写顺序单遍分析,find_tail 检查时 _default_depth 和 DEFAULT_DEPTH 还不存在,只能报"未定义标识符"——但用户视角里它们明明定义了,只是写在后面。两遍方案里第一遍先把 {next: Node, find_tail: () -> Variant, DEFAULT_DEPTH: int, ...} 全部登记进符号表,第二遍随便按什么顺序查表都命中。用空间换顺序自由度,这是脚本语言"不强迫你关心声明次序"的代价和它的实现方式。
第二遍:自底向上推导
函数体检查是递归下降的 reduce_* 家族:reduce_binary_op 先归约左右子表达式拿到各自 DataType,查运算表(int + int -> int)得出结果类型;reduce_call 先归约参数,再解析被调函数的签名做参数匹配。类型信息从叶子流向根,AST 每归约完一个节点就贴上类型标签。
推导的边界值得讲清楚,因为它解释了"为什么有时候明明能推出来却报 unknown":
- 不跨函数边界。一个函数没有返回类型标注时,analyzer 不会为了调用方去分析它的返回值——调用结果就是
Variant。返回类型标注是跨函数推导唯一能依靠的桥梁。 - 变量声明时定终身。
var x = 1之后x = "hello"在开启类型推断的项目里会报错:x的类型在声明处定为int,之后不变。没有Any逃逸舱,只有不写标注时的全动态。 - 分支收窄不存在。
if x is Sprite2D:内部访问x的精灵属性,analyzer 不会把x窄化成Sprite2D——类型系统在 if 内部不重新推导。这是和有 flow-sensitive typing 的语言(TypeScript、Kotlin)的本质差距,抱怨之前先知道它是设计选择:GDScript 的 analyzer 只做声明点类型,不做控制流传播,实现成本差一个量级。
这些边界合起来回答了"标注为什么快"的机制链,这里补全成完整闭环:标注 → 声明点类型确定 → 属性访问 a.b 编译成 GET_NAMED_VALIDATED,方法调用编译成直接 MethodBind* 调用 → VM 跳过逐次动态查找。而未标注的 a.b 在运行期要经历:查 a 的运行时类型 → 查该类 method_map/property_map(StringName 哈希)→ 拿到 setter/getter → 调用。省了哈希查找和动态分派,热循环里就是数倍的差距——"快一倍"是保守说法,纯属性访问场景下十倍也不稀奇。
typed array:少数的动态性保险
Array[int] 是 4.x 类型系统里设计得最克制的特性。它解决的具体问题是:动态 Array 装进任何东西,引擎无法假设元素类型,foreach 迭代时每个元素都要走完整 Variant 通道;而 Array[int] 在 analyzer 里是独立类型,append 时插入类型检查,迭代时编译器敢直接生成 ITERATE_BEGIN_INT 一类的整数快路径。
代价是写放大:往 Array[int] 里塞一个运行期才知道类型的值,会触发运行时检查甚至隐式转换失败。它在"序列化读进来的数据"和"代码内部的热路径"之间划了线——前者用动态 Array 加显式转换,后者用 typed array 换速度。
警告系统:半静态化的活口
静态类型在 GDScript 里不是编译错误体系,而是警告体系。UNSAFE_PROPERTY_ACCESS、UNSAFE_METHOD_ACCESS、UNSAFE_CAST 这些警告标记的是"这里 analyzer 推不出确定类型,运行期才能查"的位置。引擎自己的代码规范(以及默认的项目设置)允许 warning 存在,但把警告输出当检查清单用:一个文件里 UNSAFE 警告密集出现,等价于 profiler 告诉你"这段代码大部分操作都要走动态通道"。类型化迁移的标准流程就是迭代地消灭警告,每灭一批测一次帧时间。
把 analyzer 的整体设计收拢成一句话:它用两遍遍历买来了脚本的书写自由,用声明点类型换来了 VM 的快路径,用警告体系保留了动态性的逃生门。这三笔交易都不亏,亏的是以为"不写类型也没区别"——区别不在语法糖,在编译出来的指令本身。
本文代码引自 Godot 4.2.2-stable 的 modules/gdscript/gdscript_analyzer.cpp,函数名与调用顺序有删减;推导边界描述适用于 4.x 全部版本。