技术博客

Callable 与信号的关系:bind 的参数存在哪

第 15 / 149 篇 · 2026-03-09 · godot · 引擎架构源码分析信号

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
};

四种构造来源对应四种内部表示:

  1. 方法引用:Callable(obj, "method") —— 存 ObjectID + StringName;
  2. bind:callable.bind(args...) —— 返回 CallableCustomBind,custom 指针指向堆上的包装对象;
  3. lambda / GDScript 函数引用:编译器生成 CallableCustom 子类,闭包环境存在 custom 里;
  4. 静态函数: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 的路径:

  1. 查 signal_map 找到该 StringName 对应的连接列表;
  2. 对每个连接,把发射时传入的参数打包成 Variant**;
  3. 调 Callable::callp(),进入 Callable 的分派逻辑;
  4. 如果是 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 参数的个数——第一次看到时很容易误判方向。

连接与发射的参数流

signal.connect(f.bind(x)) 发射时的参数流 Object::emit_signal 信号参数 (a, b) CallableCustomBind 拼接:(a, b) + binds(x) 目标方法 f(a, b, x) Callable(标准形态) object: ObjectID(不是裸指针) method: StringName 目标 queue_free 后 is_valid()=false 发射信号时静默跳过,不崩 CallableCustomBind(bind 形态) callable: 原 Callable 的拷贝 binds: Vector<Variant> 存参数 call 时栈上拼接参数(alloca) 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,均有删减。