button.pressed.connect(_on_pressed.bind(42)) 这行代码里,bind(42) 返回的不是原方法的别名,而是一个新的 Callable,里面存着原 Callable 加一个参数数组。信号发射时,这个数组会被追加到信号自带参数后面一起传给方法。理解这层结构,信号系统里一半的"玄学行为"(bind 参数顺序、参数个数不匹配报错)都能推导出来。源码引自 4.2.2-stable 的 core/variant/callable.h、core/variant/callable_bind.cpp。
Callable 的四种形态
Callable 在 Variant 家族里占一个类型标签,内部结构:
// core/variant/callable.h(简化)
class Callable {
ObjectID object; // 目标对象(ObjectID,不是裸指针)
StringName method; // 方法名(标准形态)
CallableCustom *custom = nullptr; // 自定义形态(bind/lambda)
// 二者互斥:要么是 object+method,要么是 custom
};
四种构造来源对应四种内部表示:
- 方法引用:
Callable(obj, "method")—— 存 ObjectID + StringName; - bind:
callable.bind(args...)—— 返回CallableCustomBind,custom 指针指向堆上的包装对象; - lambda / GDScript 函数引用:编译器生成
CallableCustom子类,闭包环境存在 custom 里; - 静态函数:
Callable(SomeClass.static_method)—— 4.x 里也是 custom 形态。
关键点是 ObjectID 而不是 Object*:和 Variant 存对象引用同一个理由(见 Variant 那篇),目标对象被 queue_free() 后 Callable 不会变成悬垂指针,is_valid() 会去 ObjectDB 查 ID 的有效性。这就是为什么信号连接的对象被释放后,发射信号不会崩,只是静默跳过。
bind 的实现:CallableCustomBind
Callable::bind 的实现(callable.cpp)很短:
Callable Callable::bind(const Variant **p_args, int p_argcount) const {
return Callable(memnew(CallableCustomBind(*this, p_args, p_argcount)));
}
CallableCustomBind(callable_bind.cpp)持有一个原 Callable 的拷贝和一个 Vector<Variant> 存 bind 参数。调用时走 CallableCustom::call() 虚函数:
void CallableCustomBind::call(const Variant **p_arguments, int p_argcount,
Variant &r_return_value, Callable::CallError &r_call_error) const {
// 把 bind 参数追加在信号参数后面
int total = p_argcount + binds.size();
const Variant **args = (const Variant **)alloca(sizeof(Variant *) * total);
memcpy(args, p_arguments, p_argcount * sizeof(Variant *));
for (int i = 0; i < binds.size(); i++) {
args[p_argcount + i] = &binds[i];
}
callable.call(args, total, r_return_value, r_call_error);
}
注意参数顺序:信号自带参数在前,bind 参数在后。signal(args).connect(f.bind(x)) 最终调用 f(args..., x)。这个顺序是反直觉的(很多人以为 bind 参数在前),3.x 时代甚至有 CONNECT_BIND 标志控制方向,4.x 统一成"bind 永远追加在尾部"。
参数拼接发生在栈上(alloca),不分配堆内存——这是热路径优化:信号发射是引擎里频率最高的间接调用之一,多一次 malloc 都嫌贵。alloca 的代价是参数特别多时(比如 bind 了 20 个参数的信号高频发射)栈空间被占用,但 Godot 的方法调用参数上限本来就压在 255 个以内(VARARG 编码限制),不会出事。
Callable 的相等比较与 disconnect
disconnect(sig, callable) 能work的前提是 Callable 有可定义的相等语义。规则(callable.cpp 的 operator==):
- 标准形态:object ID 和 method 名都相等才算同;
- custom 形态:比 custom 指针,即只有同一个
CallableCustom对象才算相等。
这就是"临时再 bind 一次断不开连接"的机制根源:f.bind(x) 每次调用都 memnew 一个新的 CallableCustomBind,两个内容完全相同的 bind Callable 指针不同,不相等。正确姿势:
var cb := _on_hit.bind(enemy)
hit.connect(cb)
# ...
hit.disconnect(cb) # 用保存的同一个 Callable
如果确实没保存引用,4.x 给了逃生门:signal.get_connections() 返回连接列表,遍历找到 callable.get_object() 和 get_method() 匹配的再断——但这比对的是语义而不是 identity,bind 参数不同的同名连接会被误伤,慎用。
callp 与 call:变参的两条路径
Callable 有两个调用入口:callp()(参数是 const Variant ** 数组)和 call()(变参模板,逐个打包成 Variant)。引擎内部全走 callp——参数已经在栈上的 Variant 数组里,零打包。脚本层 callable.call(a, b, c) 走的是打包路径,每个实参一次 Variant 构造。高频调用(每帧几十次以上的回调)值得把 Callable 缓存并减少参数个数,参数打包的 Variant 构造开销在小参数数量下也不可忽略,尤其是参数是 String/Dictionary 这类带堆对象的类型。
信号发射时参数从哪来
信号的连接存在 Object::SignalMap 里(core/object/object.h),每个连接记录目标 Callable 和 flags。Object::emit_signal 的路径:
- 查
signal_map找到该 StringName 对应的连接列表; - 对每个连接,把发射时传入的参数打包成
Variant**; - 调
Callable::callp(),进入 Callable 的分派逻辑; - 如果是 CallableCustomBind,走上面那段参数拼接,递归调用内部 Callable。
所以 bind 参数的存储位置和信号本身完全无关——信号存的是 Callable,Callable 里套着 Callable 和参数。链条可以无限嵌套:f.bind(1).bind(2) 合法,参数顺序是 (信号参数..., 1, 2)。
这个设计还解释了一个常见报错:Method expects 2 arguments, but called with 3。pressed 信号本身 0 个参数,bind 3 个就超了。报错发生在最内层 MethodBind::call 的参数数量校验,错误信息里的数字是拼接后的总数,不是 bind 参数的个数——第一次看到时很容易误判方向。
连接与发射的参数流
lambda 捕获:CallableCustom 的另一面
GDScript 的 lambda 编译后也是 CallableCustom 子类(GDScriptLambdaCallable),捕获的变量存在 custom 对象里。捕获语义是拷贝:lambda 创建时把捕获变量按值塞进一个数组,之后外部修改原变量不影响 lambda 内部。
这和 C++ lambda 的 [&] 引用捕获不一样,是刻意的——GDScript 没有借用检查,引用捕获会在"局部变量已销毁但 lambda 还活着"时直接悬垂。拷贝捕获用内存换安全,代价是 lambda 里改捕获变量不会传出去(新手常以为能借此实现闭包计数器,不行)。
实战推论
- bind 参数在 Callable 里,生命周期跟 Callable 走。
connect(sig, f.bind(node))把 node 以 Variant 形式存进 binds,Variant 持对象引用(带 ObjectID)但不阻止释放——node 被 queue_free 后,信号再发射会带着一个失效的 Variant 进方法,is_instance_valid()检查是你的责任。 - 参数个数校验发生在最内层。报错信息里的参数数是"信号参数 + bind 参数"的总和,排查时先数信号声明。
- 多层 bind 可以叠加但不推荐。每层 bind 是一次 CallableCustom 虚函数调用加一次参数数组拼接,三层以上在热信号上能被 profiler 看见。
- 断开连接用同一个 Callable。
disconnect(sig, f.bind(x))能断,因为 Callable 的相等比较对 custom 类型比的是指针——bind每次都 memnew 新对象,所以必须保存 connect 时的那个 Callable 引用才能 disconnect,临时再 bind 一次是断不开的。这是 4.x 新手踩坑第一名。
本文代码引自 Godot 4.2.2-stable 的 core/variant/callable.h、core/variant/callable.cpp、core/variant/callable_bind.h、core/variant/callable_bind.cpp、core/object/object.cpp,均有删减。