技术博客

Variant 的内部结构:union 加类型标签怎么装下四十多种类型

第 5 / 149 篇 · 2026-03-23 · godot · 引擎架构源码分析数据结构

GDScript 里一个变量可以是 1、"hello"、Vector3(1,2,3)、一个节点引用,C++ 侧承接它们的是同一个类:Variant。这个类是引擎里出现频率最高的类型——每次脚本调引擎函数,参数和返回值都要装进 Variant;每个资源属性、每个 RPC 参数、每个字典值都是它。它的设计直接决定了脚本互操作的性能天花板。核心文件是 core/variant/variant.h 和 variant.cpp,4.2.2 里这份头文件约 900 行,但真正决定行为的是开头 100 行的内存布局。

布局:4 字节标签 + 4 字节对齐填充 + 16 字节 union

Variant 的存储结构(variant.h,简化):

class Variant {
public:
    enum Type {
        NIL, BOOL, INT, FLOAT, STRING,
        VECTOR2, VECTOR2I, RECT2, RECT2I, VECTOR3, VECTOR3I,
        TRANSFORM2D, VECTOR4, VECTOR4I, PLANE, QUATERNION, AABB,
        BASIS, TRANSFORM3D, PROJECTION,
        COLOR, STRING_NAME, NODE_PATH, RID, OBJECT, CALLABLE, SIGNAL,
        DICTIONARY, ARRAY,
        PACKED_BYTE_ARRAY, PACKED_INT32_ARRAY, /* ... */ VARIANT_MAX
    };
private:
    Type type = NIL;            // 4 字节
    // 编译器自动插 4 字节对齐填充
    union {
        bool _bool;
        int64_t _int;
        double _float;
        Transform2D *_transform2d;   // 大件存指针
        Transform3D *_transform3d;
        AABB *_aabb;
        Basis *_basis;
        Projection *_projection;
        Vector4 _vector4;            // 小件直接内联
        Vector4i _vector4i;
        // ...
        ObjData _obj;                // Object 指针 + 实例 ID
        // 字典/数组/packed array 也是指针(引用计数对象)
    } _data alignas(8);
};

整体是 sizeof(Variant) == 24(64 位平台):4 字节类型标签 + 4 字节填充 + 16 字节 union。为什么是 16 字节而不是 8?因为 union 里要内联放下 Vector4(4×float=16 字节)和 Vector4i(4×int32=16 字节)这两个"刚好 16 字节"的类型。一旦 union 尺寸定为 16,Color(16 字节 float×4)也能内联,而 Transform3D(48 字节)这种塞不下的只能存指针。

这个布局不是拍脑袋定的,而是一条明确的临界线规则:

$$ \text{内联存储} \iff \text{sizeof}(T) \le 16 \text{ 字节} $$

临界线之上的类型直接内联,访问零间接;之下的类型堆分配,Variant 里只存指针。这条线划在 16 是性能和内存的平衡:线再往上抬(比如 48 字节装下 Transform3D),Array 里每个元素都要按最大类型分配,10 万个 int 的数组会浪费 4.6MB;线往下压(比如 8 字节),Vector4 也要走指针,高频数学类型全部多一次缓存未命中。16 正好是"常见向量类型能装下、标量浪费可接受"的甜点位。

为什么 Object 引用要带 instance ID

_obj 字段不是裸的 Object*,而是:

struct ObjData {
    Object *obj;
    ObjectID id;
};

多带一个 8 字节的 ObjectID(对象创建时分配的单调递增 ID),解决的是悬垂指针问题。Godot 的对象析构不走 GC,节点 queue_free() 后内存可能立刻被复用。如果脚本持有的 Variant 里只有裸指针,访问前没有任何办法知道指针还有效。有了 ID,Variant::get_validated_object() 会先查 ObjectDB(ID → 对象的哈希表)确认这个 ID 当前对应的对象还是原来那个指针,才返回。这是"用 8 字节空间换运行时安全"的典型交易,代价是所有持 Object 的 Variant 从 16 字节涨到 24 字节里 union 用满,以及每次校验多一次哈希查找。

类型分派:编译期模板 + 运行时 switch

Variant 的操作(如 Variant::operator==、hash()、duplicate())本质是"按 type 标签分派到具体类型的实现"。引擎里用了两套机制:

  • 编译期:VariantInternal 命名空间里一组模板函数,用 if constexpr 在编译期展开"如果类型是 int 就这么办"的逻辑,配合 Type::VARIANT_MAX 大小的函数指针表;
  • 运行时:构造函数里按 type 填 union,析构函数里按 type 决定要不要调指针目标的析构(Transform3D 的指针要 memdelete,_int 不用管)。

这套"编译期生成、运行时查表"的组合比虚函数快——没有虚表指针,分派是一次数组索引加间接跳转;也比巨型 switch 好维护——新增类型时编译器会强制你补齐所有特化,漏一个就编译报错。代价是 variant.cpp 编译时间长(单文件编译要 10 秒以上),以及调试时栈里的模板展开很丑。

引用语义的分界线:哪些类型"赋值即共享"

Variant 的类型在赋值语义上分裂成两派,这是 GDScript 新手最常踩的坑:

  • 值类型(内联或深拷贝指针):int、float、Vector2/3/4、Color、String、Transform3D——var a = b; a.x = 1 不会影响 b;
  • 引用类型(共享同一底层数据):Array、Dictionary、PackedByteArray 等所有 Packed*Array——var a = b; a.push_back(1) 之后 b 也变。

底层原因是 Array/Dictionary 在 union 里存的是指向 ArrayPrivate/DictionaryPrivate 的指针,拷贝 Variant 只复制指针,两个 Variant 指向同一份数据。这个设计是性能妥协:GDScript 里 func f(arr): arr.push_back(x) 如果深拷贝数组,每次函数调用都是 O(n) 内存分配,解释器会直接不可用。引用语义让数组传参退化成指针复制,和 Python 的 list 行为一致。

边界情况在 packed array 的写时复制(COW):PackedByteArray 在脚本侧看起来是引用类型,但一旦多份引用存在且有一方要写入,会先复制一份再写。这是靠底层 CowData 模板(core/templates/cowdata.h)实现的——读写路径都过引用计数检查,读共享写复制。这套机制和 Linux 内核的 fork() COW 是同一种思想:不付出代价直到非付不可。

内存布局总览

Variant 内存布局(64 位,共 24 字节) Type 标签 4B 对齐填充 4B union _data(16 字节,按 8 字节对齐) union 里能装什么: 内联:bool/int64/double 内联:Vector4/Vector4i 指针:Transform3D/AABB ObjData:ptr+ID(16B) 指针:Array/Dictionary 内联:RID/Callable 为什么是 16 字节的 union 下界:Vector4(4×float)要 16 字节才能内联,否则高频数学类型全走指针 上界:Transform3D 要 48 字节,装它意味着 Array 里每个元素膨胀 3 倍 16 字节 = 常见向量类型内联 + 标量浪费可接受 的平衡点

性能推论:什么操作快、什么操作慢

理解了布局,几个经验法则就能推导出来而不必死记:

  • int/float/bool 赋值是零成本:就是 24 字节的结构体拷贝,编译器能优化成三条 mov;
  • Array/Dictionary 传递是零成本:只复制指针,但共享语义意味着函数内修改会泄漏到调用侧;
  • String 赋值比想象便宜:String 内部是 COW,赋值不复制字符数据,只有写入时才分叉;
  • Variant 数组连续访问不快:Array 的底层是 Vector<Variant>,每个元素 24 字节,遍历 100 万元素要扫 24MB 内存——这是 GDScript 里大数据处理慢的硬件层面原因,和解释器开销叠加后更糟。

最后一个常被忽略的点:Variant 的 NIL 状态不占 union 空间(union 保持未初始化),所以"空 Variant 数组"的内存开销就是纯粹的 24×n 字节加 Vector 的容量预留。做内存敏感的系统设计时,用 PackedInt64Array 替代 Array[int] 能把每元素开销从 24 字节压到 8 字节,还能避开 COW 的引用计数检查。

类型转换:VariantCaster 与零拷贝读取

从 Variant 取回具体类型有两条路径,开销差一个量级。慢路径是 Variant::operator Vector3() 这类转换运算符——它会做类型提升(int 可以转 float,Vector2 可以转 Vector3),内部是一套 40×40 的转换规则表,好处是宽容,坏处是每次转换都可能分配(String 转 NodePath 要解析)。快路径是 VariantInternal::get<Vector3>(&v)——直接按 type 标签校验后从 union 里读引用,零拷贝零分配。

引擎内部热路径全走快路径:MethodBind::ptrcall 解包参数时用 VariantCaster 特化(core/variant/binder_common.h),对 POD 类型就是一次 reinterpret_cast 加引用返回。这就是为什么 GDScript 调原生方法时,参数标注了静态类型(func f(v: Vector3))能省一大截——VM 生成的 ptrcall 路径里参数校验是"类型必须精确匹配",匹配成功直接读 union,没有任何转换逻辑可以执行。

配套的纪律是:脚本 API 的参数类型标注越精确,运行时越不需要猜测。Array[Vector3] 比裸 Array 快,因为元素访问时省了逐元素的类型检查。这条规则在 4.x 的 typed array 上体现得最直接——类型信息让 VM 可以跳过运行时分派,直接生成特化的访问指令。

本文代码引自 Godot 4.2.2-stable 的 core/variant/variant.h、core/variant/variant.cpp、core/templates/cowdata.h、core/variant/binder_common.h,均有删减。sizeof 数据为 x86-64 平台实测。