std::array经常被当成「多此一举的包装」:既然int a[5]就够了,为什么要包一层?答案是两个词:零开销和不退化。零开销的意思是sizeof(std::array<int, 5>) == sizeof(int[5]),一个字节都没多;不退化指的是它把裸数组最容易丢掉的东西(长度)留住了。这篇用sizeof实测、用退化过程图说明裸数组的坑,最后给一张按场景选容器的决策表。
官方文档:std::array (cppreference)
1. 引子:一个「传进函数就变指针」的数组
void print_size(int arr[5]) { // 这里的 5 是骗人的 std::cout << sizeof(arr) << '\n'; // 打印的是指针大小,不是 20 }形参写成int arr[5],编译器会把它改写成int* arr——那个5被彻底忽略。于是函数里既不知道长度,也没法遍历。std::array解决的正是这个问题,而且不额外收费。先验证「不额外收费」这件事:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo #include <array> #include <cstddef> #include <iostream> #include <iterator> int main() { std::array<int, 5> a{1, 2, 3, 4, 5}; int raw[5]{1, 2, 3, 4, 5}; std::cout << "sizeof(std::array<int,5>) = " << sizeof(a) << '\n'; std::cout << "sizeof(int[5]) = " << sizeof(raw) << '\n'; std::cout << "sizeof(int) * 5 = " << sizeof(int) * 5 << '\n'; // 零开销:封装没有引入任何额外字节 static_assert(sizeof(std::array<int, 5>) == sizeof(int[5]), "零开销封装"); static_assert(std::size(a) == 5, "std::size 对 std::array 也适用"); constexpr std::array<int, 5> kFixed{1, 2, 3, 4, 5}; static_assert(kFixed.size() == 5, "size() 是 constexpr"); std::array<int, 5> copy_of_a{a}; // 可以整体拷贝 std::cout << "整体拷贝后 copy_of_a[4] = " << copy_of_a[4] << '\n'; return 0; }sizeof(std::array<int,5>) = 20 sizeof(int[5]) = 20 sizeof(int) * 5 = 20 整体拷贝后 copy_of_a[4] = 520 == 20 == 20:std::array内部就是一个裸数组作为唯一成员,没有任何长度字段、没有虚表、没有指针间接,所以 sizeof 完全一致。编译期能力也跟着裸数组走:.size()是constexpr,static_assert能在编译期就把它验掉。
2. 裸数组的坑:退化与不能赋值
退化(decay)是裸数组最本质的问题:在大多数表达式里,数组名会自动转换成指向首元素的指针,长度信息随之消失。
裸数组退化过程 main 里: int raw[5] = {1, 2, 3, 4, 5}; ┌────┬────┬────┬────┬────┐ │ 1 │ 2 │ 3 │ 4 │ 5 │ sizeof(raw) == 20 └────┴────┴────┴────┴────┘ std::size(raw) == 5 ▲ │ 传参:形参写成 int* 或 int[] 时发生退化 │ void f(int* p) ← 函数只收到一个地址 ┌────┐ sizeof(p) │ 8 │ = 指针大小(64 位),长度信息丢了 └────┘ std::array 走的是同一条内存布局,但它是一个「对象」: void g(const std::array<int, 5>& arr) → arr.size() 依然可用 void h(const std::array<int, N>& arr) → 模板推导,N 也跟着传进去除了退化,裸数组还有两个动作做不了:不能整体赋值、不能用另一个数组初始化(int b[5] = a;直接编译错误)。std::array是聚合类型,有隐含的拷贝构造和拷贝赋值,这两个动作都能做。Core Guidelines 的SL.con.1就是把这条写成了规则:优先用std::array或std::vector,而不是 C 风格数组。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo #include <array> #include <cstddef> #include <iostream> #include <iterator> int main() { std::array<int, 3> a{1, 2, 3}; std::array<int, 3> b = a; // OK:整体拷贝 b[0] = 100; // b 与 a 各自独立 std::cout << "a[0]=" << a[0] << " b[0]=" << b[0] << '\n'; std::array<int, 3> c{}; c = a; // OK:可以赋值 std::cout << "赋值后 c[0]=" << c[0] << '\n'; int raw1[3]{1, 2, 3}; int raw2[3]{}; // int raw3[3] = raw1; // 反例,不要这么写:编译错误,裸数组不能这样初始化 // raw2 = raw1; // 反例,不要这么写:编译错误,裸数组不能整体赋值 for (std::size_t i = 0; i < std::size(raw1); ++i) raw2[i] = raw1[i]; // 只能逐元素搬 std::cout << "裸数组逐元素拷贝后 raw2[0]=" << raw2[0] << '\n'; return 0; }a[0]=1 b[0]=100 赋值后 c[0]=1 裸数组逐元素拷贝后 raw2[0]=13. 三者对比与选型
先看硬指标。std::array和裸数组的差别只在「类型安全」上,和vector的差别在「存储位置」上:
| 对比项 | std::array<T, N> | T[N](裸数组) | std::vector<T> |
|---|---|---|---|
| 大小是否编译期固定 | 是,N是模板参数 | 是 | 否,运行期可变 |
| 存储位置 | 栈上(随对象走) | 栈上 | 堆上(对象本身只有三个指针) |
| 是否知道自身长度 | 是,.size()且是constexpr | 否,退化后丢失 | 是,size()运行期查询 |
| 能否拷贝 / 赋值 | 能(整体拷贝) | 不能 | 能(深拷贝) |
| 传给函数 | 不退化,长度信息保留 | 退化成指针 | 传引用,本身也不退化 |
sizeof | sizeof(T) * N | sizeof(T) * N | 24(三个指针,实现定义) |
| 额外开销 | 0 | 0 | 一次堆分配 + 一次指针间接 |
| 边界检查 | .at()有,[]无 | 都没有 | .at()有,[]无 |
两个结论直接从表里读出来:std::array相对裸数组是纯赚——同样的字节数、同样的访问速度,换来长度信息、整体拷贝和模板推导能力,没有任何代价;vector相对两者的代价是「一次堆分配 + 一层指针间接」,但换来运行期可变的大小。只有当数组大小编译期就确定时,std::array才成立。
按场景选:
| 场景 | 选择 | 理由 |
|---|---|---|
| 元素个数编译期已知(配置表、查表数组、固定维度的矩阵行) | std::array<T, N> | 栈上分配、无堆开销、长度信息保留 |
| 元素个数运行期才知道(读文件、网络包、用户输入) | std::vector<T> | 只有它能运行期增长 |
需要和 C API 交互(memcpy/ 三方库 / 系统调用) | std::array或vector的.data() | 二者都保证连续存储,.data()拿得到裸指针 |
必须使用 C 风格数组类型的地方(如T arr[3]的旧接口) | 裸数组 | 没办法;但可以包一层std::array再取.data() |
| 超大且栈空间紧张(几十 MB) | std::vector<T> | 栈通常只有几 MB,大数组会直接栈溢出 |
官方文档:C++ Core Guidelines · SL.con.1 (isocpp.github.io)
4. std::array 作为函数参数:长度信息不丢
这是std::array最实用的地方。把形参写成template <std::size_t N>加const std::array<T, N>&,编译器会把N一并推导出来,函数体里既有长度也能遍历,而且对任意N都只写一份代码:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo #include <array> #include <cstddef> #include <iostream> #include <iterator> // 裸数组按值传参 → 退化成指针,长度信息丢失 void take_raw_ptr(int* p) { std::cout << "裸数组(指针): sizeof(p)=" << sizeof(p) << "(指针大小,不是数组大小)\n"; } // 传引用能保住长度,但长度被写死成 5 void take_raw_ref(int (&arr)[5]) { std::cout << "裸数组(引用): 元素个数=" << std::size(arr) << ",只能接受恰好 5 个元素的数组\n"; } // std::array + 模板参数 N:长度信息保留,且任意 N 都能用 template <std::size_t N> void take_array(const std::array<int, N>& arr) { std::cout << "std::array: 元素个数=" << arr.size() << ",sizeof=" << sizeof(arr) << "(编译期就知道)\n"; } int main() { int raw[5]{1, 2, 3, 4, 5}; const std::array<int, 5> a{1, 2, 3, 4, 5}; const std::array<int, 3> b{1, 2, 3}; std::cout << "main 里 sizeof(raw)=" << sizeof(raw) << '\n'; take_raw_ptr(raw); take_raw_ref(raw); take_array(a); take_array(b); // 同一份模板,换 N 直接复用 return 0; }main 里 sizeof(raw)=20 裸数组(指针): sizeof(p)=8(指针大小,不是数组大小) 裸数组(引用): 元素个数=5,只能接受恰好 5 个元素的数组 std::array: 元素个数=5,sizeof=20(编译期就知道) std::array: 元素个数=3,sizeof=12(编译期就知道)三种写法的取舍很清楚:
| 传参写法 | 长度信息 | 能否接受任意长度 | 代价 |
|---|---|---|---|
void f(int* p) | 丢失 | 是(但要额外传长度) | 最危险:长度和指针可能对不上 |
void f(int (&arr)[5]) | 保留 | 否,长度写死 | 每个长度都要重载一份 |
void f(const std::array<int, N>&) | 保留 | 是,N自动推导 | 模板实例化,代码量随 N 增长 |
take_raw_ptr那种「裸指针 + 额外长度参数」的写法还有个隐患:两个参数各说各话,调用方传错也没人拦。std::array把长度编码进类型,sizeof、size()、边界检查全都在编译期就确定了,这也是 Core GuidelinesES.27(用std::array或stack_array表示栈上的数组)想表达的东西。
官方文档:std::size (cppreference)
5. 完整示例:三种容器在同一个程序里各司其职
一个程序里同时出现三者是很正常的:固定个数的配置用std::array,运行期才知道多少条的数据用std::vector,最后统一用.data()交给只认「指针 + 长度」的 C 风格函数:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo #include <array> #include <cstddef> #include <iostream> #include <numeric> #include <vector> // 模拟一个 C API:只认「指针 + 长度」 int c_style_sum(const int* data, std::size_t n) { int total = 0; for (std::size_t i = 0; i < n; ++i) total += data[i]; return total; } // 长度信息写在类型里:N 由编译器推导 template <std::size_t N> int sum_all(const std::array<int, N>& arr) { int total = 0; for (const int x : arr) total += x; return total; } int main() { const std::array<int, 4> fixed{10, 20, 30, 40}; // 个数编译期已知 → 栈上,零堆开销 std::vector<int> dynamic; // 个数运行期才知道 → 堆上 for (int i = 1; i <= 5; ++i) dynamic.push_back(i * 100); std::cout << "std::array 求和 = " << sum_all(fixed) << '\n'; std::cout << "vector 求和 = " << std::accumulate(dynamic.begin(), dynamic.end(), 0) << '\n'; std::cout << "两者一起交给 C API = " << c_style_sum(fixed.data(), fixed.size()) + c_style_sum(dynamic.data(), dynamic.size()) << '\n'; return 0; }std::array 求和 = 100 vector 求和 = 1500 两者一起交给 C API = 1600三个容器共存的方式就是这样:能用std::array的地方绝不用vector(省掉堆分配和指针间接,数据就在栈上、缓存友好);只有长度运行期可变时才用vector;两者都能用.data()卸掉封装去适配 C 接口,std::array的.data()返回的就是内部那个裸数组的首地址。
官方文档:std::array::data (cppreference)
6. 延伸阅读
- std::array (cppreference):成员列表与「不退化、可拷贝」的说明,注意它是聚合类型。
- Array declaration (cppreference):裸数组的退化规则(array-to-pointer conversion)原文。
- std::vector (cppreference):堆分配、可增长,与这篇的选型对照看。
- std::size (cppreference):C++17 起对裸数组和容器通用的长度获取方式。
- C++ Core Guidelines (isocpp.github.io):SL.con.1(容器优先于 C 数组)、ES.27(栈数组用
std::array)。
本知识库内的相关篇目:
- 《vector 扩容策略与迭代器失效全解》 —— vector 的 size 与 capacity 为什么分开、push_back 触发的扩容到底做了什么
- 《std::string 高频用法与 SSO 小字符串优化》 —— 为什么创建一个小字符串完全不碰堆、string 对象里那 32 个字节都装了什么?这篇用内存布局图和「数
- 《list 与 forward_list:链表真的比 vector 快吗》 —— 用计数分配器和实测耗时把 std::list / std::forward_list 的真实开销算清楚
7. 一句话总结
std::array<T, N>就是「内嵌了一个T[N]的对象」,所以sizeof与裸数组完全相同、访问也没有任何间接,却补上了裸数组丢失的长度信息、整体拷贝赋值能力和模板推导能力;固定大小编译期已知就用它,运行期才知道大小就用std::vector,只有要适配 C 风格数组类型的旧接口时才写裸数组。