技术博客

Vector/HashMap/LocalVector:Godot 为什么不用 STL

第 14 / 149 篇 · 2025-06-26 · godot · 引擎架构源码分析数据结构

翻 Godot 源码第一个不适应的地方:满屏的 Vector<T>、HashMap<K,V>、LocalVector<T>,没有 std::。这不是历史惯性,是主动选择——引擎曾经评估过全面切到 STL,结论是不切。这篇拆 core/templates/ 下的三个主力容器,讲清它们和 STL 对应物的分歧点,以及分歧背后的三条理由。源码引自 4.2.2-stable。

理由一:拷贝语义——CowData 的 COW 是引擎级约定

Vector<T> 的底层是 CowData<T>:

// core/templates/vector.h
template <class T>
class Vector {
    CowData<T> _cowdata;   // 引用计数 + 写时复制
};

赋值 Vector<int> b = a; 不复制数据,只加引用计数;b.push_back(1) 时才分叉。这和 std::vector 的"赋值即深拷贝"是完全相反的默认语义。

为什么 Godot 选 COW?因为引擎内部对象到处按值传递:Resource 的属性、信号参数、Variant 里内嵌的容器,全都要能廉价拷贝。COW 让"按值传一个可能很大的数组"在只读场景下零成本,配合 Variant 的 24 字节布局,脚本和 C++ 之间传数组不需要额外包装。std::vector 要复刻这个行为得套 shared_ptr<vector<T>>,语义反而更绕(shared_ptr 的拷贝是线程安全原子操作,开销比 CowData 的非原子计数还高——CowData 的计数只在 THREAD_SAFE 编译宏下用 SafeNumeric 原子化)。

COW 的坑也实在:多线程共享同一个 Vector,一方写入触发分叉是安全的,但"两个线程同时写入各自的逻辑拷贝前共享底层缓冲"时,分叉本身靠引用计数判断,计数读和写之间有窗口期。引擎的纪律是跨线程传递前调用 duplicate() 强制分叉,这点没有静态检查,全靠 review。

理由二:内存模型——LocalVector 的明确所有权

LocalVector<T>(core/templates/local_vector.h)是 4.x 新增的容器,和 Vector 的关键差异:

  • 无 COW:赋值是深拷贝(实际上赋值运算符被设计成显式复制整个缓冲),移动语义转移所有权;
  • 默认不构造元素:resize(n) 对 POD 类型只是移动指针,不调构造函数,批量初始化 POD 数组比 Vector 快;
  • 可选紧凑模式:LocalVector<T, uint32_t, true> 用 32 位容量字段,适合"数量大但单个数组小"的场景,比如每个渲染 item 的变换列表。

它的存在是因为 servers 层(RenderingServer、PhysicsServer)需要"数据只归这个 RID 所有、绝不共享"的容器。COW 在这种场景是纯负担——引用计数白付,还可能被意外的按值传递触发隐式共享。LocalVector 把"不共享"写进类型,编译器帮你挡住误用。

一个实测差异:往 Vector 和 LocalVector 各 push 100 万个 int,release 构建下 LocalVector 快约 15%(省掉每次写入前的 _get_refcount() 检查)。听起来不多,但物理引擎每帧对所有 active body 做这种操作,累积起来可观。

理由三:ABI 与构建控制

第三条理由最实际:STL 容器的内存布局是实现相关的。libstdc++ 的 std::vector 和 libc++ 的 std::vector 二进制不兼容,而 GDExtension 要求 C++ 扩展和引擎核心跨编译器传递对象。引擎容器全是简单的"指针 + 计数 + 容量"布局,且头文件里就是全部实现,跨 ABI 边界传 Vector 的内部表示是确定的。std:: 容器过了边界就是未定义行为。

配套收益是构建可控:禁用 RTTI、禁用异常、自定义分配器(memnew/memdelete 走 core/os/memory.cpp 的全局计数)这些引擎级约定,自研容器天然遵守,STL 容器要靠 allocator 模板参数硬套,代码丑还容易漏。

增长策略与迭代失效

Vector 的扩容是几何增长(接近翻倍),push 的摊还复杂度 $O(1)$,这一点和 std::vector 一致。但 COW 改变了"扩容时机"的语义:共享状态下任何写入(包括非扩容的 set())都先触发完整复制,所以"共享中的 Vector 第一次写入"是一次 $O(n)$ 的隐性成本,性能分析时要算进去。

摊还增长的数学:设每次扩容容量翻倍,$n$ 次 push 的总搬移次数是

$$ T(n) = 1 + 2 + 4 + \cdots + n = 2n - 1 $$

单次 push 的摊还成本 $T(n)/n \approx 2$,常数级的来源就是这个等比级数。COW 分叉不参与这个级数——分叉是"共享即复制",和容量无关,两者的成本曲线在 profile 里要分开认。

迭代失效规则和直觉一致但有一个 COW 特有的坑:

var a = [1, 2, 3]
var b = a            # 共享同一缓冲
for x in a:
    b.append(4)      # b 写入 → COW 分叉,a 的迭代不受影响

底层 CowData 分叉后 a 还指向旧缓冲,迭代安全——这在 std::vector 的共享包装下是做不到的(共享指针会被两边同时看到变化)。反过来,对同一个 Vector 在迭代中 push 自己依旧可能触发扩容搬移,迭代器(这里是指针)失效,和 std::vector 一样属于未定义行为。

HashMap:开放寻址的罗宾汉表

HashMap<K,V>(core/templates/hash_map.h)不是链式哈希,是开放寻址 + 罗宾汉(Robin Hood)策略:

  • 元素存在连续数组里,缓存友好;
  • 冲突时"劫富济贫":探测距离(元素当前位置到理想位置的距离)更长的元素抢走位置,被抢的继续往下探。这让探测距离的方差最小化,最坏情况查找长度从 $O(n)$ 压到 $O(\log n)$ 级别。

罗宾汉哈希的核心量是每个元素的 DIB(distance to initial bucket)。设元素 $i$ 的理想桶为 $h_i$,实际位置为 $p_i$,则

$$ \text{DIB}_i = p_i - h_i \pmod{\text{capacity}} $$

插入时若新元素的 DIB 大于当前占用者,交换两者继续插入。平均查找长度的界是 $\Theta(\log_2 n)$ 而不是链式的负载因子敏感。Godot 的实现在元素里显式存了 32 位哈希和探测数据,换来冲突处理时不用重算哈希。

负载因子默认上限约 0.8(hash_map.h 里的增长策略),到阈值扩容重哈希。和 std::unordered_map(链式,指针追踪)相比,迭代和查找都快,但删除元素需要 backward-shift 调整后续元素的 DIB,实现复杂度上去了——这也是 hash_map.cpp 里 remove() 比 insert() 难读的原因。

结构示意

三个容器的内存语义对比 Vector<T>(CowData) 赋值 = 共享 + 引用计数 写入时分叉(COW) 适用:Variant/资源/信号参数 开销:每次写检查计数 LocalVector<T> 无共享,移动转移所有权 POD 元素 resize 零构造 适用:servers 层 RID 数据 开销:无计数检查 HashMap<K,V> 开放寻址 + 罗宾汉 连续存储,缓存友好 适用:ClassDB/ObjectDB/缓存 开销:删除需 backward-shift 为什么不用 std:: 1. 语义:std::vector 赋值深拷贝,引擎要的是默认共享(COW) 2. ABI:GDExtension 跨编译器传对象,STL 布局实现相关不可依赖 3. 构建:no-exceptions / no-rtti / 自定义分配器,自研容器天然遵守 历史注:3.x 曾用 List(双向链表),4.x 热路径已全换成连续存储容器

配套的还有两个小众但重要的表结构:HashSet<T>(core/templates/hash_set.h,同策略只存键)和 RBMap/RBSet(红黑树,有序遍历用)。需要"按键序遍历"的场景(比如把 HashMap 的键排序输出做确定性序列化)要么用 RBMap,要么 HashMap 取出键后显式 sort——没有"保序哈希表"这种便利品。

另有一个 OrderedHashMap(core/templates/ordered_hash_map.h)值得知道:哈希表加双向链表的复合结构,提供按插入序遍历,用于少数"既要 O(1) 查找又要稳定顺序"的场景(资源缓存的 LRU 语义就靠类似结构)。代价是每元素多两个链表指针,内存和缓存局部性都逊于纯 HashMap,默认别选它。

与 Packed*Array 的关系

脚本层的 PackedInt32Array 等类型底层是 Vector 的特化近亲(core/variant/ 下的 packed array 实现复用 CowData),但语义上多了一层"元素类型固定为 POD"。固定元素类型带来两个硬收益:内存连续且无每元素开销(对比 Array 每元素 24 字节 Variant),以及序列化和渲染上传可以整块 memcpy。给 RenderingServer 上传顶点数据时,引擎要的就是 Packed*Array——这是"容器类型匹配数据形态"的直接体现。

选择速查

写引擎级 C++ 时的决策顺序:

  1. 要传给脚本或跨信号边界 → Vector(COW 语义和 Variant 匹配);
  2. 只在 C++ 内部、所有权明确 → LocalVector;
  3. 需要键值查找 → HashMap,需要有序 → RBMap,需要插入序遍历 → OrderedHashMap;
  4. 中段插入删除频繁且元素大 → 才考虑 List,先确认不是伪需求(多数"频繁插入"其实是批量重建,Vector 一次 rebuild 更快)。

拿不准时的默认答案:LocalVector(C++ 内部)或 Vector(跨边界)。选错的代价比想象的低,选"聪明"容器选错的代价(COW 惊群、链表缓存雪崩)比想象的高。

实战推论

  • 遍历优先 Vector/LocalVector,别用 List。4.x 里 List<T> 还在(双向链表,core/templates/list.h),但只用于"频繁中段插入删除"的极少数场景。连续容器的缓存命中率优势在现代 CPU 上是数量级的。
  • 跨线程传容器前 duplicate()。COW 不会替你挡并发写入,这是纪律不是机制。
  • HashMap 迭代顺序不稳定,插入顺序、扩容历史都会影响遍历序。序列化前先排序 key,否则 diff 会很难看。
  • reserve() 在热路径值得用。Vector 和 HashMap 都有 reserve,能预估规模时(比如解析一个已知行数的文件)先预留,省掉几何增长途中的多次搬移——COW 容器里 reserve 还有额外价值:预先独占容量后,后续写入不再触发分叉检查失败的复制。
  • 不要把 LocalVector 塞进 Variant。Variant 不认它,跨脚本边界必须换回 Vector 或 Packed*Array。LocalVector 是纯粹的 C++ 内部工具。
  • 想确认类型行为,看 core/templates/ 源码头部的注释块。Godot 的容器头文件注释写得比文档诚实,语义分歧(比如 LocalVector 的 resize 是否清零)都写在上面。

本文代码引自 Godot 4.2.2-stable 的 core/templates/vector.h、core/templates/cowdata.h、core/templates/local_vector.h、core/templates/hash_map.h、core/templates/list.h,均有删减。性能数字为本机 M 系列芯片 release_debug 构建实测,仅供参考。