LearnCpp C 风格数组
17.7 C 风格数组简介
C 风格数组继承自 C,是 C++ 核心语言原生支持的数组类型,无需包含头文件即可使用。std::array 和 std::vector 等标准库数组容器通常也以 C 风格数组作为底层实现的一部分。
声明、长度与下标
C 风格数组使用方括号声明,方括号中的长度表示元素数量,其类型是 std::size_t:
int testScore[30] {}; // 30 个经过值初始化的 int 元素普通 C 风格数组的长度必须至少为 1。零、负数或非整数长度都会导致编译错误;动态分配在堆上的 C 风格数组是例外,可以具有零长度。
数组长度必须是常量表达式。部分编译器为了兼容 C99,会把非常量长度的可变长度数组(variable-length array,VLA)作为扩展接受,但 VLA 不是合法的标准 C++,不应使用。编译器接受这类声明通常意味着编译器扩展尚未禁用。
constexpr int length { 5 };int valid[length] {};
int runtimeLength { 5 };int invalid[runtimeLength] {}; // 非标准 C++下标运算符 operator[] 可以使用任意整数类型的值,包括有符号整数、无符号整数和无作用域枚举。这与只接受无符号 std::size_t 下标的标准库容器不同,因而不会自动引入同样的符号转换问题。
operator[] 不进行边界检查。长度为 N 的数组有效下标为 0 到 N-1;越界访问会产生未定义行为。声明 int arr[5] 中的方括号属于声明语法,并不是一次下标运算。
聚合初始化
C 风格数组是聚合类型,可以通过花括号中的初始化列表按元素顺序初始化,从下标 0 开始:
int fibonacci[6] = { 0, 1, 1, 2, 3, 5 }; // 复制列表初始化int prime[5] { 2, 3, 5, 7, 11 }; // 列表初始化,优先使用未提供初始化器时,元素执行默认初始化;对 int 等基本类型,这通常会留下未初始化值。没有显式初值时应使用空花括号进行值初始化:
int uninitialized[5];int zeroed[5] {}; // 所有元素值初始化为 0初始化器数量超过数组长度会导致编译错误;数量少于数组长度时,剩余元素执行值初始化。
int tooMany[4] { 1, 2, 3, 4, 5 }; // 编译错误int partial[4] { 1, 2 }; // 后两个元素为 0C 风格数组不是类模板,不能使用类模板实参推导;也不能以 auto 从初始化列表推导数组元素类型,元素类型必须显式写出。
当初始化列表显式给出全部元素时,可以省略数组长度,由编译器根据初始化器数量推导:
const int prime1[5] { 2, 3, 5, 7, 11 };const int prime2[] { 2, 3, 5, 7, 11 }; // 推导长度为 5省略长度要求存在能够确定全部成员数量的初始化器。int bad[] {}; 会推导出不允许的零长度数组。显式初始化每个元素时,优先省略长度,使数组长度在增删初始化器后自动同步。
const、constexpr、占用空间与长度
C 风格数组可以声明为 const 或 constexpr。常量数组必须初始化,之后不能修改其元素。
constexpr int squares[] { 1, 4, 9, 16, 25 };const int primes[] { 2, 3, 5, 7, 11 };// primes[0] = 17; // 编译错误对数组对象使用 sizeof,得到整个数组占用的字节数,而不是元素数量。C 风格数组本身没有额外管理开销,只包含连续存放的元素。例如,若 int 占 4 字节,则 5 个 int 的数组占 20 字节。
C++17 可以使用 <iterator> 中的 std::size() 获取无符号长度,返回类型为 std::size_t;C++20 可以使用 std::ssize() 获取有符号长度,返回类型通常是 std::ptrdiff_t。<array>、<vector> 等头文件也经常间接提供这两个函数,但对裸数组而言,惯例是显式包含其规范头文件 <iterator>。
#include <iterator>
const int primes[] { 2, 3, 5, 7, 11 };auto length1 = std::size(primes); // 5,C++17auto length2 = std::ssize(primes); // 5,C++20C++11 和 C++14 可以用数组引用形参及非类型模板参数保留并返回长度:
template <typename T, std::size_t N>constexpr std::size_t length(const T (&)[N]) noexcept{ return N;}旧代码还可能使用 sizeof(array) / sizeof(array[0]) 或等价的 sizeof(array) / sizeof(*array)。其依据是“数组字节数 = 元素数量 × 单个元素字节数”。数组一旦退化为指针,这个公式会错误地使用指针大小,因此不安全;std::size() 和数组引用模板在这种情况下会直接编译失败,更适合作为防错方案。
C 风格数组不支持整体赋值
C 风格数组不是可修改左值,因此不能在创建后通过赋值运算符替换整个数组。单个元素仍可赋值。
int arr[] { 1, 2, 3 };arr[0] = 4; // 合法// arr = { 5, 6, 7 }; // 编译错误需要整体替换值时,通常应改用 std::vector。也可以逐元素赋值,或使用 std::copy(std::begin(src), std::end(src), std::begin(arr)) 从另一个数组复制元素。
17.8 C 风格数组退化
若按值传递大型数组,每次调用都复制所有元素会非常昂贵;同时,不同长度的数组具有不同类型,C 既没有引用和模板,也没有表示“任意长度数组”的参数语法。C 和 C++ 通过数组退化同时绕开了复制成本和长度类型不兼容问题。
数组到指针的转换
在大多数表达式中,C 风格数组会隐式转换为指向元素类型的指针,指针保存下标 0 元素的地址。这称为数组到指针转换,通常简称数组退化。int[N] 退化为 int*,const int[N] 退化为 const int*。
int arr[5] { 9, 7, 5, 3, 1 };auto ptr { arr }; // ptr 的类型为 int*,值等于 &arr[0]退化产生的指针只是普通指针。数组不是指针:数组对象是连续元素序列,类型 int[5] 包含长度信息;指针对象只保存地址,类型 int* 不包含数组长度。“退化”描述的正是类型中长度信息的丢失。
C++ 中常见的不退化场景有:
- 作为
sizeof()或typeid()的实参; - 对数组使用取地址运算符
operator&; - 数组作为类类型的成员随类对象传递;
- 数组按引用传递。
对 C 风格数组使用下标时,operator[] 实际作用于退化后的指针。因此只要指针指向数组首元素,arr[2] 与 ptr[2] 会访问同一元素。
函数参数中的数组退化
C 风格数组作为函数实参时会退化,实际传递的是首元素地址,而不是数组副本。因此它看起来像按值传递,实际采用按地址传递;函数能够直接访问并修改原数组元素。函数无意修改数组时,应把元素类型声明为 const。
同一元素类型但不同长度的数组是不同类型,例如 int[5] 与 int[8] 互不兼容;退化后两者都是 int*,因此可传给同一个函数。
void printFirst(const int arr[]) // 编译器按 const int* 处理{ std::cout << arr[0];}
const int primes[] { 2, 3, 5, 7, 11 };const int squares[] { 1, 4, 9, 16, 25, 36 };printFirst(primes);printFirst(squares);函数形参 const int arr[] 与 const int* arr 在类型上相同,方括号中的长度即使写出也会被忽略。数组语法能向调用者表明该参数预期接收退化数组,而不是指向单个值的指针,因此接收 C 风格数组的形参宜写成 T arr[]。代价是退化事实不如指针语法明显,函数体必须避免把形参误当作仍保留长度的数组。
退化丢失长度造成的问题
数组对象与退化指针上的 sizeof 含义不同:前者返回整个数组的字节数,后者只返回指针本身的字节数。因此在函数形参上使用 sizeof(arr) / sizeof(*arr) 会算出错误长度。std::size() 和 std::ssize() 不接受指针,能把这类错误转化为编译错误。
代码在原函数中直接操作未退化数组时可能正确;将代码重构进接收数组形参的函数后,数组已经退化,同一段长度逻辑可能无法编译,甚至静默产生错误结果。
缺少长度还使函数无法可靠检查数组是否足够长,也无法知道遍历终点。调用者可以传入比预期更短的数组,甚至传入指向单个对象的指针,随后下标访问就可能越界并产生未定义行为。
传统代码主要用两种方法补偿长度丢失。
第一种方法是分别传入数组地址和长度。这仍存在多项风险:
- 调用者必须保证指针与长度匹配,传错长度仍会出错;
std::size()返回std::size_t,与有符号长度参数之间可能发生符号转换;- 运行时
assert只有执行到对应路径才生效;函数形参不能是constexpr,即使函数本身是constexpr或consteval,也难以用static_assert验证常量数组的长度; - 只有显式函数调用能额外传入长度,运算符等隐式调用没有第三个位置携带长度。
第二种方法是用语义上无效的哨兵元素标记末尾,再从起点遍历到哨兵。C 风格字符串使用空终止符完成这件事,因此退化后仍可确定逻辑终点。这种方法同样脆弱:缺失哨兵会使遍历越过数组末尾;处理函数必须专门识别并跳过哨兵;物理数组长度与有效元素数量不一致;并且只有确实存在一个无效值时才能采用。
现代 C++ 中的使用边界
由于非标准的按地址传递语义和长度丢失风险,应尽量避免 C 风格数组:
- 只读字符串使用
std::string_view; - 可修改字符串使用
std::string; - 非全局的常量表达式数组使用
std::array; - 非常量表达式数组使用
std::vector。
数组也可以按引用传递以阻止实参在传入时退化,但函数体中对该数组引用求值时仍会发生退化。数组引用形参还固定了长度;若要接受多种长度,必须再使用函数模板。任何一个调用点漏写引用都会重新发生退化,因此通常直接使用 std::array 更简单可靠。
现代 C++ 中仍常见的 C 风格数组用途有两类:
- 保存全局
constexpr或静态局部constexpr程序数据。调用者可直接访问,不必通过函数传递,因而避开退化问题;其声明语法较简洁,下标也没有标准库容器的符号转换问题。 - 直接接收非常量 C 风格字符串的函数或类。把它转换为
std::string_view需要先遍历空终止字符串以确定长度;若性能关键路径根本不需要长度,或随后还要调用只接受 C 风格字符串的接口,来回转换可能没有收益。
17.9 指针算术与下标
数组元素在内存中连续存放。指针算术允许对指针应用整数加法、减法、递增和递减,生成新的元素地址。
指针算术按对象大小移动
对于指针 ptr,ptr + 1 指向下一个同类型对象,ptr - 1 指向前一个同类型对象,而不是简单移动一个字节。若 ptr 是 int* 且 int 占 4 字节,ptr + 1 的地址增加 4 字节,ptr + 2 增加 8 字节。
++ptr 等价于 ptr = ptr + 1,--ptr 等价于 ptr = ptr - 1,会把计算结果写回指针。
按照 C++ 标准,只有当原指针和计算结果都位于同一数组内,或结果恰好是数组末元素之后一个位置时,指针算术才有定义。越过这个范围即使不解引用结果也是未定义行为。实现通常不会主动阻止这种计算,不能因此把它视为合法。
下标是指针算术的简写
指针下标表达式 ptr[n] 等价于 *((ptr) + (n)):先将指针移动 n 个元素,再隐式解引用结果。数组用于表达式时先退化为首元素指针,所以 arr[n] 的工作方式相同。
数组从 0 开始编号使这种计算无需额外减一:arr[0] 等价于 *arr,arr[n] 等价于 *(arr + n)。由于加法可交换,语法上 n[ptr] 也能工作,但这种写法难以理解,不应使用。
下标表示相对于当前指针的位置,而不是数组中的绝对位置。若 ptr 指向数组元素 3,则 ptr[0] 是元素 3,ptr[1] 是元素 4,ptr[-1] 是元素 2。负下标因此可以合法工作,前提是结果仍在同一数组的有效范围内。
从数组首元素开始访问时应优先使用下标,使下标值与数组元素编号一致;从任意给定元素进行相对定位时应使用指针算术,避免让代码误示为绝对编号。
使用首尾指针遍历数组
指针算术可以在没有显式下标的情况下遍历数组:
constexpr int arr[] { 9, 7, 5, 3, 1 };
const int* begin { arr };const int* end { arr + std::size(arr) }; // 尾后位置
for (; begin != end; ++begin) std::cout << *begin << ' ';end 指向末元素之后一个位置。保存和比较这个地址是合法的,但该位置没有对象,不能解引用。使用半开区间 [begin, end) 可以让循环条件和地址计算无需额外加减一。
把遍历代码提取为 printArray(const int* begin, const int* end) 后,两个指针已经包含遍历所需边界,不必把数组对象本身传入函数,也就不需要在函数内恢复退化后丢失的长度。标准库的迭代器和算法广泛使用同样的首尾对约定。
范围 for 循环处理 C 风格数组时也使用这一机制:建立指向起点和尾后位置的内部值,在二者不相等时递增起点,解引用当前指针以初始化循环变量。其基本结构相当于:
auto begin = arr;auto end = arr + std::size(arr);
for (; begin != end; ++begin){ auto element = *begin; std::cout << element << ' ';}17.10 C 风格字符串
C 风格字符串是元素类型为 char 或 const char 的 C 风格数组。字符串字面量的类型是 const char[N],其中 N 包括所有显式字符以及末尾隐藏的空终止字符。例如,"Hello, world!" 包含 13 个可见字符,类型为 const char[14]。
C 风格字符串字面量本身可以正常使用,但可修改的 C 风格字符串对象难用且危险;现代 C++ 通常使用 std::string 和 std::string_view。
定义、退化与输出
char buffer[8] {};const char text[] { "string" }; // 6 个字符加空终止符,数组长度 7constexpr char greeting[] { "hello" }; // 数组长度 6字符串需要额外一个元素保存隐式空终止符。使用字符串初值定义数组时,应省略数组长度,让编译器同时计算可见字符和空终止符,避免修改初值后忘记同步长度。
C 风格字符串会遵循普通数组的退化规则。字符串字面量退化为 const char*;数组对象根据其常量性退化为 char* 或 const char*。退化会丢失类型中的数组长度,空终止符使程序仍能通过从起点计数到 \0 来重新确定字符串的逻辑长度,但这种遍历需要线性时间。
std::cout 从首字符开始输出,遇到空终止符停止。若字符串缺少空终止符或终止符被覆盖,输出会继续读取相邻内存,直到偶然遇到值为零的字节,行为未定义。
输入与缓冲区溢出
C 风格字符串容量固定,输入前必须分配足够大的数组。C++20 之前,std::cin >> buffer 会一直提取到首个非前导空白,输入字符数可能超过数组容量,覆盖数组之后的内存并产生未定义行为。
这种向存储区写入超出其容量数据的错误称为数组溢出或缓冲区溢出。它不仅造成程序错误,也是安全漏洞:攻击者可能利用越界写入改变相邻内存和程序行为。
C++20 修改了 operator>>,使其只接受未退化的 C 风格字符串并根据数组长度限制提取数量,从而避免溢出;代价是不能再用它向已经退化的字符串指针输入。
使用 std::cin 读取 C 风格字符串时,推荐显式提供容量:
#include <iostream>#include <iterator>
int main(){ char rolls[255] {}; // 最多 254 个字符和一个空终止符 std::cin.getline(rolls, std::size(rolls)); std::cout << rolls << '\n';}getline() 可以读取空白,最多接收容量允许的字符,超出部分被丢弃。未退化数组可用 std::size() 安全提供容量;指针已经丢失长度,必须通过其他方式取得容量,传错数值仍会造成错误或安全问题。存储用户输入时,能自动扩容的 std::string 更安全。
修改与长度
C 风格字符串可以在创建时初始化,但数组不支持整体赋值,因此不能用赋值运算符替换整个字符串。非常量字符数组仍可通过下标逐字符修改。
char str[] { "string" };str[1] = 'p'; // 结果为 "spring"// str = "rope"; // 编译错误std::size() 和 std::ssize() 返回物理数组长度,包括未使用容量和空终止符,并且不适用于退化指针。例如 char str[255]{ "string" }; 的数组长度是 255,不是字符串的 6 个可见字符。
<cstring> 中的 std::strlen() 接受未退化数组或指针,返回空终止符之前的字符数量,不包含空终止符。它必须从头遍历到终止符,因此成本与字符串长度成正比。
旧代码中还常见以下 <cstring> 函数:
strcpy()、strncpy()、strcpy_s():用一个 C 风格字符串覆盖另一个;strcat()、strncat():在末尾追加字符串;strcmp()、strncmp():比较字符串,相等时返回0。
除 strlen() 外,通常应避免这些接口。除非存在明确且充分的理由,应使用 std::string 代替非常量 C 风格字符串;内存受限设备等确实需要固定缓冲区时,宜选择经过充分测试的第三方定长字符串库。
17.12 多维 C 风格数组
数组的维数是选定一个元素所需的下标数量。一个下标即可选定元素的是一维数组;数组的元素类型也可以是另一个数组,因此可以构成二维及更高维数组。
int a[3][5]; // 含 3 个元素,每个元素都是含 5 个 int 的数组int cube[4][4][4]; // 4×4×4 三维数组二维数组使用两个下标,通常把左侧下标视为行,右侧下标视为列,因此 a[2][3] 表示第 2 行、第 3 列。超过三维的数组受语言支持,但实际较少使用。
内存布局与初始化
物理内存是一维的,多维数组仍以连续元素序列存放。C++ 使用行主序:同一行的元素按列从左到右连续排列,一行结束后再存放下一行。Fortran 等语言使用列主序,即先沿同一列从上到下排列。
C++ 按行主序初始化数组。遍历时按照实际内存顺序访问通常最有效率,因此二维数组应把行循环放在外层、列循环放在内层。
二维数组宜使用嵌套花括号初始化,每组内层花括号代表一行。某些编译器允许省略内层花括号,但保留它们可显著提高可读性。行内缺少的初始化器执行值初始化。
int values[3][5]{ { 1, 2 }, // 1, 2, 0, 0, 0 { 6, 7, 8 }, // 6, 7, 8, 0, 0 { 11, 12, 13, 14 } // 11, 12, 13, 14, 0};具有初始化器的多维数组只能省略最左侧长度,编译器可根据行数推导该维度;其他维度必须保留,因为它们定义每个子数组的类型。空花括号仍可把所有元素值初始化为零。
int valid[][5]{ { 1, 2, 3, 4, 5 }, { 6, 7, 8, 9, 10 }};
// int invalid[][] { { 1, 2 }, { 3, 4 } }; // 编译错误int zeroed[3][5] {};遍历与坐标映射
索引循环中,std::size(arr) 返回行数,std::size(arr[0]) 返回列数。范围 for 循环则先以常量引用取得每一行数组,再遍历该行中的元素:
for (std::size_t row { 0 }; row < std::size(arr); ++row){ for (std::size_t col { 0 }; col < std::size(arr[0]); ++col) std::cout << arr[row][col] << ' ';}
for (const auto& row : arr){ for (const auto& element : row) std::cout << element << ' ';}二维数组适合表示乘法表等网格数据。以编译期常量指定行列数,先值初始化整个数组,再用外层行循环和内层列循环计算及输出,可保持访问顺序与行主序一致。
笛卡尔坐标通常写作 {x, y}:x 选择水平方向的列,y 选择垂直方向的行;二维数组索引写作 [row][col]。因此笛卡尔坐标 {x, y} 应转换为数组索引 [y][x]。以坐标变量遍历时,外层使用 y,内层使用 x,访问表达式为 array[y][x]。
参考来源
部分信息可能已经过时


























