C++ 的新标准容器:flat_map、inplace_vector 与 mdspan
这个仓库已经开源!现代化 C++(C++11/14/17/20)从基础到进阶的系统教程都在这里,力争做一条完备的现代 C++ 学习路径!欢迎各位大佬前来参观,喜欢的话点个⭐!
Github 一键直达: https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP
看看超酷的新网站:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/
想找其他伙伴的话在这里!https://github.com/orgs/Awesome-Embedded-Learning-Studio/discussions/9,加入我们!
这一篇讲什么:C++23/26 补的几个长期缺口
标准库的 container 家族从 C++98 定下来后稳定了二十多年,vector/map/unordered_map 这一套咱们天天用,几乎没变过。但实战里一直有几个缺口被人念叨:有序的关联容器能不能别用红黑树、改用连续存储换 cache 友好?定长的 array 和会堆分配的 vector 之间,能不能有个"容量上限已知、运行期可变长、又绝不碰堆"的中间态?多维数据(矩阵、图像、体素)能不能有个像 span 一样的非拥有多维视图?C++23 和 C++26 这两波正好把这几样补上了。这一篇咱们讲 flat_map/flat_set、inplace_vector、mdspan 三个已经标准化的,顺带提一句还在路上的 hive。
有个预防针得打在前面:这些组件都很新,flat_map 和 mdspan 是 C++23(要较新的 libstdc++/libc++),inplace_vector 是 C++26,工具链跟不上的话编不过。不过理解它们的设计思路比马上能用更重要,等您升上 C++23/26 工具链,这些就是现成的弹药。本文所有例子都在 GCC 16.1.1(libstdc++,-std=c++23 / -std=c++26)上实跑通过:<flat_map> 和 <mdspan> 从 GCC 15 起就有,<inplace_vector> 要 GCC 16。
flat_map / flat_set:把红黑树拍平成排序 vector(C++23)
头一批登场的是 std::flat_map 和 std::flat_set(连同 flat_multimap/flat_multiset,一共四个)。它们的动机很直接:咱们在 map 与 set 深入 里见过,map/set 底层是红黑树,每个元素一个堆节点,节点之间靠指针串,查找遍历都在节点间跳,cache 命中率差------复杂度虽然是 O(log n),常数因子却被 cache 不友好吃掉一大块。flat_map 的做法是把整棵树拍平成一个排序的连续容器 (默认底层就是 std::vector),键值对在内存里挨着排好,查找用二分(O(log n)),但因为是连续内存,cache 友好,实际常数比红黑树小一截。
接口上 flat_map 几乎能直接替换 map------insert/erase/find/operator[]/范围遍历都在,甚至有序遍历也能用,咱们迁移起来成本很低。但代价也很清楚,全来自"底层是连续容器"这个事实。最明显的是插入和删除都成了 O(n) :要在有序数组中间塞一个元素,得把它后面所有元素往后挪,删一个同样要往前挪,跟红黑树 O(log n) 的增删一比差距就出来了,所以 flat_map 适合"查找和遍历远多于增删"的场景。另一条是迭代器和引用不稳定 :任何插删都可能像 vector 一样触发搬移甚至重分配,让所有迭代器失效,而 map 的迭代器是永不失效的。把这些合起来看,flat_map 是拿"增删变贵、失效变凶"去换"查找遍历更快的常数",数据量不大、读多写少时它往往是更合适的选择。
cpp
#include <flat_map>
#include <print>
#include <string>
int main()
{
std::flat_map<int, std::string> m;
m.insert({3, "three"});
m.insert({1, "one"});
m.insert({2, "two"}); // O(n):维护有序要搬移
auto it = m.find(2); // O(log n):二分,连续内存 cache 友好
std::println("find(2) = {}", it->second);
m.erase(1); // O(n):删了要往前挪
// it 在这里已失效------和 vector 一样,别再用
for (auto [k, v] : m) { // 有序遍历:1 已删,剩下 2, 3
std::println("{}: {}", k, v);
}
return 0;
}
inplace_vector:定容、不堆分配的变长容器(C++26)
接着说 std::inplace_vector<T, N>,C++26 进的标准(提案 P0843)。它填的是 array 和 vector 中间的缝:array<T, N> 大小编译期定死、不能变;vector<T> 能变长但要堆分配,扩容时 new 新块、拷贝、释放旧块。很多时候咱们要的其实是"容量上限编译期知道、运行期 size 可变、但绝不碰堆",inplace_vector 就是干这个的。它的元素直接存在对象内部 (对象本身占 sizeof(T) * N 那块空间,放栈上或静态区),运行期可以在 0 到 N 之间增删,不 new、不扩容、不拷贝搬移。
最讨喜的一个性质是:当 T 是 trivially copyable 时,inplace_vector<T, N> 本身也是 trivially copyable 。这意味着它可以整体 memcpy、可以放进寄存器、可以安全交给 DMA,对咱们做嵌入式和系统编程极重要------array 深入 讲过的"连续内存 + trivially copyable"的好处,inplace_vector 同样吃到,而 std::vector 因为持有一个堆指针、不是 trivially copyable,是吃不到的。容量超限时的行为也设计得克制:push_back 超过 N 会抛 std::bad_alloc(异常关闭时退化为 terminate);想避开异常,可以用 C++26 的 try_push_back/try_emplace_back,它们超限时不抛,返回 std::optional<T&>,拿空值代表失败,适合 -fno-exceptions 环境。
cpp
#include <cstdio>
#include <inplace_vector>
#include <optional>
#include <type_traits>
int main()
{
std::inplace_vector<int, 4> v; // 容量上限 4,绝不堆分配
static_assert(std::is_trivially_copyable_v<decltype(v)::value_type>); // 存储类型 int 是 trivially copyable
static_assert(std::is_trivially_copyable_v<decltype(v)>); // 整个 inplace_vector 也会是 trivially copyable
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4); // size 现在 4,无法再塞了
std::printf("size = %zu, max_size = %zu, capacity = %zu\n", v.size(), v.max_size(), v.capacity());
// 此时如果 v.push_back(5) 超容量,抛 bad_alloc
// 想避免异常就用 try_push_back / try_emplace_back:超限不抛,返回 std::optional<T&>,空值代表失败
std::optional<int&> res = v.try_push_back(5);
if (res.has_value()) {
std::printf("successfully pushed the fifth element %d", res.value());
} else {
std::printf("failed to push the fifth element due to out of fixed capacity");
}
return 0;
}
bash
g++ -std=c++26 -O2 -o /tmp/ipv_demo /tmp/ipv_demo.cpp && /tmp/ipv_demo
text
size = 4, max_size = 4, capacity = 4
failed to push the fifth element due to out of fixed capacity
咱们得把 inplace_vector 和 array 的边界拎清:array<T, N> 的 size 恒等于 N,是定长;inplace_vector<T, N> 的容量上限是 N,但 size 在 0 到 N 之间运行期可变。要定长用 array,要"上限已知 + 运行期可变 + 不堆分配"就用 inplace_vector。
mdspan:span 的多维版(C++23,切片在 C++26)
最后一个标准化的是 std::mdspan,C++23 进的标准(提案 P0009)。span 深入 讲过 span 是一维的连续内存视图,但现实里到处是二维三维数据------矩阵、图像、体素场、张量。过去咱们只能拿一个一维指针手动算下标(data[i * cols + j]),既丑又容易把行列搞反。mdspan 把这种"一块连续内存 + 一个多维形状"包成一个视图类型,咱们可以直接用多维下标 m[i, j] 访问,零拷贝、不持有数据、只描述"这块内存怎么按多维解释"。
它的模板参数有四个:元素类型、描述形状的 Extents(每个维度多大)、把多维下标映射成一维偏移的 LayoutPolicy(默认 layout_right,行优先,C/C++ 风格)、管元素读写的 Accessor(默认裸访问)。形状用 std::extents<IndexType, dims...> 描述,维度大小编译期已知就填常量、运行期才知道就填 std::dynamic_extent。嫌麻烦可以直接用 std::dextents<IndexType, Rank>,表示"Rank 个维度全动态"。访问用 m[i, j] 这种多维方括号下标 (靠 C++23 的多维 operator[] 语言特性 P2128),不是老的 m[i][j],后者会让人误以为返回子视图,实际上 mdspan 直接把多维索引算成一维偏移、返回元素引用。这里有个容易踩的坑:咱们要注意是方括号 m[i, j]、不是函数调用 m(i, j)。早期 mdspan 参考实现(Kokkos)确实用 operator(),但 C++23 标准化后统一改成了多维 operator[],这也是不少老教程和博客还在写 m(i, j) 的原因,照抄会编译不过。
cpp
#include <mdspan>
#include <cstdio>
int main()
{
int raw[12] = {
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
};
// 把 12 个 int 当成 3 行 4 列的二维视图,行优先
std::mdspan<int, std::extents<size_t, 3, 4>> m(raw);
std::printf("m[1,2] = %d\n", m[1, 2]); // 第 1 行第 2 列 = 7
std::printf("m[2,3] = %d\n", m[2, 3]); // 第 2 行第 3 列 = 12
// 维度运行期才知道:用 dextents
std::mdspan<int, std::dextents<size_t, 2>> d(raw, 3, 4);
std::printf("d[0,0] = %d, rank = %zu\n", d[0, 0], d.rank());
return 0;
}
bash
g++ -std=c++23 -O2 -o /tmp/mdspan_demo /tmp/mdspan_demo.cpp && /tmp/mdspan_demo
text
m[1,2] = 7
m[2,3] = 12
d[0,0] = 1, rank = 2
有个坑得提前说清:submdspan(切片)是 C++26,不是 C++23 。mdspan 在 C++23 落地时,切行、切列、切子块的功能没赶上,挪到了 C++26(P2630)。所以咱们想在 C++23 里取一行,还得自己算偏移;要等 C++26 工具链才能用 std::submdspan(m, std::full_extent, slice) 这种零拷贝切片。mdspan 的更大意义在于它是 std::linalg(线性代数库)的底座,后面的标准里,矩阵运算 API 都建立在 mdspan 之上。
还在路上:hive 等提案
最后说一个常被提及、但还没进标准 的:std::hive(来自 Matt Bentley 的 plf::hive,提案 P0909/P2826)。它是个"节点容器",设计目标是元素地址稳定(插删不影响其它元素的地址)、擦除快、遍历 cache 友好(用块组织节点而不是纯链表),适合那种"要长期持有指向元素的引用、又要频繁增删"的场景。截至 C++26 它仍是提案,没被采纳,想用现在只能上第三方 plf::hive 库。这里提一句是为了说明方向:标准委员会在认真考虑"比 list 更好用的节点容器",但它还不是 std:: 的一员,咱们写文章或简历时别把它写成"C++26 的 hive"。
临了收几句
这一波新容器各填一个坑。flat_map 给"想要有序、又想要 cache 友好"的场景,代价是增删 O(n) 和像 vector 一样的失效。inplace_vector 给"容量上限已知、运行期变长、绝不堆分配"的中间态,C++26 才有,trivially copyable 的特性对嵌入式很香。mdspan 给多维数据一个零拷贝的视图类型,C++23 就有,切片 submdspan 要等 C++26。三个都依赖较新的工具链,flat_map 要 C++23 库支持、inplace_vector 要 C++26,咱们落地前先确认编译器和标准库版本。容器主线到这里就收尾了,从 array 到新标准容器,存数据的家伙事儿讲全了;接下来教程会转向"遍历和操作数据"的迭代器与算法。