第 10 章
指针、字符指针与程序内存模型
这一章讲指针如何保存地址、取地址与解引用、为什么所有指针同样大、跨类型赋值为何被拒绝;再讲字符指针与字符串字面量的只读性,以及虚拟内存、栈与堆的分工和 new/delete 的基本用法。
Pointers, Character Pointers, and the Program Memory Model
This chapter explains what a pointer stores, address-of and dereference, why every pointer has the same size, and why cross-type assignment is rejected; then character pointers and the read-only nature of string literals, followed by virtual memory, the stack/heap split, and the first use of new and delete.
篇内目录
- What this chapter is about
- Timeline
- Pointers: variables that hold addresses
- The null pointer
- Every pointer has the same size
- Position of *, and the comma trap
- Address-of and dereference
- Types must match
- Character pointers and string literals
- Why const is required
- Printing the pointer versus the dereference
- Use an array when you need to modify
- The program's memory model
- Virtual memory
- The role of the MMU
- Memory layout
- Stack versus heap
- Three forms to avoid
- The fixed routine for heap memory
- Chapter takeaways
- Comprehension check
- How this connects to the neighbouring chapters
这一章在讲什么
这是 31 小时长视频的第 10 章,对应 09:55:39–11:05:24。
上一章末尾留下一个悬念:std::cout << numbers 打印的不是数组内容,而是一个地址。这一章把「地址」变成可以直接操作的东西。
内容分三层:先是指针本身——声明、取地址、解引用、类型约束;然后是指针的特例字符指针,以及它和字符串字面量的关系;最后是程序的内存模型——虚拟内存、栈与堆的分工,以及 new / delete 的第一次登场。前两层是语法,第三层才是理解后面所有内容(动态内存、引用、对象生命周期)的地基。
时间轴
| 时间 | 内容 |
|---|---|
| 09:56:08 | 声明指针:类型后的 *;指针只能存对应类型的地址 |
| 09:57:22 | 空指针 nullptr;不要解引用空指针 |
| 09:57:51 | 所有指针在同一台机器上大小相同 |
| 09:58:42 | * 的位置与逗号声明的陷阱 |
| 10:00:06 | 取地址 &、赋值、打印地址 |
| 10:01:44 | 不能把 double* 赋给 int* |
| 10:02:23 | VS Code 实操:声明、大小、逗号声明实验 |
| 10:12:20 | 解引用 *p:通过地址读值 |
| 10:14:34 | 字符指针与字符串字面量 |
| 10:18:12 | *p 打印一个字符,p 打印整个字符串 |
| 10:20:10 | 需要修改文本时改用字符数组 |
| 10:27:31 | 虚拟内存与 MMU:程序以为自己独占内存 |
| 10:34:29 | 内存布局:text、data、stack、heap |
| 10:38:58 | 写未初始化指针或空指针是未定义行为 |
| 10:41:33 | new 在堆上分配、delete 归还 |
| 10:45:35 | 重复 delete 会崩溃 |
| 10:49:00 | VS Code 实操:堆内存与三类错误 |
指针:保存地址的变量
int* p_number {}; // 空指针,等价于 int* p_number {nullptr};
double* p_fractional {};
int* 读作「指向 int 的指针」。它存放的不是整数,而是某个 int 变量在内存中的地址。
空指针
{} 或 nullptr 表示「不指向任何有效对象」。它的意义是给指针一个确定的初始状态,避免里面是随机地址。不要解引用空指针——那是在访问一个明确无效的地址。
所有指针大小相同
std::cout << "sizeof(int*) : " << sizeof(int*) << std::endl;
std::cout << "sizeof(double*) : " << sizeof(double*) << std::endl;
在本机两者都是 8 字节。原因是它们存的都是内存地址,而同一台机器上地址的宽度是固定的(64 位系统为 64 位)。指针指向什么类型,只影响编译器如何解释那块内存,不影响指针自身的大小。
* 的位置与逗号声明
int* p_a; // 讲者偏好
int *p_b; // 也可以
int * p_c; // 也可以
三者都对,* 的空白位置不影响语义。但下面这种写法有陷阱:
int* p_number5, other_int; // other_int 是普通 int,不是指针!
* 只修饰紧跟其后的那个声明符。other_int 的类型是 int。课程现场打印 sizeof 验证了这一点:sizeof(p_number5) 是 8,sizeof(other_int) 是 4。结论是一行只声明一个变量,指针尤其如此。
取地址与解引用
int int_var {43};
int* p_int {&int_var}; // & 取地址
std::cout << "int_var : " << int_var << std::endl; // 43
std::cout << "p_int : " << p_int << std::endl; // 一个十六进制地址
std::cout << "*p_int : " << *p_int << std::endl; // 43
&x得到x的地址,类型是int*。*p沿着指针找到那个地址上的值,称为解引用。
指针可以被重新赋值,指向另一个同类型变量:
int int_var1 {65};
p_int = &int_var1; // 现在指向 int_var1
类型必须匹配
int* p_int1 {};
double double_var {33.0};
p_int1 = &double_var; // 编译错误
编译器报错类似「cannot convert double* to int*」。原因是解引用时要按类型解释内存:int* 会读 4 个字节并当作整数,而 double 是 8 字节且编码方式不同。允许跨类型赋值会让 *p 读出错误的结果,所以类型系统直接禁止。确实需要时要用显式转换,但那是另一回事。
字符指针与字符串字面量
字符指针的声明与用法与其他指针相同:
char char_var {'A'};
char* p_char {&char_var};
std::cout << *p_char << std::endl; // A
特殊之处在于可以用字符串字面量初始化:
const char* p_message {"Hello world"};
编译器把字面量展开成一个字符数组(末尾带 '\0'),p_message 指向它的第一个字符。
为什么必须加 const
字符串字面量存放在只读区域,标准规定修改它是未定义行为。现代编译器要求用 const char* 接收:
char* p_message {"Hello world"}; // C++11 起非法
const char* p_message {"Hello world"}; // 正确
课程里用 GCC 与 MSVC 各试了一次,两者都拒绝前者。这不是吹毛求疵:如果允许 char*,你就能写下 p_message[0] = 'B',而那块内存根本不允许写。
打印指针与打印解引用
const char* p_message {"Hello world"};
std::cout << p_message << std::endl; // Hello world
std::cout << *p_message << std::endl; // H
std::cout 对 char* 有特殊处理:从首地址开始逐字符输出,直到 '\0',所以直接打印指针得到整串文本;而 *p_message 只是一个 char,打印出来是单个字符。这与上一章字符数组的行为完全一致——它们本来就是同一件事。
需要修改时用数组
char message[] {"Hello world"};
message[0] = 'B'; // 合法
std::cout << message << std::endl; // Bello world
数组有自己的可写副本;指针指向的是只读字面量。只读文本用 const char*,需要修改用 char[]。
程序的内存模型
图:每条声明落在哪个内存段,以及栈帧里有什么。
虚拟内存
程序运行时看到的内存并不是物理 RAM,而是一张由操作系统定义的虚拟地址空间。64 位系统上,每个进程都以为自己拥有 到 的地址范围。
这样设计是为了隔离:如果每个程序都直接用物理内存,几百个程序很快就会把内存耗尽,而且一个程序可以随意读写另一个程序的数据。虚拟内存让每个进程以为自己独占全部地址空间,实际的物理页由操作系统按需分配。
MMU 的角色
程序里的地址经过 CPU 的**内存管理单元(MMU)**翻译成真实的物理地址。整个程序不会被一次性载入内存,只有近期会用到的部分才在 RAM 中,其余留在硬盘上按需调页。这是操作系统与硬件的协作,写 C++ 时不需要手动管理,但要知道「你看到的地址不是物理地址」。
内存布局
一个进程的地址空间大致分为:
| 区域 | 存放内容 | 生命周期 |
|---|---|---|
| text | 程序的机器码 | 整个进程 |
| data | 全局变量、静态变量 | 整个进程 |
| stack | 局部变量、函数调用信息 | 由作用域决定 |
| heap | new 分配的内存 | 由程序员决定 |
课程只深入 stack 与 heap,因为这两个直接决定变量的生死。
栈与堆的区别
{
int local_var {23}; // 在栈上,离开作用域即销毁
}
// local_var 已经不存在
栈上的变量由作用域控制生死:进入代码块时创建,离开时销毁。程序员无法让它提前结束,也无法让它活得更久。
堆则相反:
int* p_number {new int {77}}; // 在堆上分配一个 int
std::cout << *p_number << std::endl;
delete p_number; // 手动归还
p_number = nullptr; // 防止悬空
new int {77} 向操作系统申请一块足够放 int 的内存,写入 77,返回地址。这块内存在 delete 之前一直属于本程序,即使创建它的作用域早已结束。堆把生命周期的控制权交给了程序员,代价是必须记得归还。
三类必须避免的写法
int* p_number2; // 未初始化,里面是垃圾地址
*p_number2 = 55; // 未定义行为,可能崩溃
int* p_number3 {}; // 空指针
*p_number3 = 55; // 未定义行为,必然无效
int* p4 {new int {99}};
delete p4;
delete p4; // 重复释放,崩溃
前两种课程都现场运行过:程序在写入那一行被操作系统终止,后面的 std::cout 根本没有执行。第三种同样会崩溃,而且更隐蔽——真实项目里两个指针可能指向同一块内存,各自 delete 一次就构成了重复释放。
使用堆内存的固定流程
- 用指针接收
new返回的地址。 - 通过指针读写。
delete归还内存。- 把指针设为
nullptr。
第 4 步经常被省略,但它是防止「悬空指针」的最低成本手段:之后再误用这个指针,至少是明确的空指针,而不是一块已经不属于你的内存。
一句话总结: 指针存地址,类型决定怎么解释那块内存;字符指针指向只读字面量,打印它得到整串;栈由作用域管生死,堆由程序员管生死,
new与delete必须成对且只出现一次。
本章结论
int* p保存int的地址;&x取地址,*p解引用。- 指针只能保存同类型对象的地址,跨类型赋值编译失败。
- 同一台机器上所有指针大小相同;
int* p, q;中q是普通int。 - 字符指针指向字符串字面量时必须用
const char*;*p是首字符,p打印整串。 - 程序看到的是虚拟内存,MMU 负责映射到物理内存。
- 栈存放局部变量,生命周期由作用域决定;堆由
new/delete手动管理。 - 未初始化指针、空指针解引用、重复
delete都是未定义行为,通常会崩溃。
理解检验
1. 指针里存的是什么?为什么 int* 和 double* 在同一台机器上大小相同?
参考答案:指针存的是一个内存地址,即某个对象在虚拟地址空间中的位置。指针的大小由地址宽度决定,64 位系统上是 8 字节,与它指向的类型无关。类型只影响解引用时如何解释那块内存(读几个字节、按整数还是浮点编码),不改变地址本身的大小。所以在同一台机器上,int*、double*、char* 的 sizeof 都相同。
2. int* p; *p = 55; 为什么危险?如果改成 int* p {}; *p = 55; 呢?
参考答案:第一种情况下 p 未初始化,里面是随机残留的地址,可能指向其他程序或操作系统的内存;写入 55 是未定义行为,可能崩溃或破坏数据。第二种情况 p 是空指针,明确不指向任何有效对象,解引用同样是未定义行为,通常立即崩溃。正确做法是让指针指向一个真实对象(int x; int* p = &x;)或先 new 分配内存。
3. int* p_number5, other_int; 中两个变量各是什么类型?为什么讲者建议一行只声明一个?
参考答案:p_number5 是 int*,other_int 是 int。* 只属于紧跟其后的声明符,不会作用于整行。这种写法极易被误读为「两个都是指针」,课程用 sizeof 现场验证:前者 8 字节,后者 4 字节。因此建议一行只声明一个变量,尤其是指针。
4. 为什么不能把 double 变量的地址赋给 int*?
参考答案:解引用时编译器按指针的类型解释内存。int* 会读 4 个字节并按整数解码,而 double 占 8 字节、采用 IEEE 754 编码。若允许这种赋值,*p 读到的会是 double 的前半段字节,结果毫无意义,甚至可能越界。类型系统在编译期就拒绝 double* 到 int* 的隐式转换,正是为了避免这类错误。
5. const char* p {"Hello world"}; 中,std::cout << p 与 std::cout << *p 分别输出什么?为什么必须写 const?
参考答案:std::cout << p 输出 Hello world,因为 std::cout 把 char* 当作 C 字符串,从首地址逐字符输出直到 '\0';std::cout << *p 输出 H,因为 *p 是单个 char。必须写 const 是因为字符串字面量存放在只读区域,标准规定修改它是未定义行为;现代编译器(GCC、MSVC)都要求用 const char* 接收,否则直接报错。
6. 栈和堆上的变量,生命周期分别由什么控制?使用堆内存的完整流程是什么?
参考答案:栈上变量的生命周期由作用域控制:进入代码块时创建,离开时自动销毁,程序员无法干预。堆上内存由程序员控制:new 分配后一直有效,直到 delete 归还,与创建它的作用域无关。完整流程是:用指针接收 new 返回的地址 → 通过指针读写 → delete 归还 → 把指针设为 nullptr,防止后续误用形成悬空指针。
7. 为什么重复 delete 同一个指针会崩溃?为什么归还后要立刻置空?
参考答案:第一次 delete 已经把这块内存还给操作系统,指针仍然保存着旧地址,成为悬空指针;第二次 delete 会试图释放一块不再属于本程序的内存,这是未定义行为,通常导致运行时崩溃。归还后立即写 p = nullptr,可以让指针进入明确无效的状态:之后再误用至少能被检测为空指针,而不是悄悄读写一块已经易主的内存。
与前后章的关系
这一章解释了上一章末尾的疑问——数组名为什么打印出地址,因为它会退化为指向首元素的指针。下一章(第 11 章)继续深入动态内存:悬空指针、内存泄漏与动态数组。再往后,第 12 章的引用可以理解为「不能为空、不能改指向的指针」,第 18 章的类对象则会大量使用 this 指针与堆上分配。
What this chapter is about
This is chapter 10 of a single 31-hour video, covering 09:55:39–11:05:24.
The previous chapter ended on a loose thread: std::cout << numbers printed an address rather than the array contents. This chapter turns "an address" into something you can manipulate directly.
It has three layers: pointers themselves — declaration, address-of, dereference, type constraints; then the special case of character pointers and their relation to string literals; finally the program's memory model — virtual memory, the division between stack and heap, and the first appearance of new and delete. The first two layers are syntax; the third is the foundation for everything about dynamic memory, references, and object lifetimes.
Timeline
| Time | Topic |
|---|---|
| 09:56:08 | Declaring a pointer with *; a pointer stores only addresses of its own type |
| 09:57:22 | The null pointer nullptr; never dereference it |
| 09:57:51 | Every pointer has the same size on a given machine |
| 09:58:42 | Position of *, and the comma-declaration trap |
| 10:00:06 | Address-of &, assignment, printing an address |
| 10:01:44 | A double* cannot be assigned to an int* |
| 10:02:23 | Live coding: declarations, sizes, the comma experiment |
| 10:12:20 | Dereferencing *p to read through an address |
| 10:14:34 | Character pointers and string literals |
| 10:18:12 | *p prints one character, p prints the whole string |
| 10:20:10 | Use a character array when the text must be modifiable |
| 10:27:31 | Virtual memory and the MMU |
| 10:34:29 | Layout: text, data, stack, heap |
| 10:38:58 | Writing through an uninitialised or null pointer is undefined |
| 10:41:33 | new allocates on the heap, delete returns it |
| 10:45:35 | Deleting twice crashes |
| 10:49:00 | Live coding: heap memory and three kinds of mistake |
Pointers: variables that hold addresses
int* p_number {}; // null pointer, same as int* p_number {nullptr};
double* p_fractional {};
int* reads as "pointer to int". It does not hold an integer; it holds the address in memory of some int variable.
The null pointer
{} or nullptr means "points at no valid object". Its purpose is to give the pointer a known initial state instead of a random address. Never dereference a null pointer — that is accessing an address that is explicitly invalid.
Every pointer has the same size
std::cout << "sizeof(int*) : " << sizeof(int*) << std::endl;
std::cout << "sizeof(double*) : " << sizeof(double*) << std::endl;
Both are 8 bytes here. They store memory addresses, and an address has a fixed width on a given machine (64 bits on a 64-bit system). The pointee type only decides how the compiler interprets the memory behind the address; it does not change the size of the pointer itself.
Position of *, and the comma trap
int* p_a; // the lecturer's preference
int *p_b; // also fine
int * p_c; // also fine
All three are legal; whitespace around * does not change the meaning. But this form is a trap:
int* p_number5, other_int; // other_int is a plain int, not a pointer!
* applies only to the declarator that follows it. other_int has type int. The lecture verifies this with sizeof: sizeof(p_number5) is 8 while sizeof(other_int) is 4. The conclusion is to declare one variable per line, especially pointers.
Address-of and dereference
int int_var {43};
int* p_int {&int_var}; // & takes the address
std::cout << "int_var : " << int_var << std::endl; // 43
std::cout << "p_int : " << p_int << std::endl; // a hex address
std::cout << "*p_int : " << *p_int << std::endl; // 43
&xyields the address ofx, with typeint*.*pfollows the pointer to the value at that address; this is dereferencing.
A pointer can be reassigned to another variable of the same type:
int int_var1 {65};
p_int = &int_var1; // now points at int_var1
Types must match
int* p_int1 {};
double double_var {33.0};
p_int1 = &double_var; // compile error
The compiler reports something like "cannot convert double* to int*". Dereferencing interprets memory according to the type: an int* reads 4 bytes as an integer, while a double occupies 8 bytes with a different encoding. Allowing the assignment would make *p produce nonsense, so the type system forbids it. An explicit cast exists, but that is a different conversation.
Character pointers and string literals
A character pointer is declared and used like any other:
char char_var {'A'};
char* p_char {&char_var};
std::cout << *p_char << std::endl; // A
What is special is that it can be initialised from a string literal:
const char* p_message {"Hello world"};
The compiler expands the literal into a character array with a trailing '\0', and p_message points at its first character.
Why const is required
String literals live in read-only storage, and modifying them is undefined behaviour. Modern compilers require const char*:
char* p_message {"Hello world"}; // ill-formed since C++11
const char* p_message {"Hello world"}; // correct
The lecture tries both GCC and MSVC, and both reject the first. This is not pedantry: allowing a char* would let you write p_message[0] = 'B' on memory that cannot be written.
Printing the pointer versus the dereference
const char* p_message {"Hello world"};
std::cout << p_message << std::endl; // Hello world
std::cout << *p_message << std::endl; // H
std::cout treats a char* specially: it prints character by character from the first address until '\0', so printing the pointer gives the whole string; *p_message is a single char, so it prints one character. This is the same behaviour as the character arrays in the previous chapter — they are the same thing.
Use an array when you need to modify
char message[] {"Hello world"};
message[0] = 'B'; // legal
std::cout << message << std::endl; // Bello world
The array owns a writable copy; the pointer points at a read-only literal. Read-only text uses const char*; modifiable text uses char[].
The program's memory model
Figure:Which segment each declaration lands in, and what a stack frame holds。
Virtual memory
The memory a running program sees is not physical RAM but a virtual address space defined by the operating system. On a 64-bit system every process believes it owns the range to .
The reason is isolation: if every program used physical memory directly, a few hundred processes would exhaust it, and one process could freely read or write another's data. Virtual memory lets each process believe it owns the whole space, while the operating system allocates physical pages on demand.
The role of the MMU
Addresses in the program are translated to physical addresses by the CPU's memory management unit (MMU). The whole program is not loaded at once; only the parts needed soon stay in RAM, and the rest waits on disk and is paged in when required. The operating system and hardware handle this, so C++ code does not manage it manually — but it is worth knowing that the addresses you see are not physical.
Memory layout
A process's address space is roughly divided as follows:
| Region | Contents | Lifetime |
|---|---|---|
| text | machine code | the whole process |
| data | globals and statics | the whole process |
| stack | locals and call frames | determined by scope |
| heap | memory from new | determined by the programmer |
The course focuses on stack and heap, because those decide when variables live and die.
Stack versus heap
{
int local_var {23}; // on the stack, destroyed at the closing brace
}
// local_var no longer exists
Stack variables are governed by scope: created on entering the block, destroyed on leaving. The programmer can neither end them early nor extend their life.
The heap is the opposite:
int* p_number {new int {77}}; // allocate an int on the heap
std::cout << *p_number << std::endl;
delete p_number; // give it back
p_number = nullptr; // avoid a dangling pointer
new int {77} asks the operating system for enough memory for an int, stores 77, and returns the address. That memory belongs to this program until delete, even if the scope that created it has long ended. The heap hands lifetime control to the programmer, at the price of remembering to return the memory.
Three forms to avoid
int* p_number2; // uninitialised, holds a garbage address
*p_number2 = 55; // undefined behaviour, may crash
int* p_number3 {}; // null pointer
*p_number3 = 55; // undefined behaviour, always invalid
int* p4 {new int {99}};
delete p4;
delete p4; // double free, crash
The lecture runs the first two: the program is terminated by the operating system at the write, and the following std::cout never executes. The third crashes too, and is subtler — in a real project two pointers may refer to the same allocation, and a delete through each one is already a double free.
The fixed routine for heap memory
- Receive the address returned by
newin a pointer. - Read and write through the pointer.
deleteto return the memory.- Set the pointer to
nullptr.
Step 4 is often skipped, but it is the cheapest protection against dangling pointers: a later misuse then hits an explicitly null pointer rather than memory that now belongs to someone else.
One-line summary: a pointer holds an address and its type decides how that memory is read; a character pointer refers to a read-only literal, and printing it yields the whole string; the stack ties lifetime to scope, the heap ties it to
new/delete, which must appear once each and as a pair.
Chapter takeaways
int* pholds the address of anint;&xtakes an address and*pdereferences.- A pointer may hold only addresses of objects of the same type; cross-type assignment fails to compile.
- Every pointer has the same size on a given machine; in
int* p, q;theqis a plainint. - A character pointer to a string literal must be
const char*;*pis the first character,pprints the whole string. - Programs see virtual memory; the MMU maps it to physical memory.
- The stack holds locals with scope-determined lifetimes; the heap is managed manually with
newanddelete. - Uninitialised pointers, null dereferences, and double
deleteare undefined behaviour and usually crash.
Comprehension check
1. What does a pointer store, and why do int* and double* have the same size on one machine?
Reference answer: A pointer stores a memory address — the location of an object in the virtual address space. Its size is set by the width of an address, 8 bytes on a 64-bit system, independent of the pointee type. The type only affects how dereferencing interprets the memory (how many bytes to read, integer or floating-point encoding); it does not change the size of the pointer. Hence sizeof(int*), sizeof(double*) and sizeof(char*) are all equal on the same machine.
2. Why is int* p; *p = 55; dangerous? What about int* p {}; *p = 55;?
Reference answer: In the first case p is uninitialised and holds whatever address happened to be left there, possibly belonging to another program or to the operating system; writing 55 is undefined behaviour that may crash or corrupt data. In the second, p is a null pointer that explicitly points at nothing, so dereferencing it is also undefined behaviour and typically crashes immediately. The correct approach is to point it at a real object (int x; int* p = &x;) or allocate with new first.
3. In int* p_number5, other_int;, what type is each variable? Why does the lecturer recommend one declaration per line?
Reference answer: p_number5 is int* and other_int is int. The * belongs only to the declarator that immediately follows it, not to the whole line. The form is easily misread as "both are pointers", and the lecture confirms with sizeof: 8 bytes for the first, 4 for the second. Declaring one variable per line removes the ambiguity, especially for pointers.
4. Why can't the address of a double be assigned to an int*?
Reference answer: Dereferencing interprets memory according to the pointer's type. An int* reads 4 bytes and decodes them as an integer, while a double occupies 8 bytes with an IEEE 754 encoding. If the assignment were allowed, *p would read the first half of the double's bytes and produce a meaningless value, possibly reading across object boundaries. The type system rejects the implicit double*-to-int* conversion at compile time to prevent exactly this.
5. For const char* p {"Hello world"};, what do std::cout << p and std::cout << *p print? Why is const required?
Reference answer: std::cout << p prints Hello world, because std::cout treats a char* as a C string and emits characters from the first address until '\0'. std::cout << *p prints H, because *p is a single char. const is required because a string literal lives in read-only storage and modifying it is undefined behaviour; modern compilers (GCC and MSVC both, as the lecture shows) insist on const char* and reject a plain char*.
6. What controls the lifetime of a stack variable versus a heap allocation, and what is the full routine for using heap memory?
Reference answer: A stack variable's lifetime is controlled by scope: it is created on entering the block and destroyed on leaving, with no programmer control. Heap memory is controlled by the programmer: it stays valid from new until delete, regardless of the scope that created it. The routine is: receive the address from new in a pointer, read and write through it, delete to return the memory, then set the pointer to nullptr to avoid a dangling pointer.
7. Why does deleting the same pointer twice crash? Why set it to null right after deleting?
Reference answer: The first delete returns the memory to the operating system while the pointer still holds the old address, making it dangling. The second delete tries to free memory this program no longer owns, which is undefined behaviour and usually a runtime crash. Setting p = nullptr immediately afterwards puts the pointer into an explicitly invalid state: a later misuse at least reads as a null pointer instead of silently touching memory that has changed hands.
How this connects to the neighbouring chapters
This chapter resolves the loose end from the previous one — an array name prints an address because it decays to a pointer to its first element. Chapter 11 continues into dynamic memory: dangling pointers, leaks, and dynamic arrays. Later, references in chapter 12 can be understood as pointers that cannot be null and cannot be reseated, and class objects in chapter 18 rely heavily on the this pointer and heap allocation.