Chapter 11: Array
std::array 要解决的主问题是:当元素数量在编译期已经确定时,怎样把一段内嵌连续存储表达成标准容器,并让它进入 iterator、algorithm、tuple interface 和 constexpr 体系。读完本章后,读者应能判断一段固定容量数据应使用 std::array、C 数组还是 std::vector,并能解释该判断背后的对象模型、初始化规则、迭代器能力和工程边界。
贯穿本章的材料是一段固定协议头和一组固定权重。协议头的 magic 字节数量写在协议格式里,卷积权重的数量由算法形状决定。两者都要求“数量固定、元素内嵌、对象整体可复制或可作为成员出现”。这类需求用 std::array 表达时,容量成为类型的一部分,容器对象自身携带元素存储,算法仍然通过 begin() / end() 访问它。
C++ 工作草案把 <array> 描述为存储固定大小对象序列的类模板,并规定 array<T, N> 存储 N 个 T,因此 size() == N 是稳定不变量;它也是 contiguous container,并且可以用至多 N 个可转换到 T 的元素做列表初始化。这个标准边界可以从 C++ working draft 的 array overview 看到;cppreference 对 std::array 的整理也明确指出它封装固定大小数组、保持 aggregate 语义、提供标准容器接口、支持随机访问和连续迭代器。这里的重点是把这些规定转成可执行的工程判断。
本章的结论先给出:std::array 适合表达“大小属于类型、对象整体拥有元素、生命周期随宿主对象一起结束”的固定序列。它的性能判断来自内嵌连续存储,它的安全性来自容器接口保留长度和迭代器范围,它的限制来自大小固定和 swap / 赋值等操作按元素执行。
11.1 std::array 的固定大小对象模型
std::array<T, N> 是一个对象,它把 N 个 T 作为自身状态的一部分保存。这里的“固定大小”有两层含义:N 是模板实参,参与类型形成;对象创建之后,元素数量保持 N。因此 std::array<int, 4> 和 std::array<int, 8> 是两个不同类型,函数重载、模板匹配、结构体布局都会感知这个差异。
这个模型和 std::vector 的第一处差异在所有权路径。std::vector<T> 对象通常保存指针、大小和容量,元素存储位于动态分配区;std::array<T, N> 对象直接包含元素序列。局部自动对象中的 std::array 随自动存储期对象一起存在,成员中的 std::array 嵌入外层对象,动态创建的外层对象中也同样内嵌这段元素存储。准确说,std::array 自身不申请额外堆内存,元素位置由包含它的对象存储位置决定。
下面的代码把固定协议头写成对象状态。magic 的 4 个字节和 version 一起构成 PacketHeader 对象,复制 PacketHeader 时会逐元素复制 magic,销毁 PacketHeader 时会销毁其中每个元素。std::byte 的生命周期和外层对象生命周期绑定,额外 allocator 不参与这条路径。
#include <array>
#include <cstddef>
#include <cstdint>
struct PacketHeader {
std::array<std::byte, 4> magic;
std::uint16_t version;
};
PacketHeader header{
.magic = {std::byte{'P'}, std::byte{'K'}, std::byte{'T'}, std::byte{'1'}},
.version = 3,
};
这段代码展示了 std::array 对象模型的三个可观察结果。第一,magic.size() 在运行时返回 4,但这个 4 已经写入类型 std::array<std::byte, 4>。第二,magic.data() 指向一段连续的 std::byte 元素范围;标准规定非空数组中 data() 与首元素地址一致,且 [data(), data() + size()) 是有效范围。第三,PacketHeader 的对象大小会包含 4 个 std::byte 元素和 version 的布局、对齐与 padding,编译器按照普通成员布局处理它。
常见实现形状通常接近一个只含核心数组成员的 aggregate。例如可以把教学简化模型理解成下面这样,但这段代码只用于解释对象关系,不能当作某个标准库实现的真实源码。
template<class T, std::size_t N>
struct teaching_array_shape {
T elems[N];
constexpr T* begin() noexcept { return elems; }
constexpr T* end() noexcept { return elems + N; }
constexpr std::size_t size() const noexcept { return N; }
constexpr T* data() noexcept { return elems; }
};
这个简化形状说明了 std::array 的核心约束:元素存储在对象内部,范围边界来自 N,iterator 可以由连续位置表达。真实标准库实现需要处理 N == 0、类型别名、异常规格、constexpr、tuple interface 和标准容器要求,所以源码会比这段模型更完整。
N == 0 是对象模型的特殊边界。标准要求 std::array<T, 0> 支持零大小场景,此时 begin() == end();data() 的返回值未指定,front() 与 back() 没有有效元素可引用。工程判断上,零长度 std::array 可以作为模板计算或泛型代码的边界值,但读取首尾元素前必须先用 empty() 或 size() 建立前提。
11.2 aggregate initialization 与 constexpr 支持
std::array 是 aggregate,这一点决定了它的初始化主要服从 aggregate initialization 规则。aggregate initialization 的工作定义是:对象没有用户提供的构造函数参与实参解释,花括号中的初始化器按成员顺序进入对象内部。对 std::array<T, N> 来说,初始化器按顺序进入那 N 个元素,未显式给出的元素会按列表初始化规则补齐。
下面的代码展示固定权重的三种初始化状态。它要证明的问题是:std::array 看起来像容器,初始化时仍然保留 aggregate 的逐元素语义。
#include <array>
std::array<int, 3> weights_a{1, 2, 3};
std::array<int, 3> weights_b{1};
std::array<int, 3> weights_c{};
weights_a 的三个元素分别初始化为 1、2、3。weights_b 的第一个元素为 1,剩余元素通过值初始化得到 0。weights_c 的三个元素全部值初始化得到 0。这个规则对工程代码有直接影响:固定大小缓冲区使用 {} 初始化时可以获得确定初始值;省略花括号直接默认初始化 std::array<int, 3> weights; 时,标量元素会保留不确定值,后续读取会进入错误路径。
C++17 增加了 std::array 的类模板实参推导。表达式 std::array values{1, 2, 3}; 会推导出 std::array<int, 3>。标准中的推导指引要求所有实参类型一致;cppreference 的 deduction guide 页面也说明了 (std::is_same_v<T, U> && ...) 需要成立。这个要求让类型信息保持稳定:std::array mixed{1, 2u}; 这类混合整型实参不会自动合成一个共同类型。
#include <array>
#include <type_traits>
std::array values{1, 2, 3};
static_assert(std::is_same_v<decltype(values), std::array<int, 3>>);
// std::array mixed{1, 2u}; // C++17 CTAD 下类型不一致,程序不合格
constexpr 支持来自两个方向。第一,N 在编译期已知,size() 返回稳定常量。第二,C++ 标准持续扩展标准库函数的 constexpr 可用性;当前工作草案中的 array 成员函数大量带有 constexpr,iterator / const_iterator 也满足 constexpr iterator 要求。工程上可以把固定权重、查表常量、小型状态转移表放进 constexpr std::array,再在编译期函数中遍历它们。
#include <array>
constexpr std::array<int, 4> coeffs{1, 3, 3, 1};
constexpr int sum_coeffs() {
int sum = 0;
for (int value : coeffs) {
sum += value;
}
return sum;
}
static_assert(sum_coeffs() == 8);
这个例子体现的边界是:std::array 适合作为编译期固定数据的承载对象,但元素类型和所调用操作也必须能在常量求值中执行。std::array<std::string, 4> 在现代标准中能做的编译期操作受 std::string 版本能力、分配行为和编译器实现支持约束;判断时先看元素类型,再看所调用成员函数和算法是否具备对应标准版本的 constexpr 条件。
C++20 的 std::to_array 提供了从内建数组创建 std::array 的标准函数。标准草案规定左值数组版本按元素复制,右值数组版本按元素移动;cppreference 也把 to_array 标为 C++20。它常用于把字符串字面量或 C 数组入口转成保留大小信息的标准容器对象。
#include <array>
constexpr auto tag = std::to_array("PKT");
static_assert(tag.size() == 4); // 包含字符串结尾的 '\0'
这里的判断点是:std::to_array("PKT") 得到的是 std::array<char, 4>,因为字符串字面量对应的内建数组包含结尾空字符。协议字段若只需要 3 个可见字符,应显式截取或改用 std::array<char, 3>{'P', 'K', 'T'}。
11.3 iterator 支持与 STL 集成
std::array 能进入 STL 算法体系,原因在于它提供标准范围接口:begin()、end()、cbegin()、cend()、反向 iterator、data()、size()。cppreference 对 std::array 的成员类型说明中把 iterator 归入随机访问和连续迭代能力;工作草案也把 array 定位为 contiguous container。算法因此可以把 std::array 当作半开区间 [begin, end) 消费。
下面的代码把固定权重作为算法输入。代码要证明的是:std::array 无需转换成 std::vector 就能被 std::sort、range-for 和接受 iterator pair 的函数处理。
#include <algorithm>
#include <array>
#include <numeric>
int normalize_score(std::array<int, 4> scores) {
std::sort(scores.begin(), scores.end());
return std::accumulate(scores.begin(), scores.end(), 0);
}
这里 std::sort 能工作,是因为 std::array<int, 4>::iterator 具备随机访问能力。std::accumulate 只要求输入迭代能力,因此同一个 std::array 可以进入不同能力层级的算法。算法不关心底层对象叫 array、vector 还是原始指针范围;它关心迭代器是否能提供所需操作。
连续存储能力给 std::array 带来另一个接口入口:它可以自然传给 std::span。std::span 是非拥有视图,表达“借用一段连续元素”。当函数只需要读取或改写元素序列,同时不接管所有权时,使用 std::span 接收 std::array、std::vector 和 C 数组可以统一接口。
#include <array>
#include <span>
int checksum(std::span<const std::byte> bytes) {
int sum = 0;
for (std::byte b : bytes) {
sum += static_cast<unsigned char>(b);
}
return sum;
}
void use_header() {
std::array<std::byte, 4> magic{
std::byte{'P'}, std::byte{'K'}, std::byte{'T'}, std::byte{'1'}
};
(void)checksum(magic);
}
这个例子中的所有权边界很清楚:magic 仍然拥有元素,std::span 只保存指针和长度。函数返回后,magic 的生命周期继续由调用者控制。这个边界能减少接口模板数量,也能保留 std::array 的固定容量表达。
iterator 失效规则也和对象模型一致。std::array 没有插入、删除、扩容,元素存储地址在对象生命周期内保持稳定,因此指向元素的 iterator 通常不会因为普通元素赋值而失效。swap 是需要单独判断的边界:标准说明 array::swap 的效果等价于按元素 swap_ranges,复杂度线性;iterator 继续关联原来的 array 对象,指向的位置相同,但该位置中的值可能已经和另一个 array 的对应值交换。
#include <array>
void iterator_after_swap() {
std::array<int, 2> left{1, 2};
std::array<int, 2> right{9, 8};
auto it = left.begin();
left.swap(right);
// it 仍指向 left 的第一个元素;该元素的值现在是 9。
*it = 7;
}
这段代码说明了 std::array 的 iterator 稳定性来源:对象内部元素位置没有被重新分配。它也说明了稳定 iterator 和稳定值是两个不同判断。持有 iterator 的代码可以继续访问原位置,但该位置的元素值会随赋值、fill、swap 改变。
11.4 与 C 数组、vector 的判别维度
std::array、C 数组和 std::vector 都能表示连续元素序列,但它们回答的问题不同。选择时应使用同一组维度:大小是否属于类型、对象是否拥有元素、传参时是否保留长度、是否允许运行时改变大小、是否需要标准容器接口、是否涉及动态分配。
| 维度 | C 数组 | std::array<T, N> | std::vector<T> |
|---|---|---|---|
| 大小来源 | 声明处的数组边界 | 模板实参 N | 运行时 size / capacity |
| 类型是否包含大小 | 原始数组类型包含边界,函数形参中常退化 | 类型直接包含 N | 类型不包含当前 size |
| 元素所有权 | 数组对象拥有元素 | array 对象拥有元素 | vector 对象拥有动态存储中的元素 |
| 传参长度 | 裸数组形参常丢失边界 | 按引用传递保留 N | 对象保存 size |
| 大小变化 | 声明后固定 | 创建后固定 | 可增长、可缩减 |
| 标准容器接口 | 接口零散,需借助 std::begin 等 | 提供成员接口和 iterator | 提供成员接口、容量管理和修改操作 |
| 分配路径 | 随所在对象存储 | 随 array 所在对象存储 | 通常通过 allocator 管理动态存储 |
C 数组适合和 C ABI、底层结构、硬件寄存器映射或历史接口对接。它的优势是语言原生、布局直接;代价是接口能力弱,数组名在很多表达式和函数形参中会转换成指针,长度信息需要额外传递。代码中若经常同时传 ptr 和 len,就说明调用边界已经在手工补偿这个缺口。
std::array 适合把固定长度纳入类型系统。函数可以通过引用接收完整类型,从而在编译期保留长度。
#include <array>
#include <cstddef>
template<std::size_t N>
int sum_array(const std::array<int, N>& values) {
int sum = 0;
for (int value : values) {
sum += value;
}
return sum;
}
这段代码的关键在于 N 被保留在模板参数里。调用 sum_array(std::array<int, 4>{...}) 和 sum_array(std::array<int, 8>{...}) 会实例化不同版本,编译器可以看到长度,函数体也能使用标准 range-for。
std::vector 适合元素数量由输入、运行时策略或外部数据决定的场景。它通过 size / capacity 管理可变长度,通过 allocator 申请和释放元素存储。代价是对象状态多了一层动态存储关系,扩容可能移动元素并造成 iterator 失效,异常安全也需要考虑分配、构造、移动和提交点。
三者的工程判别可以压缩成一句话:固定长度、对象内嵌、要接入 STL 算法时选 std::array;运行时长度变化时选 std::vector;和语言级数组边界、C ABI 或特殊底层布局交互时保留 C 数组,并在接口层显式携带长度或转换成 std::span。
11.5 工程使用场景
std::array 的工程价值来自“固定容量即类型事实”。当容量由协议、数学维度、硬件通道数、颜色分量数、矩阵形状、小型查表数据决定时,使用 std::array 能让代码把这个事实写在类型里,并把对象生命周期、元素所有权和算法接口统一起来。
协议字段是典型场景。固定 magic、固定长度 nonce、固定大小 hash 片段都适合放进 std::array<std::byte, N>。这样做能让结构体成员携带长度,比较、复制、传参时保持对象语义;同时 data() 可以在边界清楚的情况下交给底层 I/O 或 C 接口。
#include <array>
#include <cstddef>
#include <span>
struct FileSignature {
std::array<std::byte, 8> bytes;
};
bool has_prefix(std::span<const std::byte> input, const FileSignature& signature) {
if (input.size() < signature.bytes.size()) {
return false;
}
for (std::size_t index = 0; index < signature.bytes.size(); ++index) {
if (input[index] != signature.bytes[index]) {
return false;
}
}
return true;
}
这个例子中,FileSignature 拥有 8 个字节;input 是外部数据视图;比较循环以 signature.bytes.size() 为固定边界。对象所有权、借用范围和固定长度分别由 std::array、std::span 和 size() 表达,调用者无需猜测 magic 长度。
小型数学数据也是稳定场景。二维点、RGBA 颜色、3x3 kernel、SIMD 前的数据打包,经常要求元素连续、数量固定、复制成本可控。使用 std::array<float, 9> 表示 3x3 kernel 时,函数签名就能表达“只接受 9 个系数”。如果未来 kernel 大小变成运行时配置,判断顺序会转向 std::vector 或专门矩阵类型。
std::array 也适合作为类成员保存固定数量的子状态。对象复制时,成员 array 逐元素复制;对象移动时,成员 array 逐元素移动;析构时,成员 array 的元素按对象成员规则销毁。这里没有单独的容量管理路径,也没有扩容失败路径。异常安全主要取决于元素类型的拷贝、移动、交换和赋值是否可能抛出。
array::swap 是性能判断中容易漏掉的一项。很多容器的 swap 可以通过交换控制块达到常数复杂度;std::array 的元素就在对象内部,交换两个 array 需要逐元素交换,所以复杂度是线性的。若 T 的 swap 可能抛出,array::swap 也会受影响。需要频繁交换大型固定块时,应把元素数量、元素 swap 成本和异常规格纳入判断。
可复用判断顺序如下。先判断长度来源:长度来自编译期固定事实时进入 std::array 候选,来自运行时输入时进入 std::vector 候选。再判断所有权:对象要拥有固定元素时使用 std::array,函数只借用连续范围时使用 std::span。接着判断接口边界:需要传给 C ABI 时使用 data() 和 size() 明确边界,需要 STL 算法时传 iterator 或 range。最后判断操作成本:关注按元素赋值、按元素 swap、元素类型异常规格和 N == 0 的首尾访问前提。
这一章最终建立的理解是:std::array 把固定长度、内嵌存储、对象语义和标准容器接口组合成一个类型。读 STL 源码或写工程代码时,看到 std::array<T, N> 应先读出 N 是类型事实,再读出元素生命周期随对象走,最后再判断算法、传参和性能边界。
最小自检任务
阅读下面代码,判断 header.magic、values、view 和 it 分别体现了 std::array 的哪一条工程规则,并说明 left.swap(right) 后 it 是否还能解引用、它指向哪个对象的元素、该位置的值来自哪里。
#include <array>
#include <cstddef>
#include <span>
#include <type_traits>
struct Header {
std::array<std::byte, 4> magic{};
};
void check() {
Header header{};
std::array values{1, 2, 3};
static_assert(std::is_same_v<decltype(values), std::array<int, 3>>);
std::span<const std::byte> view = header.magic;
std::array<int, 2> left{1, 2};
std::array<int, 2> right{9, 8};
auto it = left.begin();
left.swap(right);
(void)view;
(void)*it;
}
答案要点
header.magic 体现固定容量元素内嵌在外层对象中的规则;Header 对象拥有这 4 个 std::byte,没有单独 allocator 路径。values 体现 C++17 类模板实参推导,三个 int 实参推导出 std::array<int, 3>,长度进入类型。view 体现 std::array 的连续存储和标准范围接口,std::span 只是借用 header.magic 的元素范围,所有权仍由 header 持有。it 在 left.swap(right) 后仍能解引用,它继续指向 left 的第一个元素位置;由于 swap 按元素交换,left[0] 的值从原来的 1 变成 9。这里稳定的是 iterator 对象关联的位置,变化的是该位置中的元素值。
本章知识点总结
- 固定长度:
std::array<T, N>把元素数量N写入类型,并让size() == N成为对象不变量。 - 内嵌存储:
std::array自身不申请额外堆内存,元素随 array 所在对象的存储位置和生命周期存在。 - 对象语义:复制、移动、赋值、销毁
std::array时,核心动作按元素类型的对应操作展开。 - 零长边界:
std::array<T, 0>的begin()与end()相等,读取首尾元素前必须先确认存在有效元素。 - 聚合初始化:
std::array保留 aggregate 语义,花括号中的初始化器按元素顺序进入固定序列。 - 默认初始化:标量元素的默认初始化可能留下不确定值,使用
{}可以为固定缓冲区建立确定初始状态。 - CTAD 规则:C++17 的
std::array推导要求实参类型一致,推导结果同时包含元素类型和元素数量。 - constexpr 数据:固定长度和 constexpr 成员使
std::array适合承载编译期查表数据,但元素类型和操作也要满足常量求值条件。 - 算法入口:
std::array提供begin()、end()、data()和size(),因此能直接进入 STL 算法与std::span接口。 - iterator 稳定:array 元素存储在对象内部,普通赋值和 swap 不重新分配存储;swap 会改变对应位置的元素值。
- 线性 swap:
array::swap按元素交换,复杂度随N线性增长,异常边界取决于元素类型的 swap。 - 选型顺序:先判断长度来源,再判断所有权,再判断接口边界,最后检查按元素操作成本和零长访问前提。