技术博客

Object 基类与 ClassDB:Godot 反射系统与信号槽的地基

第 2 / 149 篇 · 2025-04-03 · godot · 引擎架构源码分析信号槽

Godot 所有的脚本交互、属性面板、场景序列化、RPC,最终都建立在同一套地基上:Object 基类加 ClassDB 反射表。C++ 本身没有反射,这套东西全是手写出来的。翻 4.2.2 的 core/object/ 目录(object.h、object.cpp、class_db.h)之后,我觉得最值得记录的是三个设计:GDCLASS 宏怎么把类信息"变"出来、信号连接存在哪个哈希表里、emit 时为什么要先复制连接列表。在展开之前,先回答一个更根本的问题:为什么 Godot 宁可手写四千行反射代码,也不用编译器白送的 RTTI。

为什么不用 RTTI:ABI 与裁剪两个硬约束

C++ 的 RTTI(dynamic_cast、typeid)看起来能解决"运行时知道对象是什么类",但放进引擎里有两个过不去的坎。

第一个是 ABI 边界。RTTI 的 type_info 由编译器生成,地址和布局跟着编译单元走。GDExtension 是独立编译的动态库,插件里的 Node 和引擎里的 Node 各有各的 type_info,dynamic_cast 跨库边界行为依赖平台和链接方式,做不到可移植。而 Godot 的反射要服务的首要对象恰恰就是脚本和 GDExtension 这些"外部世界"。

第二个是裁剪。RTTI 是编译器为每个多态类无条件生成的,想从引擎二进制里裁掉一类功能,得连类定义一起删。Godot 的做法是给每个类一个 _class_is_enabled 编译期常量,配合生成的 disabled_classes.gen.h,在 SCons 里配 disable_3d=yes 之类选项就能把整块类 hierarchy 从注册表和二进制里一起拿掉。反射信息存在自己手里,才能谈裁剪粒度。

所以 GDCLASS 宏不是"重新发明轮子",是在 RTTI 给不了的两件事上自己铺路:跨边界安全、可编译期裁剪。代价也明摆着——所有类信息靠手写 _bind_methods() 维护,引擎里那几万个 bind_method 调用就是这套方案的维护成本。

GDCLASS:一个宏撑起半个反射系统

每个引擎类的声明里都有 GDCLASS(Node, Object); 这样一行。它展开后是一串静态方法和虚函数(object.h,节选):

#define GDCLASS(m_class, m_inherits) \
private: \
    void operator=(const m_class &p_rval) {} \
    friend class ::ClassDB; \
public: \
    typedef m_class self_type; \
    static constexpr bool _class_is_enabled = !bool(GD_IS_DEFINED(ClassDB_Disable_##m_class)) && m_inherits::_class_is_enabled; \
    virtual String get_class() const override { /* ... */ return String(#m_class); } \
    static _FORCE_INLINE_ String get_class_static() { return String(#m_class); } \
    static _FORCE_INLINE_ String get_parent_class_static() { return m_inherits::get_class_static(); } \
    // ...

几个值得注意的点:

  • get_class_static() 用 #m_class 字符串化得到类名,编译期就确定了,没有 RTTI 参与;
  • get_parent_class_static() 递归调父类的同名静态函数,继承链是靠模板递归串起来的;
  • _class_is_enabled 配合 disabled_classes.gen.h——编译时可以把整个类裁掉(ClassDB_Disable_XXX 宏),这是"裁剪引擎体积"的底层开关;
  • friend class ::ClassDB 暗示了注册动作由 ClassDB 主导。

is_class("Node") 这类检查不走 dynamic_cast,而是沿 get_class_static() 的字符串链比对——慢是慢点,但跨 DLL 边界安全,这也是 GDExtension 能用它的原因。实际比对的不是裸字符串而是 StringName:字符串在第一次使用时驻留进全局表,之后的比较退化成指针比较,$O(1)$ 完成。所以"字符串链比对"的真实成本是 $O(\text{继承深度})$ 次指针比较,而不是 $O(\text{深度} \times \text{字符串长度})$ 的字符扫描——StringName 的驻留机制把反射检查拉回了可用区间,这也是为什么引擎里所有"按名字"的接口(方法名、信号名、属性名)签名上都写 StringName 而不是 String。

ClassDB:一张全局 HashMap

class_db.h 里 ClassDB 的全部状态:

class ClassDB {
public:
    struct ClassInfo {
        APIType api = API_NONE;
        ClassInfo *inherits_ptr = nullptr;
        HashMap<StringName, MethodBind *> method_map;
        HashMap<StringName, int64_t> constant_map;
        HashMap<StringName, MethodInfo> signal_map;
        HashMap<StringName, PropertyInfo> property_map;
        HashMap<StringName, PropertySetGet> property_setget;
        bool exposed = false;
        Object *(*creation_func)() = nullptr;
        // ...
    };

    static HashMap<StringName, ClassInfo> classes;

    template <class T>
    static Object *creator() { return memnew(T); }

    template <class T>
    static void register_class(bool p_virtual = false) {
        T::initialize_class();
        ClassInfo *t = classes.getptr(T::get_class_static());
        // ...
        t->creation_func = &creator<T>;
        t->exposed = true;
    }
};

注册发生在引擎启动时:register_core_types()、register_scene_types() 这些函数里一排 GDREGISTER_CLASS(Node);,每个都调 ClassDB::register_class<T>(),往 classes 这张全局哈希表塞一份 ClassInfo。

creation_func 是反射实例化的全部秘密。 ClassDB::instantiate("Node") 就是查表拿到函数指针然后调用。脚本里 Node.new()、.tscn 文件里的 [node type="Node"],最终都汇到这一个函数指针上。没有注册进 ClassDB 的类,场景文件就还原不出来。这也是为什么自定义 C++ 模块忘了写 GDREGISTER_CLASS 时,编辑器里一切正常、导出后场景加载静默丢节点——编辑器进程注册了所有类,导出模板只注册被链接进来的。

方法绑定同理。每个类的 _bind_methods() 静态函数里一排:

ClassDB::bind_method(D_METHOD("add_child", "node"), &Node::add_child);

bind_method 是个变参模板,用 create_method_bind(p_method) 把成员函数指针包成 MethodBind 对象(模板元编程推导出参数类型列表),塞进 method_map。脚本调 node.add_child(x) 的路径就是:脚本 VM → Object::callp → ClassDB 查 method_map → MethodBind::call → 解包 Variant 参数 → 调到真实 C++ 函数。每次脚本调引擎方法都要走这一趟,这是 GDScript 调原生方法的主要开销来源,也是 GDScript 编译器搞出 OPCODE_CALL_METHOD_BIND 指令直接缓存 MethodBind* 的原因(上一篇讲过)。

这条调用链值得画出来,因为每一跳都有明确的成本:

脚本调用 node.add_child(x) 的完整路径(Godot 4.2.2) GDScript VM OPCODE_CALL_* Object::callp 按名派发 ClassDB 查表 method_map[StringName] MethodBind::call 解包 Variant C++ 函数 真正干活 VALIDATED 指令可缓存 MethodBind* 直跳末段 StringName 指针级比较,O(1) 参数类型逐一校验装箱 启动时的反向路径:注册 GDREGISTER_CLASS(Node) → ClassDB::register_class<T>() → classes["Node"].creation_func = &creator<T> _bind_methods() → bind_method(D_METHOD("add_child","node"), &Node::add_child) → method_map 塞入 MethodBind*

一次调用的固定开销大致是:一到两次哈希查找 + 若干次 Variant 解包校验。固定开销意味着它和被调函数本身干多少活无关——调 set_position 和调一个重活函数,过路费一样。所以优化方向从来不是"少调函数",而是让编译器把查找这步省掉(VALIDATED 指令),或者干脆把热点循环挪进 C++。

信号:存在对象自己身上的两张表

信号的存储结构在 object.h 里:

struct Connection {
    ::Signal signal;
    Callable callable;
    uint32_t flags = 0;
};

struct SignalData {
    struct Slot {
        int reference_count = 0;
        Connection conn;
        List<Connection>::Element *cE = nullptr;
    };
    MethodInfo user;
    HashMap<Callable, Slot, HashableHasher<Callable>> slot_map;
};

HashMap<StringName, SignalData> signal_map;  // 每个 Object 一份
List<Connection> connections;                // 我作为目标被别人连的记录

设计上是双向登记:signal_map 记"我发出的信号连着谁",connections 记"我作为接收方连着谁的信号"。后者存在的意义是析构清理——对象被释放时,遍历自己的 connections 把所有指向自己的连接断掉,防止悬垂回调。

为什么必须是双向而不是单向加扫描?算一笔账:如果只在发射侧存连接,对象析构时要清理指向自己的连接,就得扫描全引擎所有对象的所有信号表,成本是 $O(\text{对象数} \times \text{连接数})$,而 Godot 里节点生灭是高频操作。多维护一张反向表,析构清理变成 $O(\text{指向自己的连接数})$。这是典型的用空间换时间——每个连接存两遍,换来析构路径的确定性开销。垃圾回收语言里观察者模式的"弱引用自动置空"在这里被手工实现了一遍。

Object::connect()(object.cpp,节选)的关键步骤:

Error Object::connect(const StringName &p_signal, const Callable &p_callable, uint32_t p_flags) {
    SignalData *s = signal_map.getptr(p_signal);
    if (!s) {
        // 先校验信号存在:ClassDB::has_signal 或脚本信号
        bool signal_is_valid = ClassDB::has_signal(get_class_name(), p_signal);
        // ...
        signal_map[p_signal] = SignalData();
        s = &signal_map[p_signal];
    }

    // 同一个 callable 重复连接直接报错(除非 CONNECT_REFERENCE_COUNTED)
    if (s->slot_map.has(*target.get_base_comparator())) {
        ERR_FAIL_V_MSG(ERR_INVALID_PARAMETER, "Signal ... is already connected ...");
    }

    SignalData::Slot slot;
    slot.conn.callable = target;
    slot.conn.flags = p_flags;
    if (target_object) {
        slot.cE = target_object->connections.push_back(conn);  // 在目标侧登记
    }
    s->slot_map[*target.get_base_comparator()] = slot;
    return OK;
}

注意用 get_base_comparator() 做 key——这是"带 bind 参数的重复连接判断"的依据:connect("x", cb.bind(1)) 和 connect("x", cb.bind(2)) 被当成同一个 callable,第二个会报重复。想让同一个方法接多次同一个信号,要么换 callable,要么接受这个限制。

emit 时为什么先复制一份连接列表

emit_signalp() 里最见功力的一段(object.cpp,节选):

Error Object::emit_signalp(const StringName &p_name, const Variant **p_args, int p_argcount) {
    if (_block_signals) {
        return ERR_CANT_ACQUIRE_RESOURCE;
    }

    SignalData *s = signal_map.getptr(p_name);
    if (!s) {
        return ERR_UNAVAILABLE;  // 没人连,直接返回
    }

    // 发射期间阻止自己被析构(针对 RefCounted 的边角案例)
    Ref<RefCounted> rc = Ref<RefCounted>(Object::cast_to<RefCounted>(this));

    // 把整个 slot_map 复制到本地数组
    LocalVector<Connection> slot_conns;
    slot_conns.resize(s->slot_map.size());
    uint32_t idx = 0;
    for (const KeyValue<Callable, SignalData::Slot> &slot_kv : s->slot_map) {
        slot_conns[idx++] = slot_kv.value.conn;
    }

    for (const Connection &c : slot_conns) {
        if (!c.callable.is_valid()) {
            continue;  // 回调途中目标被删了,跳过
        }
        if (c.flags & CONNECT_DEFERRED) {
            MessageQueue::get_singleton()->push_callablep(c.callable, args, argc, true);
        } else {
            Callable::CallError ce;
            c.callable.callp(args, argc, ret, ce);  // 同步直接调
        }
        // ...
    }
    return OK;
}

复制到 LocalVector 的理由注释里没写,但一想就明白:回调里什么都能发生——disconnect 自己、queue_free 发射者、甚至 connect 新目标。如果直接遍历 slot_map,回调一改哈希表就迭代器失效。复制快照后,"回调中 disconnect"只影响后续发射,不影响本轮;"回调中删掉目标对象"由 c.callable.is_valid() 兜底跳过。

快照语义还带来一个值得记住的行为:回调里新 connect 的目标,本轮发射收不到。这轮回调拿到的连接集合在 emit_signalp 入口就冻结了。配合 CONNECT_DEFERRED 能写出很绕的时序——A 的信号回调里 connect 了 B,又立刻再 emit 一次,B 能收到第二次;但同一次发射里 B 永远缺席。排查"信号连了却没触发"时,先怀疑时序再怀疑连接本身。

另外可见 CONNECT_DEFERRED 的实现就是把调用推进 MessageQueue——信号和消息队列在这里交汇。emit_signal.call_deferred() 和 connect 时加 CONNECT_DEFERRED 是两条路,后者连参数都是入队时打包的。

属性系统:ClassInfo 里另一张被低估的表

ClassInfo 里除了 method_map 还有 property_map 和 property_setget——属性不是成员变量,而是"setter/getter 方法对加一段 PropertyInfo 元数据"。_bind_methods() 里的 ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "speed", PROPERTY_HINT_RANGE, "0,100"), "set_speed", "get_speed") 注册的就是这个三元组。这张表同时喂三个系统:

  • 编辑器 Inspector 按 PropertyInfo 的 hint 渲染控件(PROPERTY_HINT_RANGE 变滑条);
  • 场景序列化按 PROPERTY_USAGE_STORAGE 过滤要落盘的属性;
  • Object::set("speed", v) 走 property_setget 查到 MethodBind* 再调 setter。

所以"脚本里 node.speed = 5"和"改 C++ 成员变量"之间隔着一次查表加方法调用——这也是为什么性能敏感路径上引擎内部都直接调 C++ 函数,只有脚本侧才走属性通道。ClassDB::is_parent_class 同理,是沿 inherits_ptr 逐级比对的链式查找,没有预计算的继承闭包,类多了以后每次 is_class 检查都是 O(继承深度)。

为什么宁可每次链式走也不预计算继承闭包?因为类可以运行时注册——GDExtension 在引擎跑起来之后随时能往 ClassDB 里插新类,继承关系是动态的。预计算闭包意味着每次注册都要失效重建一批缓存,而 is_class 的调用频率远低于类注册的管理复杂度预算。这又是同一个取舍模式:写少读多但写必须灵活的表,选读侧轻量、写侧零维护的结构。

Object::notification 的双向分发

最后补一块和前两篇衔接的拼图。Object::notification()(object.cpp)决定了 C++ 和脚本谁先收到通知:

void Object::notification(int p_notification, bool p_reversed) {
    if (p_reversed) {
        if (script_instance) {
            script_instance->notification(p_notification, p_reversed);
        }
    } else {
        _notificationv(p_notification, p_reversed);  // C++ 虚函数先
    }
    // ... GDExtension 的 notification 回调
    if (p_reversed) {
        _notificationv(p_notification, p_reversed);
    } else {
        if (script_instance) {
            script_instance->notification(p_notification, p_reversed);  // 脚本后
        }
    }
}

默认顺序是 C++ 先、脚本后——NOTIFICATION_READY 先走 Node::_notification(里面完成 set_process(true) 自动注册),再轮到脚本的 _notification 或 GDVIRTUAL 回调。这就是引擎能在你的 _ready 跑之前把一切准备好的原因。反向通知(p_reversed = true,用于 PREDELETE 这类销毁序列)顺序翻转:脚本先收到,能在 C++ 侧拆地基之前做最后的清理。一进一出的顺序设计,保证脚本永远面对"地基完好"的对象。

本文代码引自 Godot 4.2.2-stable 的 core/object/object.h、core/object/object.cpp、core/object/class_db.h,均有删减。