文档里 lambda 捕获只有一句话:"局部变量按值捕获"。这句话字面上对,实操上不够——因为 GDScript 里"值"的语义由 Variant 决定,而 Variant 对不同类型的"拷贝"是三种完全不同的行为。想把闭包写对,得先搞清楚捕获时到底发生了什么。
捕获的实现:打包,不是绑定
编译期,analyzer 发现 lambda 引用了外层函数的局部变量,就生成一个捕获列表;运行时创建 lambda 的 Callable 时,VM 按列表把每个变量的当前值逐个复制进 lambda 自己的槽位数组。之后 lambda 和外层函数在数据上完全断开——外层变量再改,lambda 里看不见。
这解释了第一个实验:
var x := 1
var f := func(): return x
x = 100
print(f()) # 输出 1,不是 100
f 创建那一刻 x 的值 1 被打包进捕获数组,后面的赋值和 lambda 无关。这是值捕获,行为对齐 C++ 按值捕获、不像 JavaScript 的按变量捕获。想让 lambda 看到最新值,得捕获一个大家共享的容器——下面讲为什么容器是另一回事。
Variant 拷贝的三层语义
捕获数组存的是 Variant,而 Variant 的"复制"按类型分三档:
标量(int/float/bool/String/StringName)是真拷贝。 数据区直接复制,之后两边独立。上面的实验属于这档。
容器(Array/Dictionary)是写时复制(COW)的共享。 捕获瞬间不复制数据,只给底层缓冲的引用计数 +1;lambda 或外层任何一方写入时,引擎先深拷贝一份再写。行为推论:捕获后读双方看到同一份数据;写入瞬间分叉。还有个容易忽略的窗口——元素级写入也算写:arr[0] = 9 触发 COW,lambda 里的 arr 从此和外层是两个数组。但如果在捕获后、写入前,外层先改了一处元素,lambda 读到的是改过的值。想在 lambda 和外部之间稳定共享状态,正确姿势是捕获一个 RefCounted 或 Object,把状态挂到它身上,绕开 COW。
Object(含 Node)是指针拷贝。 捕获的是指向同一个对象的指针,对象本身只有一份。lambda 里调 node.position = x 改的是大家共享的那个节点——这一档是"引用捕获"的效果。内存模型那篇讲过的泄漏形态三(闭包捕获 Node 构成环)就是从这里来的。
循环里捕获循环变量
经典陷阱在 for 循环里批量生成 lambda:
var fs: Array[Callable] = []
for i in 3:
fs.append(func(): return i)
print(fs[0]()) # 输出 2,不是 0
每次迭代都创建一个新的 i 槽位(GDScript 的 for 循环变量每轮重新声明),lambda 捕获的是当轮的拷贝——这段代码在 Godot 4 里行为正确,三个 lambda 分别返回 0、1、2。3.x 里同样的代码输出 2、2、2,因为循环变量是函数级单一槽位,三个 lambda 捕获的是同一个变量的最终值。从 3.x 迁移来的代码如果曾靠"都是 2"的怪行为写过 workaround,迁移时会反着踩一次。
捕获与生命周期
捕获数组延长被捕获值的生命周期:lambda 不死,捕获的 Variant 就不释放;捕获的是 Object 指针时,对象本身的生命周期不受影响(指针拷贝不加引用计数),lambda 活得比对象久,调它就是对已释放对象操作。安全模式有两种:捕获 RefCounted 替代裸 Object(引用计数托底),或者调用前显式判断 is_instance_valid(node)。Callable 那篇讲过 bind() 的参数也是同样打包进捕获槽位,lambda 和 bind 在数据布局上是同一套机制的两个入口。