C 风格数组是固定数量的同类型对象,元素在内存中连续排列,长度属于数组类型的一部分。
数组在多数表达式和函数形参中会转换成首元素指针;转换后,长度信息不会随指针一起传递,也没有自动边界检查。
长度固定且需要值语义时优先用 std::array,传递连续序列时用 std::span,长度运行时变化时用 std::vector。
是什么,为什么存在
C 风格数组(C-style array)是 C++ 的内置复合类型。声明 int readings[4] 会创建 4 个 int 子对象;它们的索引是 0 到 3,并在内存中连续排列。这个声明中的 4 是 数组边界(array extent) ,也是类型信息:int[4] 与 int[5] 是不同类型。
内置数组提供最直接的定长对象布局,不携带长度字段,也不分配额外控制块。结构体成员、字符串字面量、C 接口和底层库接口中都会遇到它。它的规则也解释了许多看似属于指针的问题,例如函数为何收不到数组长度,以及二维数组为何不能传给 int**。
这种类型来自语言核心,代价是能力很少。数组不能整体赋值,不能按值传给函数,也不能由函数按值返回;下标操作不检查边界。现代 C++ 代码通常让标准库类型承担所有权和接口表达,只在布局、互操作或非常局部的定长存储中直接使用内置数组。
三个标准库类型覆盖了常见意图:std::array<T, N> 拥有固定数量的元素并支持值语义,std::span<T> 是不拥有元素的连续视图,std::vector<T> 拥有运行时可变长度的序列。它们没有改变内置数组规则,但能把长度和所有权写进接口。
| 需求 | 通常使用 | 长度在哪里 |
|---|---|---|
| 固定长度并拥有元素 | std::array<T, N> | 类型中的 N |
| 观察或修改一段连续元素 | std::span<T> | span 对象或固定边界类型 |
| 运行时增长和缩小 | std::vector<T> | 容器对象 |
| 与要求内置布局的接口互操作 | T[N] 或 T* 加数量 | 数组类型或独立契约 |
字符数组仍然只是元素类型为 char 的数组。char name[] = "Ada" 的边界是 4,因为初始化会复制末尾的空字符 '\0'。只有满足终止约定的字符数组才能交给期望 C 字符串的 API;普通字符数组不自动成为字符串。
工作原理
声明 T values[N] 创建 N 个 T 元素,标准 C++ 要求 N 大于 0,并且它必须是编译期常量表达式。GCC 接受的零长数组或变长数组属于扩展,不是可移植的 C++23。运行时长度应由 std::vector 或其他动态存储表达。
数组对象的大小足以容纳全部元素。对于索引 i,表达式 values[i] 按定义等价于 *(values + i);这里先发生 数组到指针转换(array-to-pointer conversion) ,再进行指针运算和解引用。合法元素的索引必须小于边界。
指向元素的指针可以从首元素移动到尾后位置。尾后指针适合表示半开区间,但不能解引用。只有同一数组对象及其尾后位置内的指针运算才受语言规则保证;把指针继续移到该范围外,即使没有解引用,也不是合法的数组遍历。
初始化方式决定初值。初始化列表少于边界时,其余元素会被值初始化,所以 int counts[4]{2, 1} 得到 2, 1, 0, 0。没有初始化器的自动存储期基础类型数组含有不确定值,在给元素赋值前读取它们没有可用语义。
数组名称不是指针变量。sizeof values 作用于整个数组,&values 的类型是指向整个数组的指针,而 values 在大多数取值表达式中才转换成 T*。因此,sizeof values / sizeof values[0] 只在数组类型仍被保留的作用域内有效。
函数形参里的数组写法是一个容易误读的特例。void consume(int values[10]) 在调整后仍是 void consume(int* values);10 不会形成运行时检查,也不会成为形参类型的一部分。需要长度时,应额外传递长度、接收数组引用,或更常见地接收 std::span。
| 源码写法 | 在该位置保留的类型信息 |
|---|---|
int values[4] 局部变量 | int[4] |
auto pointer = values | int* |
auto& alias = values | int (&)[4] |
void read(int values[4]) 形参 | 调整为 int* |
void read(int (&values)[4]) 形参 | 指向 int[4] 的引用 |
std::span<T> 保存指针和边界,但不拥有数组,也不会延长数组生命周期。动态边界的 span 通常在运行时保存长度;固定边界的 std::span<T, N> 把 N 放进类型。C++23 的 span::operator[] 仍要求索引有效,它不是自动抛异常的安全下标。
多维声明由数组嵌套而成。int grid[2][3] 是含有 2 个元素的数组,每个元素又是 int[3]。连续的行子数组依次排列,形成 行主序(row-major order) ;grid[row][column] 先选中一行,再选中该行中的元素。
数组对象的生命周期取决于存储期。函数内普通数组在离开作用域时结束生命周期,静态数组持续到程序结束,new T[n] 创建的数组持续到匹配的 delete[]。从局部数组取得的指针或 span 不会在函数返回后继续拥有有效元素。
示例
保留边界并遍历
第一个示例用初始化列表创建数组,并让 std::size 从数组引用中取得边界。范围 for 也保留数组范围,因此循环体不需要手写尾后索引。
#include <iostream>
#include <iterator>
int main() {
int readings[]{18, 21, 19, 24};
std::cout << "count: " << std::size(readings) << '\n';
for (int& reading : readings) {
reading += 1;
}
std::cout << "adjusted:";
for (int reading : readings) {
std::cout << ' ' << reading;
}
std::cout << '\n';
}count: 4
adjusted: 19 22 20 25int& 让第一个循环直接修改数组元素。第二个循环按值读取每个 int。如果写成 auto pointer = readings,类型推导会先触发数组到指针转换;写 auto& same_array = readings 才会保留 int[4] 类型。
用 span 传递指针和长度
下一个示例让函数接收 span。调用方可传入内置数组,而函数同时得到首元素和元素数量,不必依赖一个容易不同步的裸指针参数。
#include <iostream>
#include <span>
int total(std::span<const int> values) {
int result = 0;
for (int value : values) {
result += value;
}
return result;
}
void replace_negative(std::span<int> values) {
for (int& value : values) {
if (value < 0) {
value = 0;
}
}
}
int main() {
int balances[]{12, -3, 8, -1};
replace_negative(balances);
std::cout << "total: " << total(balances) << '\n';
std::cout << "middle: " << total(std::span{balances}.subspan(1, 2)) << '\n';
}total: 20
middle: 8std::span<int> 允许 replace_negative 修改元素,std::span<const int> 则只提供只读访问。subspan(1, 2) 描述从索引 1 开始的两个元素;创建子视图前仍要保证范围合法。
用数组引用推导边界
模板可以通过数组引用保留边界,并把它推导为 N。这种接口适合确实只接受内置数组的窄范围工具;需要同时接收 std::array、vector 或子区间时,span 通常更简单。
#include <cstddef>
#include <iostream>
template<std::size_t N>
int last(const int (&values)[N]) {
static_assert(N > 0);
return values[N - 1];
}
template<std::size_t N>
void describe(const int (&values)[N]) {
std::cout << "extent=" << N
<< ", first=" << values[0]
<< ", last=" << last(values) << '\n';
}
int main() {
int ports[]{443, 8443, 9443};
int retries[]{1, 2, 3, 5, 8};
describe(ports);
describe(retries);
}extent=3, first=443, last=9443
extent=5, first=1, last=8两次调用实例化出不同的 N,所以 last 能在不接收第二个长度参数的情况下访问最后一个元素。该保证来自形参类型,不是从指针恢复出的信息。
传递二维数组
二维数组的列边界必须保留,编译器才能计算下一行的位置。数组引用把行数和列数都放进类型,并拒绝形状不匹配的数组。
#include <cstddef>
#include <iostream>
template<std::size_t Rows, std::size_t Cols>
void print_grid(const int (&grid)[Rows][Cols]) {
std::cout << "shape: " << Rows << 'x' << Cols << '\n';
for (std::size_t row = 0; row < Rows; ++row) {
std::cout << "row " << row << ':';
for (std::size_t column = 0; column < Cols; ++column) {
std::cout << ' ' << grid[row][column];
}
std::cout << '\n';
}
}
int main() {
int seats[2][3]{
{1, 0, 1},
{0, 1, 1},
};
print_grid(seats);
}shape: 2x3
row 0: 1 0 1
row 1: 0 1 1这里的 grid[row] 是一整个 int[Cols] 行数组,随后才转换成指向该行首元素的指针。若改成传统形参 const int grid[][3],它会调整为指向 int[3] 的指针,行数仍需另传;它从来不是 int**。
陷阱
越界与尾后指针
修复时使用半开区间:索引循环写 index < count,或直接使用范围 for、标准算法和 span。对来自外部的索引,在下标前显式检查 index < size。测试构建可启用 AddressSanitizer 和 UndefinedBehaviorSanitizer,但检测器不能替代接口中的边界。
在函数里用 sizeof 推算长度
修复时把形参改为 std::span<const int>,或在低层接口中同时传入指针和元素数量。只有当函数必须拒绝其他边界时,才用 const int (&values)[8];方括号里的 8 这时才真正属于引用的目标类型。
以为数组会整体复制
需要独立副本时使用 std::array,或把元素复制到另一个已存在的范围中。若内置数组是类的成员,类的隐式复制操作会逐元素复制该成员;这是外层类的复制规则,不表示数组本身获得了赋值运算符。
返回局部数组的视图
修复时让拥有者活得比视图久,或按值返回 std::array、std::vector 等拥有型对象。审查辅助函数时,不要只看返回类型;还要追踪 span 或指针最初引用的数组存储期。
忘记字符数组的终止符
修复时为终止符保留一个元素,例如让 char code[] = "ABC" 推导出边界 4。如果数据本来允许内嵌零字节或没有终止符,就传递显式长度或使用合适的 span,不要把它伪装成 C 字符串。
数组类型、边界与转换
数组的边界属于数组类型,但变量名通常不会把这项信息传得很远。decltype(values) 会得到声明的数组类型,sizeof values 会计算完整数组对象的大小,std::size(values) 通过数组引用重载返回元素数量。相反,普通按值推导 auto value = values 会得到首元素指针。
一元 &values 也不会先退化。若 values 是 int[4],则 &values 是 int (*)[4],给这个指针加 1 会跨过整个四元素数组。&values[0] 则是 int*,加 1 只前进一个元素。两者可能表示相同的起始地址,但类型和指针运算单位不同。
引用绑定同样能保留数组类型。模板形参 T (&array)[N] 可以同时推导元素类型与边界;const T (&array)[N] 接受只读访问。这个技巧适合边界本身决定行为的接口,但每个不同 N 都会产生不同模板实例,公开接口通常更适合使用 span。
数组到指针转换只给出首元素地址,不携带尾后地址。由此产生的 T* 无法独自区分一个元素、十个元素或没有可合法访问元素的地址。任何接收裸指针的接口都需要另一项契约说明数量、哨兵或终止条件。
std::span<T, N> 在这两种表示之间提供明确选择。固定边界 N 属于 span 类型,动态边界 span 则从对象中取得 size()。二者都只是视图;复制 span 会复制观察位置和长度,不会复制底层元素。
内置数组不能直接作为函数返回类型,也没有数组赋值。把数组放进结构体会改变使用方式:结构体可以按值返回和赋值,其生成的操作会处理每个数组成员。std::array 把值式容器操作做成了标准库类型;具体能使用哪些操作,仍取决于元素类型。
未知边界数组只在受限声明中有用,例如 extern int records[] 可以先声明在别处定义的数组。代码在看到完整定义之前不能对不完整数组类型使用需要完整大小的操作。它不是运行时变长数组,也不会把长度藏在对象中。
使用 new T[n] 时,n 可以来自运行时,但表达式结果仍是 T*,不保留可查询边界。异常、提前返回和所有权转移都会让配对 delete[] 变难。除非正在实现拥有型容器或对接要求这种形式的接口,否则 std::vector<T> 或智能指针数组更容易表达生命周期。
初始化、复制与元素生命周期
数组的初始化会依次初始化每个元素,不存在单独的“数组值”构造步骤。初始化列表提供的元素多于边界时,程序不合法;提供的元素较少时,剩余元素按空初始化列表进行初始化。对于 int,结果是零;对于类类型,则调用相应的默认构造过程。
| 声明位置与写法 | 基础类型元素的初始状态 |
|---|---|
函数内 int data[4]; | 不确定,不可先读后写 |
函数内 int data[4]{}; | 全部为 0 |
静态存储期 int data[4]; | 静态初始化阶段置 0 |
int data[4]{7, 8}; | 7, 8, 0, 0 |
数组元素是完整对象,各自遵守元素类型的构造和析构规则。类类型元素按索引递增顺序构造,数组生命周期结束时按相反顺序析构。若构造某个元素时抛出异常,已经完成构造的较早元素会被析构,尚未开始的元素没有生命周期。
顶层 const 会应用到元素。const int limits[3]{1, 2, 3} 的元素不可通过该数组修改,数组转换后得到 const int*。不能通过类型转换去掉 const 后写入这些元素;原对象确实为 const 时,这样写会产生未定义行为。
数组声明符从变量名向外读。int* pointers[3] 是含 3 个 int* 的数组,而 int (*pointer)[3] 是指向三元素数组的指针。括号改变结合方式;在审查复杂声明时,类型别名或标准库容器通常比继续叠加声明符更易读。
内置数组本身没有复制构造函数或赋值运算符。初始化另一个数组时不能把数组名称作为整体初始化器,赋值表达式左侧也不能是数组。逐元素算法、包装它的类,或 std::array 才能提供明确的复制操作。
把数组作为类成员时,成员的生命周期归外层对象管理。外层对象的默认复制与移动会逐元素处理内置数组成员,析构也会销毁所有元素。若元素不可复制,外层类相应的默认复制操作也可能被定义为删除。
typedef int Row[4] 或 using Row = int[4] 可以给数组类型命名。随后 Row table[3] 表示三行,每行四个整数。类型别名能让函数引用和指向行的指针更易读,但不会增加边界检查或值语义。
存储期与所有权必须分开考虑。局部数组通常由作用域拥有,静态数组由程序生命周期拥有,类成员数组由外层对象拥有。指针、引用和 span 只观察这些元素;它们能否使用完全取决于拥有者是否仍然存在。
数组适合边界在编译期确定的对象集合。若边界来自输入,把输入直接放进编译器扩展的栈上变长数组,会同时引入可移植性和栈空间风险。用 std::vector 分配并让分配失败遵守容器的异常与 RAII 规则,接口通常更容易审查。
在 API 边界表达数组
数组问题经常不是出在存储声明,而是出在函数边界。一个 T* 只说明可以通过某个地址访问 T,没有说明地址是否可为空、有多少元素、调用期间能否修改,以及指针能保存多久。可靠的接口会分别表达这些约束,而不是让调用者从参数名称猜测。
对于现代 C++ 内部接口,std::span<const T> 通常表示一段借用的只读连续元素,std::span<T> 表示一段借用的可修改元素。const 限制的是通过这个视图进行的写入,并不使其他别名失效。调用方仍负责保证 span 使用期间底层数组存活且不被会使地址失效的操作替换。
固定边界 span std::span<T, N> 适合算法在编译期要求恰好 N 个元素的情况。它仍不拥有元素,但错误边界的数组不能隐式构造出该类型。若算法对任意长度都成立,动态边界 std::span<T> 可以减少模板实例并让接口更宽。
C 风格接口通常使用指针加数量,例如 const unsigned char* data, std::size_t size。审查时必须确认数量的单位:元素个数、字节数和最后合法索引并不相同。对 T* 使用字节数作为循环上限,会在 sizeof(T) > 1 时越过真实元素范围。
空范围也需要契约。长度为 0 时,函数不应解引用数据指针;接口是否允许空指针,要看它自己的前置条件。span 能统一表示空范围,并让循环只依赖 begin() 与 end(),但从无效指针构造 span 仍不能修复指针来源。
标准算法和范围算法能直接接收保留边界的数组。调用 std::ranges::sort(values) 会从数组取得首尾迭代器,不需要手工计算数量。相比传递 values 和一个重复写出的常量,范围接口减少了指针与长度不一致的机会。
需要把现有内置数组变成拥有型值时,可以使用 std::to_array 或显式构造 std::array。std::to_array("ABC") 会保留字符串字面量中的终止空字符,因此结果包含 4 个元素。若目标是文本而不是原始字符容量,std::string 或 std::string_view 的接口含义更直接。
ABI 或 C 互操作可能要求保留裸指针形状。这时不要在包装层假装边界已经由类型保证;先验证长度和可空性,再尽快构造 span 供内部代码使用。包装层还应明确写出所有权仍属于调用方,以及回调或异步操作能否把视图保存到调用之后。
泛型代码可以通过 <type_traits> 中的 std::rank_v<T> 和 std::extent_v<T, I> 检查数组类型的层数与各层边界。这些特征作用于类型,所以必须在退化前使用,例如对 decltype(values) 求值。一旦手里只剩 int*,类型特征无法恢复原来的边界。
接口选择可以归结为所有权和边界两个轴。需要拥有并复制就用容器,需要借用并携带数量就用 span,只在外部契约已经明确数量时暴露裸指针。这个判断比“数组还是指针”的语法偏好更能防止实际错误。
多维数组与字符数组的边界
多维数组每一层都有独立边界。对于 int image[3][4],外层有 3 个元素,元素类型是 int[4];std::size(image) 是 3,std::size(image[0]) 是 4。访问时必须分别证明行索引和列索引有效。
当 image 用在多数表达式中时,它转换为 int (*)[4],而不是 int*。编译器需要知道每行包含 4 个元素,才能让 image + 1 前进到下一行。形参 int image[][4] 会调整成同一个指针类型,所以只能省略最外层边界。
int** 表示指向 int* 的指针,常见布局是一组行指针,每行可以位于不同分配区。二维数组没有这组行指针;它直接包含行数组。强制转换不会创造缺失的指针对象,按 int** 解引用二维数组会错误解释元素字节。
嵌套数组按外层元素顺序保存行,每一行内部又按元素顺序保存列,因此自然形成行主序。逐行、逐列访问完全符合对象边界。把 &image[0][0] 当成一个可以跨所有行任意做指针运算的一维数组指针,则绕开了嵌套子数组边界;需要扁平存储时,直接声明一维数组并自行计算 row * columns + column 更清楚。
部分列表初始化会在每一层补零。int image[2][3]{{1}, {2, 3}} 的两行分别是 1, 0, 0 与 2, 3, 0。省略内层花括号在一些简单标量数组中可行,但保留分层花括号更能表达形状,也能让编译器警告可疑初始化。
字符串字面量本身是 const char[N] 数组,其中 N 包含终止空字符。用它初始化 char text[] 会创建一份可修改数组;让 const char* text 指向字面量则只创建指针,修改字面量仍是未定义行为。两种声明的 sizeof 结果也不同:前者是数组容量,后者是指针大小。
字符数组的容量、当前文本长度和二进制数据长度是三件不同的事。sizeof buffer 只在 buffer 仍是数组时给出容量,std::strlen(buffer) 要求已有可达的 \0 并只计算它之前的字符。对于可能包含零字节的数据,长度必须独立保存,span 比字符串函数的终止约定更合适。
字符串字面量可以包含内嵌的 \0,所以数组边界不一定等于 std::strlen 加一。char sample[] = "A\0B" 的边界是 4,而 C 字符串函数看到的文本长度是 1。处理协议字段或文件内容时,不能用终止字符串规则替代明确的数据长度。
对两个元素指针做关系排序或相减前,也要先确认它们属于同一个数组对象或相应的尾后位置。数值地址碰巧接近不构成这个关系。把不同数组的指针排序或相减,不能用来推导一个更大的连续范围。
数组成员不会因为外层对象被移动就自动变成指针转移。默认移动外层对象时,数组的每个元素分别按移动初始化或移动赋值规则处理。对于大型定长元素集合,这意味着“发生了移动”本身并不保证常数成本。
边界审查最终需要同时回答三个问题:完整类型是什么,当前表达式是否已经退化,底层对象还能活多久。只检查下标数值不够;正确的数值配上错误的列类型或悬空的 span,仍然会产生未定义行为。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题