在开发中,“中间插入/删除元素”一直是个绕不开的难题。用std::vector虽然遍历很快,但删除或插入中间的某一个元素,会触发大量元素平移,而且迭代器、指针会失效;用std::list虽然插入删除是 O(1),但每个节点独立分配,遍历时的缓存命中率比较差。很多项目都在这两种容器的取舍之间反复摇摆。
C++26 标准路线图上出现了一个名为std::hive的容器,最早由plf::colony实践多年,目标就是在“指针/迭代器稳定”的前提下,把内存局部性尽量做到接近数组。也有读者把标题写成std:hive,这里顺手更正一下:正式名称是std::hive,它是一个仍然在标准化流程中演进的容器。本文会从设计原理、内存布局和基准测试三个角度,回答一个核心问题:std::hive到底有多快,它适合什么样的场景。
先说明一个重要前提:截至本文写作时,主流编译器的标准库还不能直接#include <std::hive>。我们实验时使用与提案同源的plf::colony作为参考实现,它几乎复刻了提案中的核心设计思想,代码思路在标准化后可以低成本迁移。接下来,我会先解释这个容器解决了什么问题,再结合可编译 benchmark 代码分析它的性能特征。
1. std::hive 是什么:先理解痛点
1.1vector与list的经典困局
很多 C++ 开发者应该都有过这种经历:某个热点模块大量使用std::vector,中间插入后,后面的元素全部向后移动;如果元素不是简单的int,而是内存占用比较大的结构体,这个移动成本会直接被放大。更麻烦的是,vector 的扩容会导致元素地址变化,任何外部保存的指针都会失效。
于是有人改用std::list。链表确实解决了“元素地址稳定”的问题,也让中间插入删除变成 O(1),但代价是灾难性的缓存不友好。链表的每个节点可能落在完全不同的内存页上,遍历 100 万个节点,相当于随机读取 100 万个内存地址,CPU 缓存命中和预取基本失效。对于频繁遍历、频繁增删的数据,纯链表方案往往不是好选择。
std::hive的思路很有趣:它并不试图取代vector,而是站在了“节点容器的遍历性能”与“连续容器的修改代价”之间,给了开发者第三种选项。它的核心承诺通常有两个:第一,已有元素的地址和引用不会因为插入其他元素而失效;第二,即使删除了部分元素,剩下的元素仍然紧密组织在少数的连续内存块中,遍历时能接近数组性能。
需要注意的是,hive不是deque的另一个版本。deque也采用分块存储,但它的迭代器失效规则更复杂,而且中间插入删除效果并不理想;hive的独特之处在于它会通过“空洞标记”和“空位复用”机制,把随机插入删除对内存布局的影响降到最低。
1.2std::hive的标准化背景与名称问题
如果你看过 C++ 标准提案的历史,会知道hive这个名字并不是一开始就存在。这个容器早期在第三方库中叫plf::colony,作者 Matt Bentley 实现了大量优化,后来被提案 P0447 纳入 C++ 标准讨论流程,并最终考虑命名为std::hive。提案曾经反复修改接口与迭代器语义,例如如何分配块、如何处理删除后留下的空洞、如何保证异常安全等。
从 C++ 社群目前讨论看,std::hive的定位比较明确:它是一个“基于内存块组织的节点容器”,元素在块内连续保存,但块之间并不要求连续。正因为每个块内部都是连续内存,遍历时能把块内局部性发挥出来;又因为块与块相互独立,某个块扩容时其他块里的元素不需要移动。这样它既提供了类似链表的指针稳定性,又避免了链表节点一个个散落在内存里的问题。
在标准库里,std::list的迭代器只要求“当前元素被删除,其他迭代器不受影响”,但实际遍历速度要看节点分配情况。std::hive进一步强调了内存连续性:当删除元素后,删除的位置会被标记为“已空”,之后新的插入会优先填充这些空位,从而尽量保持容器紧凑。这就是它敢于宣称“缓存友好”的底层原因。
1.3 不要把hive当成万能容器
第一次了解hive的人,很容易产生“它能全面替代 vector”的误解。事实并非如此,vector和hive各有明确的适用边界:如果需要随机访问元素,例如经常写arr[i],应该继续使用std::vector;如果数据几乎只从尾部追加,也很少删除,vector.push_back的摊销成本依然很优秀。hive的真正优势集中在两个条件同时成立的场景:一是需要稳定指针/引用,二是插入、删除与遍历高频混合出现。
这个概念先行的小节其实在回答一个问题:std::hive的快,不是“无脑快”,而是在特定内存访问模式下非常快。要理解它为什么快,必须看它的内部数据结构,而不是只看外部接口。
2. 设计原理:为什么 hive 可能这么快
2.1 分块连续存储的内存布局
hive的内存分配单位是“块”,每个块内部是一段连续的数组,块中元素的类型相同。块大小可以根据元素类型和插入数量动态决定,通常不会是固定的 1 个或 2 个元素,而是一个相对较大的组块。元素存放在块的某个槽位上,每个槽位有“存活”和“空置”两种状态,容器内部会用元数据记录空位。
用一个简化的内存布局图来看:
Block 0: [E0] [E1] [空] [E3] [E4] [空] [E6] Block 1: [E7] [空] [E9] [空] [空] [E12] Block 2: ...当遍历第一个块时,CPU 会把整段连续内存载入缓存,即使中间有少量空位,容器也能借助元数据跳过它们,不需要跳转到毫无关联的内存地址。这和list每个节点各自 new 的行为完全不同,链表节点地址分布是随机的,几乎无法利用 CPU 预取特性。
块的分配策略也让元素地址保持稳定。普通数组在扩容时通常要“整体换一块更大的内存”,所有元素都会被移动;hive的扩容只需要新增一个块,已经创建的块不移动,已经保存的元素地址自然也不会改变。这样外部持有的指针、引用都保持有效,不会因为后续插入或扩容而悬空。合理理解这个机制,就会发现它在很多场景下比vector对用户更友好。
2.2 删除不搬移,空位优先复用
hive最容易被误用的设计是“删除并不立即减少容器所占内存”。当erase一个元素时,容器只是把该元素所在的槽位标记为“已删除”,不会移动后面的元素来填补缺口。这样做的好处是删除成本只是常数级别的元数据更新,不会引起大规模元素搬运。
但空洞如果一直保留,遍历时又需要跳过大量空槽,性能会下降。为了缓解这一点,hive在插入新元素时会优先寻找已有空洞并填充,而不是立刻申请新块。这种机制保证了,如果“删除”和“插入”行为交替出现,容器的空洞会被后续插入复用,整体占用和遍历密度都会趋于稳定。
再进一步看,删除多个元素后,并没有大规模地把所有剩余元素重新排列,而是保留了一个相对紧凑的布局。因此,在“删除一半元素,再继续插入并反复遍历”这种典型业务场景中,hive不会像链表那样产生大量内存碎片,也不会像 vector 那样为了填补空洞而付出 O(n) 的搬移时间。这个设计代价是部分已删除槽位暂时不能归还给系统,如果容器长期只删不插、且要求内存尽快释放,那么需要额外考虑。
2.3 与 vector、list、deque 的对比
把常见容器的特性放到一个表格里,能比较直观地看到hive的定位:
| 容器 | 元素地址稳定性 | 中间插入/删除 | 批量尾部插入 | 随机访问 | 遍历缓存友好度 |
|---|---|---|---|---|---|
| vector | 扩容时会失效 | O(n) 平移 | 摊销 O(1) | 支持,O(1) | 非常高 |
| deque | 可能失效 | 大概率部分移动 | 摊销 O(1) | 支持 | 较高 |
| list | 稳定 | O(1) | O(1) | 不支持 | 低 |
| hive | 稳定 | 平均 O(1) 左右 | 摊销 O(1) 左右 | 不支持 | 高 |
这里说“平均 O(1) 左右”,是因为实际复杂度要取决于实现是否命中空洞、是否需要开辟新块。但相比 vector 删除中间的 O(n),依然有本质差别。同时,它的迭代器稳定性比 deque 好,遍历性能又明显优于 list,所以它更像一个“带数组速度的节点型容器”。
hive的缺点是显而易见的:它不支持随机访问,没法用it + n直接跳转;排序算法通常要求随机访问迭代器,因此std::sort不能直接用在hive上。如果业务代码极端依赖按下标访问,hive并不合适。理解了这些特性后,再看性能 benchmark 才有意义。
3. 实验准备:用什么模拟 std::hive
3.1 为什么现在还不能直接使用 std::hive
到目前为止,主流编译器的标准库实现主要以 C++17、C++20、C++23 为目标。std::hive虽然在 C++26 的讨论中非常重要,但标准化文档还没有化为 libstdc++、libc++ 或 MSVC STL 里可用的头文件。直接写#include <hive>通常是不存在的。
因此,社区普遍使用plf::colony作为实验基座。它由提案作者维护,接口与性能优化方向与 P0447 中设计的std::hive高度一致。你可以把它理解成一个“几乎就是 std::hive”的开源参考实现,但由于标准仍在演进,接口细节可能存在少量差异。比如类可能叫plf::colony,方法可能叫insert而不是push_back,实际使用时应以头文件内注释和后续标准草案为准。
为了减少实验风险,我们要做以下准备:
- 一个支持 C++17 或 C++20 的编译器,例如 GCC、Clang、MSVC;
- 从 plf 库官方仓库下载头文件
plf_colony.h,它通常是一个单头文件; - 编译时开启
-O2或更高优化,否则性能对比结果会有偏差; - 不要依赖任何外部动态链接库,这个库是 header-only,使用起来很轻。
3.2 最小可运行示例
先写一个最简示例,验证环境是否正常。下面代码会创建一个plf::colony<int>,依次插入 0 到 99,然后删除所有是 7 的倍数的元素,最后再次遍历输出:
// 文件路径:example.cpp #include <iostream> #include "plf_colony.h" int main() { plf::colony<int> c; for (int i = 0; i < 100; ++i) { c.insert(i); } // 删除所有能被 7 整除的元素 for (auto it = c.begin(); it != c.end();) { if (*it % 7 == 0) { auto toErase = it; ++it; c.erase(toErase); } else { ++it; } } std::cout << "剩余元素个数: " << c.size() << "\n"; for (int v : c) { std::cout << v << " "; } std::cout << "\n"; return 0; }plf::colony的insert类似于push_back,在末尾追加新元素;begin/end和标准容器一样,可以直接配合 range-based for 使用。删除元素时,先把当前迭代器保存到toErase,然后把正式迭代器移动到下一个有效位置,再删除旧迭代器,这样能安全遍历。
编译命令如下:
g++ -std=c++17 -O2 example.cpp -o example如果一切正常,程序会输出 86 个剩余元素,因为 0 到 99 中能被 7 整除的数是 14 个(0、7、14、...、98),100 减去 14 等于 86。“剩余元素个数”这一行基本可以验证环境没问题。
3.3 关于版本差异的提醒
由于std::hive还没有正式落地,你在不同资料里会看到不同接口叫法,例如有些文档仍然叫plf::colony,有些草案把插入方法设计成类似push_back或insert的统一接口。真实项目迁移到最终标准版时,需要做一定程度的适配。
本文所有代码和实验均以plf::colony表达std::hive的设计逻辑,性能数据和结论只能代表当前常见实现。真正的标准库版本因为要考虑通用分配器、异常规格、线程安全等更多因素,可能在这些性能数据上有一点点出入。不过这并不影响我们理解它的设计方向。
4. 完整 benchmark:std::hive 到底有多快
4.1 测试场景设计
为了回答标题中的问题,我们设计三个独立阶段:
- 批量填充:依次向容器尾部插入 100 万个 int,观察构造容器的成本。
- 批量删除一半元素:删除所有偶数元素,观察维护成本。对 vector,用 erase-remove 惯用法;对 list,用 remove_if;对 colony/hive,用迭代器逐个 erase。
- 遍历求和:删除后容器还有约 50 万元素,循环遍历并求和,观察缓存局部性。
这三个阶段合起来,正好模拟了一个典型的业务路径:数据先加载到容器,随后做一轮条件淘汰,之后需要高频读取剩余数据。对比对象是std::vector<int>、std::list<int>与plf::colony<int>。
为了避免单次运行受 CPU 频率波动影响,每个阶段都重复 3 次并取最好成绩。删除偶数这个条件对所有容器都是公平的,但是实现方式不同,因为是“各容器通常推荐的删除方式”。
4.2 benchmark 完整代码
下面是一份可以直接编译运行的完整代码,把它命名为bench_hive.cpp:
// 文件路径:bench_hive.cpp #include <algorithm> #include <chrono> #include <iostream> #include <list> #include <string_view> #include <vector> #include "plf_colony.h" using Clock = std::chrono::steady_clock; struct Result { double fill_ms = 0.0; double erase_ms = 0.0; double traverse_ms = 0.0; }; template <class C, class Fill, class Erase> static Result RunCase(int n, int trials, Fill fill, Erase erase) { Result best; for (int t = 0; t < trials; ++t) { C c; { auto start = Clock::now(); fill(c, n); auto end = Clock::now(); double ms = std::chrono::duration<double, std::milli>(end - start).count(); best.fill_ms = (t == 0) ? ms : std::min(best.fill_ms, ms); } { auto start = Clock::now(); erase(c); auto end = Clock::now(); double ms = std::chrono::duration<double, std::milli>(end - start).count(); best.erase_ms = (t == 0) ? ms : std::min(best.erase_ms, ms); } { volatile long long sink = 0; auto start = Clock::now(); long long sum = 0; for (auto const &v : c) { sum += v; } sink += sum; auto end = Clock::now(); double ms = std::chrono::duration<double, std::milli>(end - start).count(); best.traverse_ms = (t == 0) ? ms : std::min(best.traverse_ms, ms); } } return best; } static void FillVector(std::vector<int> &c, int n) { c.reserve(static_cast<size_t>(n)); for (int i = 0; i < n; ++i) { c.push_back(i); } } static void EraseEvenVector(std::vector<int> &c) { c.erase(std::remove_if(c.begin(), c.end(), [](int v) { return (v & 1) == 0; }), c.end()); } static void FillList(std::list<int> &c, int n) { for (int i = 0; i < n; ++i) { c.push_back(i); } } static void EraseEvenList(std::list<int> &c) { c.remove_if([](int v) { return (v & 1) == 0; }); } static void FillColony(plf::colony<int> &c, int n) { for (int i = 0; i < n; ++i) { c.insert(i); } } static void EraseEvenColony(plf::colony<int> &c) { for (auto it = c.begin(); it != c.end();) { if ((*it & 1) == 0) { auto toErase = it; ++it; c.erase(toErase); } else { ++it; } } } static void PrintResult(std::string_view name, Result const &r) { std::cout << name << " fill=" << r.fill_ms << "ms erase=" << r.erase_ms << "ms traverse=" << r.traverse_ms << "ms\n"; } int main() { const int n = 1000000; const int trials = 3; auto rv = RunCase<std::vector<int>>(n, trials, FillVector, EraseEvenVector); auto rl = RunCase<std::list<int>>(n, trials, FillList, EraseEvenList); auto rc = RunCase<plf::colony<int>>(n, trials, FillColony, EraseEvenColony); PrintResult("vector", rv); PrintResult("list ", rl); PrintResult("hive ", rc); return 0; }代码有一点需要注意:RunCase里没有做 CPU 预热,也没有把亲和性锁到某个核心。这里更想展示的是代码组织思路,而不是提供实验室级精确结果。实际对比时,建议关闭后台任务、固定 CPU 频率,并适当增加trials。
编译运行命令:
g++ -std=c++17 -O2 bench_hive.cpp -o bench_hive ./bench_hive如果你的plf_colony.h不在当前目录,需要用-I指定头文件路径。如果你的机器有多核或开启了频率调节,运行结果会有波动,可以多跑几次看中位数。
4.3 运行结果解读(预期)
在 100 万 int 规模下,我建议你观察以下趋势,而不是过分关注单个绝对值:
vector.fill通常是最快的,因为它在 reserve 后连续写入,几乎没有额外分支。hive.fill会比 vector 慢一点,但差距不应该非常夸张,因为它每次插入也要处理块分配与状态标记。list.fill通常最慢,因为每个节点需要单独分配,而且节点在内存中分散。- 删除偶数后,
vector.erase虽然也是 O(n),但现代 CPU 对 vector 连续内存很友好,实际往往不慢。 list.remove_if需要在链表节点间跳转,通常会明显更慢。hive.erase如果实现得当,会非常快,因为删除相当于标记槽位,不需要移动元素。- 最后的遍历,
vector依然最快;`h