前言
std::array是 C++11 引入的固定大小数组容器,定义在<array>中。它把原生数组“长度信息会退化丢失”的问题用类型系统补了回来:std::array<int, 5>的类型里就带着长度 5,传给函数时不会悄悄变成指针,size()也总是可用的。它不进行动态分配,元素连续存放,开销与原生数组基本一致。
访问元素的方法看起来很多:operator[]、at()、front()、back()、data()、std::get、迭代器、范围 for、结构化绑定。它们并不是可以随意替换的:有的做边界检查,有的不做;有的在空数组上未定义,有的返回指针,有的只能在编译期确定下标。用错一个,轻则抛异常,重则越界读写。
本文按“容器模型—下标访问—迭代器与结构化绑定—选择建议”的顺序,把std::array的元素访问方式逐个讲清,并指出每种方式在N == 0、越界、常量性等边界上的行为。代码以 C++17 为基准,涉及 C++20 的std::span会单独标注。文中不声称代码经过编译或运行,行为均按标准语义描述。
一、std::array 的模型
std::array<T, N>是一个聚合类型(aggregate),内部持有一个T[N]的数组,但没有用户声明的构造函数。因此它支持聚合初始化:
// 适用于 C++17
#include <array>
std::array<int, 5> a = {1, 2, 3}; // 前三个为 1,2,3,其余为 0
std::array<int, 5> b{}; // 全部为 0
std::array<int, 5> c; // 元素默认初始化,int 的值不确定c的写法常被误解为“自动清零”。实际上std::array的元素是内置类型时,默认初始化不会赋初值,读取c[0]是未定义行为(undefined behavior, UB)。要清零就写{},或调用fill(0)。
std::array的长度是编译期常量,N必须是常量表达式。它没有push_back、resize之类的接口,因为容量固定。需要运行期改变大小就用std::vector。
| 访问方式 | 是否检查边界 | 越界行为 | 返回类型 |
|---|
a[i] | 否 | UB | reference |
a.at(i) | 是 | 抛std::out_of_range | reference |
a.front() | 否 | 空数组时 UB | reference |
a.back() | 否 | 空数组时 UB | reference |
a.data() | 否 | 不检查,返回首元素指针 | T* |
std::get<I>(a) | 编译期检查 | 下标越界编译失败 | reference |
| 迭代器 / 范围 for | 否 | 解引用 end 是 UB | 迭代器 / 元素引用 |
二、下标访问:operator[] 与 at
operator[]是最直接的访问方式,语义与原生数组一致:不检查下标,i >= N时行为未定义。它适合循环边界已经确定、性能敏感的场合:
// 适用于 C++17
#include <array>
#include <iostream>
int main() {
std::array<int, 4> values{10, 20, 30, 40};
for (std::size_t i = 0; i < values.size(); ++i) {
std::cout << values[i] << '\n';
}
return 0;
}at()做边界检查,下标非法时抛出std::out_of_range:
// 适用于 C++17
#include <array>
#include <iostream>
#include <stdexcept>
int main() {
std::array<int, 4> values{10, 20, 30, 40};
try {
std::cout << values.at(2) << '\n';
std::cout << values.at(4) << '\n'; // 抛出 std::out_of_range
} catch (const std::out_of_range& e) {
std::cerr << "out of range: " << e.what() << '\n';
}
return 0;
}at()的检查不是免费的,但也不必然带来可观测的开销:如果下标能被编译器证明在范围内,优化器可能消除检查。不要凭直觉断言“at()一定慢多少”,需要的话用std::chrono::steady_clock自己测量,并注意测量结果受优化级别、分支预测和缓存状态影响。
front()和back()分别返回首元素和尾元素的引用,不检查容器是否为空。对std::array<T, 0>调用它们是 UB,因为根本没有元素:
// 适用于 C++17
#include <array>
int main() {
std::array<int, 0> empty{};
// empty.front(); // UB:空数组没有首元素
// empty.back(); // UB:空数组没有尾元素
return empty.empty() ? 0 : 1; // empty() 在 N == 0 时为 true
}data()返回指向内部数组首元素的指针,适合把数据交给 C 接口:
// 适用于 C++17
#include <array>
#include <cstddef>
#include <iostream>
int main() {
std::array<int, 5> values{1, 2, 3, 4, 5};
int* raw = values.data();
for (std::size_t i = 0; i < values.size(); ++i) {
std::cout << raw[i] << ' ';
}
std::cout << '\n';
return 0;
}对N == 0的std::array,data()的返回值不保证是空指针,也不应被解引用。data()返回的指针在数组对象存活期间有效;std::array不能重新分配,所以不存在std::vector那种扩容导致指针失效的问题,但对象本身被销毁后指针同样悬垂。
三、编译期下标:std::get 与结构化绑定
std::get<I>(a)要求I是编译期常量,返回第I个元素的引用。I >= N时是编译错误,而不是运行期异常:
// 适用于 C++17
#include <array>
#include <iostream>
#include <string>
int main() {
std::array<std::string, 3> names{"Alice", "Bob", "Carol"};
std::cout << std::get<0>(names) << '\n';
std::cout << std::get<2>(names) << '\n';
// std::get<3>(names); // 编译错误:下标越界
std::get<1>(names) = "Bobby";
std::cout << names[1] << '\n';
return 0;
}std::get也支持 const 和右值重载,对const std::array返回const T&,对右值返回T&&。它的价值在于下标在编译期确定,配合模板元编程或结构化绑定时很自然。
C++17 的结构化绑定可以直接把std::array拆成多个变量:
// 适用于 C++17
#include <array>
#include <iostream>
int main() {
std::array<int, 3> point{3, 4, 5};
auto [x, y, z] = point; // 拷贝出三个 int
std::cout << x << ' ' << y << ' ' << z << '\n';
auto& [rx, ry, rz] = point; // 引用绑定,可修改原数组
ry = 40;
std::cout << point[1] << '\n';
return 0;
}结构化绑定的变量个数必须与N一致,否则编译失败。auto [x, y, z] = point;产生的是三个副本;要修改原数组必须写auto&。这一点和范围 for 里auto与auto&的区别是同一个道理。
四、迭代器、范围 for 与算法
std::array提供begin()/end()、cbegin()/cend()、rbegin()/rend(),迭代器在常见实现里就是指针,支持随机访问。范围 for 是最简洁的遍历方式:
// 适用于 C++17
#include <algorithm>
#include <array>
#include <iostream>
int main() {
std::array<int, 6> scores{72, 88, 95, 60, 88, 100};
for (const auto& score : scores) {
std::cout << score << ' ';
}
std::cout << '\n';
std::sort(scores.begin(), scores.end());
auto it = std::find(scores.begin(), scores.end(), 95);
if (it != scores.end()) {
std::cout << "index = " << (it - scores.begin()) << '\n';
}
std::cout << "size = " << scores.size() << '\n';
return 0;
}std::sort需要随机访问迭代器,std::array满足;std::find返回迭代器,找不到时返回end()。用it - scores.begin()得到下标,类型是std::ptrdiff_t,输出到std::cout没有问题。
只读遍历时用const auto&避免每个元素一次拷贝。如果元素是廉价的int,按值auto也可以;如果是std::string或大结构体,按值遍历会产生明显复制。
std::array还可以配合<numeric>的算法,例如std::accumulate:
// 适用于 C++17
#include <array>
#include <numeric>
int main() {
std::array<int, 5> values{1, 2, 3, 4, 5};
int total = std::accumulate(values.begin(), values.end(), 0);
return total == 15 ? 0 : 1;
}注意accumulate的初值0是int,累加结果也按int计算;如果元素是double,初值要写0.0,否则每次累加都可能发生截断。
作为函数参数
按值传递std::array会复制整个数组;按引用传递保留长度信息:
// 适用于 C++17
#include <array>
#include <cstddef>
template <std::size_t N>
void print_all(const std::array<int, N>& values) {
for (std::size_t i = 0; i < N; ++i) {
// 读取 values[i]
}
}
void print_six(const std::array<int, 6>& values) {
// 只接受长度为 6 的数组
}模板参数N让函数对任意长度工作,同时保留类型安全;写死6则只接受那一种长度。不要把std::array传给期望const int*的函数,否则又会退化成指针,长度信息丢失。
C++20 引入了std::span,可以用一个轻量视图同时表达“指针 + 长度”,适合接受任意连续序列:
// 需要 C++20 及以上
#include <cstddef>
#include <span>
void print(std::span<const int> values) {
for (std::size_t i = 0; i < values.size(); ++i) {
// 读取 values[i]
}
}std::span不拥有数据,调用者必须保证底层数组在调用期间存活。它需要 C++20 及以上的标准库支持,使用前应确认工具链。
完整示例:统计分数分布
// 适用于 C++17
#include <algorithm>
#include <array>
#include <cstddef>
#include <iostream>
int main() {
std::array<int, 10> scores{72, 88, 95, 60, 88, 100, 45, 77, 88, 91};
std::array<int, 5> buckets{}; // 0-59, 60-69, 70-79, 80-89, 90-100
for (int score : scores) {
std::size_t index = 0;
if (score >= 90) {
index = 4;
} else if (score >= 80) {
index = 3;
} else if (score >= 70) {
index = 2;
} else if (score >= 60) {
index = 1;
}
++buckets[index];
}
const std::array<const char*, 5> labels{
"0-59", "60-69", "70-79", "80-89", "90-100"};
for (std::size_t i = 0; i < buckets.size(); ++i) {
std::cout << labels[i] << ": " << buckets[i] << '\n';
}
std::sort(scores.begin(), scores.end());
std::cout << "lowest = " << scores.front() << '\n';
std::cout << "highest = " << scores.back() << '\n';
std::cout << "median = " << scores[scores.size() / 2] << '\n';
return 0;
}这里buckets用{}清零,labels用聚合初始化;scores.front()和back()在排序后分别是最小值和最大值。scores.size() / 2一定小于 10,所以operator[]安全。若数组长度可能为 0,就不能这样调用front()/back()。
常见坑点
- ❌ 用
values[values.size()]访问尾后位置,认为它和end()一样安全。
✅operator[]不检查边界,尾后位置是 UB;遍历用i < size(),需要检查时用at()。
- ❌ 声明
std::array<int, 5> a;后直接读取元素,以为会自动清零。
✅ 用std::array<int, 5> a{};值初始化,或调用a.fill(0)。
- ❌ 对
std::array<T, 0>调用front()、back()或解引用data()。
✅ 先用empty()判断;空数组只做遍历(此时begin() == end())或传递,不解引用。
- ❌ 写
std::get<5>(a)期望运行期抛异常。
✅std::get的下标必须是编译期常量,越界直接编译失败;运行期检查用at()。
- ❌
for (auto value : arr)后修改value,期望改变数组元素。
✅ 要修改写for (auto& value : arr);只读用const auto&。
- ❌ 把
std::array传给void f(const int* p),在函数里用sizeof(p)求长度。
✅ 传const std::array<T, N>&或std::span(C++20),保留长度信息。
- ❌ 保存
data()返回的指针后,认为它永远有效。
✅ 指针只在数组对象存活期间有效;对象销毁后立即失效,不要跨作用域保存。
- ❌ 用
at()却不处理std::out_of_range,异常一路逃逸导致程序终止。
✅ 在可能越界的入口捕获并处理,或先用size()校验下标。
总结
| 需求 | 推荐方式 | 注意 |
|---|
| 已确认下标合法的高频访问 | operator[] | 越界是 UB |
| 需要运行期边界检查 | at() | 越界抛std::out_of_range |
| 首元素 / 尾元素 | front()/back() | 空数组时 UB |
| 编译期已知下标 | std::get<I> | 越界编译失败 |
| 拆成多个变量 | 结构化绑定(C++17) | 数量必须等于N |
| 交给 C 接口 | data() | 不检查长度,注意空数组 |
| 遍历 | 范围 for +const auto& | 修改元素用auto& |
| 传参保留长度 | const std::array<T, N>&或模板 | 避免退化为指针 |
std::array的访问方式没有绝对优劣,关键是把“边界是否已知”“是否需要检查”“是否要修改”三个问题先回答清楚。下标确定且经过验证时用operator[],边界来自外部输入时用at(),编译期固定位置用std::get,遍历时根据是否需要修改选择auto&或const auto&。只要不越界、不读未初始化元素、不在空数组上取首尾,std::array就是一个几乎没有额外成本、又比原生数组更安全的固定长度容器。