C风格数组

C++23内置数组的边界、初始化、指针转换、多维布局与生命周期,以及何时改用std::array、span或vector。

难度 进阶 时长 标准深度约12分钟
版本 C++23 (GCC 13.3.0)
what

C风格数组是固定数量的同类型对象,元素在内存中连续排列,长度属于数组类型的一部分。

trap

数组在多数表达式和函数形参中会转换成首元素指针;转换后,长度信息不会随指针一起传递,也没有自动边界检查。

fix

长度固定且需要值语义时优先用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 = valuesint*
auto& alias = valuesint (&)[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也保留数组范围,因此循环体不需要手写尾后索引。

readings.cpp
#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 25

int&让第一个循环直接修改数组元素。第二个循环按值读取每个int。如果写成auto pointer = readings,类型推导会先触发数组到指针转换;写auto& same_array = readings才会保留int[4]类型。

用span传递指针和长度

下一个示例让函数接收span。调用方可传入内置数组,而函数同时得到首元素和元素数量,不必依赖一个容易不同步的裸指针参数。

span_total.cpp
#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: 8

std::span<int>允许replace_negative修改元素,std::span<const int>则只提供只读访问。subspan(1, 2)描述从索引1开始的两个元素;创建子视图前仍要保证范围合法。

用数组引用推导边界

模板可以通过数组引用保留边界,并把它推导为N。这种接口适合确实只接受内置数组的窄范围工具;需要同时接收std::array、vector或子区间时,span通常更简单。

array_reference.cpp
#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能在不接收第二个长度参数的情况下访问最后一个元素。该保证来自形参类型,不是从指针恢复出的信息。

传递二维数组

二维数组的列边界必须保留,编译器才能计算下一行的位置。数组引用把行数和列数都放进类型,并拒绝形状不匹配的数组。

grid.cpp
#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道找错题

前置内容 C语言基础C指针
下一篇 C动态内存管理 Stl 即将上线 String view 即将上线 Algorithms 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?