第 09 章
数组:连续内存、零索引与 C 字符串
这一章讲数组的声明与初始化、为什么下标从 0 开始、未初始化元素的垃圾值与越界风险;再讲 std::size 与 sizeof 求长度、范围 for 循环,以及字符数组必须空字符结尾才能直接打印的原因。
Arrays: Contiguous Memory, Zero Indexing, and C Strings
This chapter covers declaring and initialising arrays, why indexing starts at zero, the garbage in uninitialised elements and the danger of running past the end, plus std::size and the sizeof trick, range-based loops, and why character arrays need a terminating null before they can be printed.
篇内目录
- What this chapter is about
- Timeline
- Declaring, indexing, and bounds
- Indexing starts at zero
- Uninitialised elements hold garbage
- Out-of-bounds access does not raise an error
- Reading, writing, and initialising
- Reading and writing with loops
- Brace initialisation
- const arrays
- Operating on elements
- Getting the length: std::size and sizeof
- Range-based for
- Character arrays and C strings
- Three correct constructions
- The failure case
- Why only character arrays print whole
- Chapter takeaways
- Comprehension check
- How this connects to the neighbouring chapters
这一章在讲什么
这是 31 小时长视频的第 9 章,对应 08:48:27–09:55:39。
上一章的循环让我们能重复操作;这一章给出第一个「集合」类型:数组。它把许多同类型的变量放进一段连续内存,用一个名字管理,再用下标访问其中每一个。
本章的难点不在语法,而在三个容易踩的坑:下标从 0 开始、未初始化的元素是垃圾值、越界访问编译器不拦你。讲者花了大量时间在第三个坑上,因为它的后果可能是程序崩溃甚至影响操作系统。
时间轴
| 时间 | 内容 |
|---|---|
| 08:48:52 | 数组的声明:类型、名字、[] 中的大小 |
| 08:51:38 | 零索引、边界与未初始化时的垃圾数据 |
| 08:53:03 | 用循环读写数组元素 |
| 08:55:07 | 花括号初始化;只写部分元素时其余补 0 |
| 08:56:21 | 省略大小,由初始值个数推导 |
| 08:57:01 | const 数组不可修改;对元素求和 |
| 08:59:02 | VS Code 实操:读写、初始化的各种写法 |
| 09:16:05 | std::size:C++17 起在运行期查询元素个数 |
| 09:17:24 | C++17 之前的做法:sizeof(arr)/sizeof(arr[0]) |
| 09:24:43 | 范围 for 循环:不需要下标与大小 |
| 09:27:01 | 字符数组与空字符 '\0' |
| 09:29:24 | 预留空间时编译器自动补 '\0' |
| 09:38:00 | 三种构造 C 字符串的方式与字符串字面量 |
| 09:45:24 | 为什么只有字符数组能直接 std::cout |
| 09:46:32 | 数组越界:编译通过,运行期未定义行为 |
| 09:51:28 | 越界写在很远的位置会让程序崩溃 |
声明、索引与边界
数组的声明要给出元素类型、名字和大小:
int scores[10]; // 10 个 int,占 40 字节
这一行执行后,内存里有一段连续空间,逻辑上叫 scores。访问元素用下标:
std::cout << scores[0] << std::endl; // 第一个元素
std::cout << scores[9] << std::endl; // 最后一个元素
下标从 0 开始
大小是 10 的数组,合法下标是 0 到 9,不是 1 到 10。讲者反复强调这一点,因为几乎所有初学者都会在这里数错一次。
原因是地址计算:scores[i] 的地址等于数组首地址加上 i * sizeof(int)。第一个元素与首地址的偏移是 0,所以它的下标是 0。这不是人为规定,而是连续内存布局的直接结果。
未初始化的元素是垃圾值
int scores[10]; 只是分配了空间,没有写入任何值。此时读取 scores[0] 得到的是那块内存里原本残留的字节,可能是 0,也可能是任意数,甚至每次运行都不一样。这种值不能拿来做任何判断。
越界不会报错
std::cout << scores[12] << std::endl; // 编译通过
编译器不会检查下标是否在范围内。scores[12] 落在数组之外,读到的是别人的内存:可能是本程序的其他数据,可能是其他程序的,也可能是操作系统保留的区域。后果按严重程度排列:
- 读到垃圾值,程序继续运行但结果错误;
- 读到受保护的内存,程序立刻崩溃;
- 写入越界位置,破坏别的程序的数据,或覆盖自己的其他变量。
讲者在演示里用了一个极大的下标写入,程序当场崩溃,后续的 std::cout 根本没有执行。数组边界是 C++ 交给你自己保证的责任。
读写与初始化
用循环读写
const size_t COUNT {10};
int scores[COUNT];
for (size_t i {0}; i < COUNT; ++i) {
scores[i] = static_cast<int>(i) * 10; // 写入 0, 10, 20, ...
}
for (size_t i {0}; i < COUNT; ++i) {
std::cout << scores[i] << std::endl;
}
注意写入时的下标就是循环迭代器,这是 C++ 里最常见的写法之一。
花括号初始化
声明时直接给值:
double salaries[5] {12.7, 7.5, 13.2, 8.1, 9.3};
元素个数必须与大小一致,多了会编译报错(too many initializer values)。
只写一部分时,其余元素自动初始化为 0:
int families[5] {12, 7, 5}; // 元素为 12, 7, 5, 0, 0
这与「完全不初始化」完全不同:后者是垃圾值,前者是确定的 0。
省略大小时,编译器用初始值个数推导:
int class_sizes[] {10, 12, 15, 11, 18, 17}; // 大小为 6
但如果既省略大小、又不给初始值,编译器无从推导,直接报错。
const 数组
const int birds[] {10, 12, 15, 11, 18, 17};
birds[2] = 8; // 编译错误
const 加在数组上,所有元素都变成只读。适合存放不应被修改的配置表或常量序列。
对元素做运算
int scores[] {2, 5, 8, 2, 5, 3, 6, 9};
int sum {0};
for (int score : scores) {
sum += score;
}
std::cout << "Score sum : " << sum << std::endl; // 42
数组本身不支持整体相加,必须逐个元素处理。
求长度:std::size 与 sizeof
硬编码长度是上一章就批评过的做法。C++17 提供了运行期查询:
#include <iterator>
int scores[] {1, 2, 3, 4, 5, 6, 7, 8};
const size_t count {std::size(scores)};
for (size_t i {0}; i < count; ++i) {
std::cout << scores[i] << std::endl;
}
std::size 返回元素个数,与数组实际长度一致;增删初始值后不需要改循环。
C++17 之前的标准做法是用 sizeof 相除:
const size_t count {sizeof(scores) / sizeof(scores[0])};
原理是:sizeof(scores) 是整个数组占用的字节数,sizeof(scores[0]) 是单个元素的字节数,相除就是元素个数。讲者现场打印过:8 个 int 的数组,sizeof(scores) 是 32,sizeof(scores[0]) 是 4,商为 8。
这个技巧只对真正的数组有效。一旦数组退化为指针(例如传进函数),sizeof 得到的是指针大小,结果就错了——这是后面章节会 revisit 的陷阱。
范围 for 循环
只关心元素值、不关心下标时,范围 for 最省事:
for (int value : scores) {
std::cout << value << std::endl;
}
它自动遍历所有元素,不需要大小、迭代器和推进。代价是拿不到下标;需要下标时仍要回到传统 for。
字符数组与 C 字符串
字符数组和其他数组一样可以逐个元素声明:
char message[5] {'H', 'e', 'l', 'l', 'o'};
for (auto c : message) {
std::cout << c;
}
std::cout << std::endl;
这段代码能正常打印 Hello。但字符数组有一个特殊之处:可以直接整体输出。
std::cout << message << std::endl; // 可能打印 Hello 加上一堆乱码
因为 std::cout 遇到 char* 时不会按「数组」处理,而是当作以空字符结尾的字符串:从首地址开始逐字节输出,直到遇到 '\0' 为止。上面的 message 只有 5 个字符、没有 '\0',输出就会越过数组继续读下去,直到碰巧遇到一个 0。
三种正确的构造方式
方式一:手动补空字符
char message1[] {'H', 'e', 'l', 'l', 'o', '\0'};
编译器推导大小为 6。
方式二:预留空间,让编译器补 0
char message2[6] {'H', 'e', 'l', 'l', 'o'};
大小为 6,只给了 5 个值,剩下的一个槽位被初始化为 0——恰好就是空字符。所以大小为 6。
方式三:字符串字面量(推荐)
char message4[] {"Hello"}; // 大小为 6
双引号告诉编译器:这是一段文本,拆成字符存进去,并在末尾补 '\0'。这种写法可以包含空格:
char message[] {"Hello world, how are you doing"};
这是课程推荐的方式,也是日常最接近「输入文本」的写法。
对比:什么时候会出错
char message3[] {'H', 'e', 'l', 'l', 'o'}; // 大小 5,无 '\0'
message3 是合法的数组,但不是合法的 C 字符串。std::cout << message3 会打印 Hello 之后继续读越界内存,输出乱码。区别只在于最后有没有那个 '\0'。
课程把这三种情况放在一起对比,核心结论是:用 char 数组承载文本时,必须保证末尾有空字符;用双引号字面量最省心。
为什么只有字符数组能整体打印
int numbers[] {1, 2, 3, 4, 5};
std::cout << numbers << std::endl; // 打印一个地址,不是 1 2 3 4 5
numbers 会退化为指向首元素的指针,std::cout 对非字符指针打印的是地址。原因要等到第 10 章讲指针时才完全清楚;这里只需记住:只有 char* 被当作字符串处理。
一句话总结: 数组是一段连续内存加一个名字,下标从 0 开始、边界要自己守;字符数组之所以能整体打印,是因为
std::cout一直在找那个'\0'。
本章结论
int scores[10];分配 10 个int的连续空间,合法下标是 0 到 9。- 未初始化的数组元素是垃圾值;部分初始化时其余元素为 0。
- 省略大小时由初始值个数推导;既不省略也给不出初始值则报错。
const数组的元素只读。std::size(arr)(C++17)给出元素个数;旧写法是sizeof(arr)/sizeof(arr[0])。- 范围 for 适合只读元素;需要下标时用传统
for。 - 字符数组要能整体打印,末尾必须有
'\0';推荐用字符串字面量。 - 越界读写编译通过、运行期未定义,可能崩溃或破坏其他数据。
理解检验
1. 为什么大小为 10 的数组,最后一个合法下标是 9 而不是 10?
参考答案:数组元素在内存中连续排列,scores[i] 的地址是首地址加 i * sizeof(int)。第一个元素与首地址偏移为 0,所以下标为 0;第 10 个元素的偏移是 9 * sizeof(int),下标为 9。合法下标范围是 0 到 9,共 10 个。访问 scores[10] 已经越界,读的是数组之后的内存。
2. int scores[10]; 之后直接输出 scores[0],会得到什么?为什么?
参考答案:得到的是不确定的垃圾值。声明只分配内存,不写入任何值;那块内存里保留着之前使用留下的字节,所以内容取决于程序的运行环境,可能是 0,也可能是任意整数,多次运行甚至可能不同。要得到确定值,必须显式初始化(例如 int scores[10] {}; 全部置 0)。
3. int families[5] {12, 7, 5}; 中下标 3 和 4 的值是什么?这与完全不初始化有什么区别?
参考答案:下标 3 和 4 都是 0。C++ 规定:用花括号初始化时,未给出的元素执行值初始化,整型得到 0。如果写成 int families[5];(完全没有初始化列表),所有元素都是垃圾值,不是 0。区别在于是否出现了初始化列表:一旦出现,未列出的元素就有确定的零值。
4. 不写数组大小、只写初始值时会发生什么?什么情况下会编译失败?
参考答案:编译器用初始值的个数推导大小。例如 int class_sizes[] {10, 12, 15, 11, 18, 17}; 得到大小为 6 的数组;之后再增删初始值,大小自动跟着变。如果省略大小却又不提供初始化列表,例如 int class_sizes[];,编译器无从推导,直接编译失败。
5. 用 sizeof 求元素个数的公式是什么?为什么它对函数参数里的数组不可靠?
参考答案:公式是 sizeof(arr) / sizeof(arr[0]):整个数组的字节数除以单个元素的字节数。它只对真正的数组对象有效。数组作为函数参数传递时会退化为指向首元素的指针,参数类型是 int*,此时 sizeof(arr) 得到的是指针本身的大小(本机 8 字节),而不是整个数组的大小,计算结果错误。因此函数内部应把长度作为额外参数传入,或改用自带大小的容器类型(如 std::array、std::vector)。
6. 为什么 char message[5] {'H','e','l','l','o'}; 用 std::cout << message 可能打印乱码,而 char message[] {"Hello"}; 不会?
参考答案:std::cout 对 char* 按 C 字符串处理:从首地址开始逐字节输出,直到遇到 '\0'。前者只有 5 个字符,没有空字符,输出会越过数组继续读后面的内存,直到碰巧遇到 0,于是带上乱码。后者用双引号字面量初始化,编译器会自动在末尾补 '\0',数组大小为 6,输出在 o 之后正确停止。区别就在末尾有没有那个空字符。
与前后章的关系
这一章把循环真正用了起来,也第一次让「内存是连续的」变得具体。下一章(第 10 章)讲指针,会解释 std::cout << numbers 为什么打印地址、数组名为什么能退化成指针,以及用指针遍历数组的写法。再往后,第 13 章的 std::string 会取代本章的字符数组,第 11 章的动态数组则解决「长度在运行期才能确定」的问题。
What this chapter is about
This is chapter 9 of a single 31-hour video, covering 08:48:27–09:55:39.
The previous chapter gave us loops; this one introduces the first collection type: the array. It groups many variables of the same type into one contiguous block of memory, managed under a single name and accessed by index.
The difficulty is not the syntax but three traps: indexing starts at zero, uninitialised elements hold garbage, and the compiler will not stop you from going out of bounds. The lecturer spends a lot of time on the third, because its consequences range from wrong answers to a crash that can disturb other programs.
Timeline
| Time | Topic |
|---|---|
| 08:48:52 | Declaring an array: type, name, size in [] |
| 08:51:38 | Zero indexing, bounds, and garbage before initialisation |
| 08:53:03 | Reading and writing elements with loops |
| 08:55:07 | Brace initialisation; missing elements become zero |
| 08:56:21 | Omitting the size and letting the compiler deduce it |
| 08:57:01 | const arrays; summing elements |
| 08:59:02 | Live coding: reading, writing, and the initialisation forms |
| 09:16:05 | std::size: querying the element count at run time since C++17 |
| 09:17:24 | The pre-C++17 formula: sizeof(arr)/sizeof(arr[0]) |
| 09:24:43 | Range-based for: no index, no size |
| 09:27:01 | Character arrays and the null character '\0' |
| 09:29:24 | Reserving space makes the compiler supply '\0' |
| 09:38:00 | Three ways to build a C string, and string literals |
| 09:45:24 | Why only character arrays can be printed directly |
| 09:46:32 | Out-of-bounds access: compiles, undefined at run time |
| 09:51:28 | Writing far out of bounds crashes the program |
Declaring, indexing, and bounds
A declaration supplies the element type, the name, and the size:
int scores[10]; // ten ints, 40 bytes
After this line there is a contiguous block of memory, logically called scores. Elements are reached by index:
std::cout << scores[0] << std::endl; // first element
std::cout << scores[9] << std::endl; // last element
Indexing starts at zero
For an array of size 10 the valid indices are 0 through 9, not 1 through 10. The lecturer repeats this because almost every beginner miscounts once.
The reason is address arithmetic: the address of scores[i] is the base address plus i * sizeof(int). The first element sits at offset zero, so its index is zero. This is not an arbitrary rule but a direct consequence of contiguous layout.
Uninitialised elements hold garbage
int scores[10]; only reserves space; nothing is written. Reading scores[0] therefore returns whatever bytes happened to be there — possibly 0, possibly any value, and it may differ between runs. Such a value cannot be used for any decision.
Out-of-bounds access does not raise an error
std::cout << scores[12] << std::endl; // compiles
The compiler does not check the index. scores[12] lies past the end of the array and reads someone else's memory: possibly another variable of this program, possibly another program's data, possibly an area the operating system reserves. The consequences, in increasing severity:
- it reads garbage and the program keeps running with a wrong result;
- it reads protected memory and the program crashes immediately;
- it writes out of bounds, corrupting other programs' data or this program's own variables.
In the demo a huge index is written, the program crashes on the spot, and the following std::cout never executes. Array bounds are a responsibility C++ hands to the programmer.
Reading, writing, and initialising
Reading and writing with loops
const size_t COUNT {10};
int scores[COUNT];
for (size_t i {0}; i < COUNT; ++i) {
scores[i] = static_cast<int>(i) * 10; // 0, 10, 20, ...
}
for (size_t i {0}; i < COUNT; ++i) {
std::cout << scores[i] << std::endl;
}
Using the loop iterator as the index is one of the most common patterns in C++.
Brace initialisation
Values can be supplied at declaration:
double salaries[5] {12.7, 7.5, 13.2, 8.1, 9.3};
The number of values must match the size; too many gives too many initializer values.
When only some values are given, the rest are initialised to zero:
int families[5] {12, 7, 5}; // elements are 12, 7, 5, 0, 0
This differs completely from no initialisation at all: the former gives certain zeros, the latter gives garbage.
Omitting the size lets the compiler deduce it from the initialisers:
int class_sizes[] {10, 12, 15, 11, 18, 17}; // size 6
But omitting both the size and the initialiser list leaves nothing to deduce, and compilation fails.
const arrays
const int birds[] {10, 12, 15, 11, 18, 17};
birds[2] = 8; // error
const on the array makes every element read-only, which suits configuration tables or fixed sequences.
Operating on elements
int scores[] {2, 5, 8, 2, 5, 3, 6, 9};
int sum {0};
for (int score : scores) {
sum += score;
}
std::cout << "Score sum : " << sum << std::endl; // 42
Arrays do not support whole-array arithmetic; elements must be processed one by one.
Getting the length: std::size and sizeof
Hard-coding the length is the same bad practice criticised in the previous chapter. C++17 offers a run-time query:
#include <iterator>
int scores[] {1, 2, 3, 4, 5, 6, 7, 8};
const size_t count {std::size(scores)};
for (size_t i {0}; i < count; ++i) {
std::cout << scores[i] << std::endl;
}
std::size returns the element count, so adding or removing initialisers needs no change to the loop.
Before C++17 the standard trick divided two sizeof results:
const size_t count {sizeof(scores) / sizeof(scores[0])};
The whole array's byte count divided by one element's byte count gives the element count. The lecture prints both: for eight ints, sizeof(scores) is 32 and sizeof(scores[0]) is 4, so the quotient is 8.
This only works on a real array object. Once an array decays to a pointer — for example when passed to a function — sizeof measures the pointer, and the result is wrong. Later chapters return to this trap.
Range-based for
When only the values matter, a range-based loop is simplest:
for (int value : scores) {
std::cout << value << std::endl;
}
It walks every element without a size, iterator, or advance. The cost is losing the index; when the index is needed, go back to a traditional for.
Character arrays and C strings
A character array can be declared element by element like any other:
char message[5] {'H', 'e', 'l', 'l', 'o'};
for (auto c : message) {
std::cout << c;
}
std::cout << std::endl;
This prints Hello. But character arrays have a special property: they can be printed whole.
std::cout << message << std::endl; // may print Hello plus junk
std::cout does not treat a char* as an array but as a null-terminated string: it prints byte after byte from the first address until it meets '\0'. The array above has five characters and no '\0', so printing runs past the end until it happens to find a zero.
Three correct constructions
Option 1: add the null by hand
char message1[] {'H', 'e', 'l', 'l', 'o', '\0'};
The compiler deduces a size of 6.
Option 2: reserve space and let the compiler fill it
char message2[6] {'H', 'e', 'l', 'l', 'o'};
Size 6, five values given, so the remaining slot is value-initialised to 0 — exactly the null character. Its size is 6.
Option 3: a string literal (recommended)
char message4[] {"Hello"}; // size 6
The double quotes tell the compiler to split the text into characters, store them, and append '\0'. Spaces are allowed:
char message[] {"Hello world, how are you doing"};
This is the form the course recommends and the closest to how one naturally writes text.
The failure case
char message3[] {'H', 'e', 'l', 'l', 'o'}; // size 5, no '\0'
message3 is a valid array but not a valid C string. std::cout << message3 prints Hello and then keeps reading past the array, producing junk. The only difference is the missing '\0'.
The conclusion: when a char array carries text, its last character must be null, and a double-quoted literal is the easiest way to guarantee that.
Why only character arrays print whole
int numbers[] {1, 2, 3, 4, 5};
std::cout << numbers << std::endl; // prints an address, not the numbers
numbers decays to a pointer to its first element, and std::cout prints an address for a non-character pointer. The full explanation arrives with pointers in chapter 10; for now remember that only char* receives string treatment.
One-line summary: an array is contiguous memory plus a name; indices start at zero and you guard the bounds yourself; a character array prints as a whole only because
std::coutkeeps looking for that'\0'.
Chapter takeaways
int scores[10];reserves ten contiguousints; valid indices are 0 through 9.- Uninitialised elements are garbage; a partial initialiser list zero-fills the rest.
- Omitting the size deduces it from the initialisers; omitting both is an error.
constarrays are read-only element by element.std::size(arr)(C++17) gives the count; the older form issizeof(arr)/sizeof(arr[0]).- Range-based
forsuits value-only traversal; traditionalforis needed for indices. - A character array must end in
'\0'to print as text; string literals do this automatically. - Out-of-bounds access compiles and is undefined at run time — it may crash or corrupt data.
Comprehension check
1. Why is the last valid index of an array of size 10 equal to 9 rather than 10?
Reference answer: Elements sit contiguously, so the address of scores[i] is the base address plus i * sizeof(int). The first element is at offset zero, hence index 0; the tenth element is at offset 9 * sizeof(int), hence index 9. The valid range is 0 through 9, ten values in total. Accessing scores[10] is already past the end and reads the memory that follows the array.
2. After int scores[10];, what does scores[0] contain, and why?
Reference answer: An indeterminate garbage value. The declaration only reserves memory and writes nothing; the bytes left there come from previous use, so the content depends on the environment, may be zero or any integer, and may differ between runs. To get a definite value it must be initialised explicitly, for example int scores[10] {}; to zero everything.
3. In int families[5] {12, 7, 5};, what are the values at indices 3 and 4? How does this differ from no initialisation at all?
Reference answer: Both are 0. With a braced initialiser list, elements not listed are value-initialised, which for integral types means zero. With int families[5]; and no list, every element is garbage rather than zero. The presence of the initialiser list is what turns the missing slots into definite zeros.
4. What happens when the size is omitted but initial values are given? When does compilation fail?
Reference answer: The compiler deduces the size from the number of initialisers. int class_sizes[] {10, 12, 15, 11, 18, 17}; yields an array of size 6, and adding or removing values updates the size automatically. If the size is omitted and there is no initialiser list, as in int class_sizes[];, there is nothing to deduce and compilation fails.
5. What is the sizeof formula for the element count, and why is it unreliable for an array passed to a function?
Reference answer: The formula is sizeof(arr) / sizeof(arr[0]), total bytes divided by the bytes of one element. It is valid only for an actual array object. When an array is passed as a function argument it decays to a pointer to its first element, so the parameter has type int*; then sizeof(arr) measures the pointer itself (8 bytes here), not the array, and the quotient is wrong. Functions should take the length as an extra parameter or use containers that carry their size, such as std::array or std::vector.
6. Why can char message[5] {'H','e','l','l','o'}; print junk with std::cout << message, while char message[] {"Hello"}; does not?
Reference answer: std::cout treats a char* as a C string and prints byte after byte until it finds '\0'. The first array holds five characters and no null, so printing runs past the end into following memory until it happens to meet a zero, which shows up as junk. The second is initialised from a double-quoted literal, so the compiler appends '\0' and the array has size 6; printing stops correctly after o. The difference is exactly that terminating null.
How this connects to the neighbouring chapters
This chapter puts loops to real use and makes "memory is contiguous" concrete for the first time. Chapter 10 on pointers explains why std::cout << numbers prints an address, why an array name decays to a pointer, and how to walk an array with a pointer. Later, std::string in chapter 13 replaces the character arrays here, and dynamic arrays in chapter 11 solve the case where the length is only known at run time.