第 11 章

动态内存:悬空指针、分配失败、泄漏与动态数组

这一章讲三类悬空指针及其对策、new 失败时的异常与 nothrow 两种处理、delete 空指针的安全性,以及三种内存泄漏和动态数组 new[]/delete[] 的限制。

Dynamic Memory: Dangling Pointers, Failed Allocation, Leaks, and Dynamic Arrays

This chapter covers the three sources of dangling pointers and the three habits that prevent them, how to handle a failed new through exceptions or std::nothrow, why deleting a null pointer is safe, the three shapes of memory leak, and new[]/delete[] with the limits that come with dynamic arrays.

首发于 2022.02.17中 / EN
系统课程cppdynamic-memorydangling-pointermemory-leakheap
语言
字号
目录
篇内目录

这一章在讲什么

这是 31 小时长视频的第 11 章,对应 11:05:24–12:22:23。

上一章引入了 new 与 delete,这一章处理它们带来的全部麻烦。讲者的组织方式很清晰:先列出三类悬空指针(指向无效地址的指针),再给出对应的三条纪律;然后是 new 分配失败时怎么办;接着是内存泄漏的三种典型场景;最后是动态数组——new[] 与 delete[],以及它和栈数组的两个关键差别。

这一章几乎没有新语法,全是「怎么不把程序写崩」的工程经验。

时间轴

时间内容
11:05:24悬空指针的定义:指向已失效地址的指针
11:06:32情形一:未初始化的指针
11:06:54情形二:delete 之后继续使用
11:07:40情形三:多个指针指向同一块内存
11:08:32三条对策:初始化、归还后置空、主从指针
11:12:24VS Code 实操:三种情形逐一复现
11:24:20new 为什么会失败:堆不是无限的
11:26:26方案一:try / catch 捕获 std::bad_alloc
11:27:46方案二:new (std::nothrow) 返回空指针
11:37:51空指针安全:用 if (p) 检查后再使用
11:39:28对空指针调用 delete 是安全的
11:45:27内存泄漏:指针被重新赋值
11:46:52内存泄漏:重复分配同一指针
11:48:03内存泄漏:指针随作用域销毁
11:55:39动态数组:new double[n]
11:57:28动态数组的初始化与部分初始化
11:59:04动态数组不能用 std::size 与范围 for
12:00:21VS Code 实操:分配、遍历、delete[]

三类悬空指针

悬空指针(dangling pointer)指向的地址已经无效。课程列出三种来源:

情形一:未初始化

int* p_number5;      // 内容是垃圾地址
*p_number5 = 55;     // 未定义行为

指针声明后没有赋值,它保存的是栈上残留的字节。这个地址可能属于别的变量、别的程序,或者操作系统。

情形二:delete 之后继续使用

int* p_number1 {new int {67}};
delete p_number1;
*p_number1 = 55;     // 未定义行为:内存已经归还

delete 只归还内存,不会清空指针本身。指针仍然保存着旧地址,但这个地址已经不属于你。

这里有一个反直觉的现象:程序有时候看起来正常运行,输出还是 67。原因是那块内存暂时没被别人占用。但这不是保证——讲者在演示里连续运行几次,结果并不稳定。「这次没崩」不等于代码正确。

情形三:多个指针指向同一块内存

int* p_number3 {new int {83}};
int* p_number4 {p_number3};   // 两个指针指向同一地址

delete p_number3;             // 内存已归还
std::cout << *p_number4;      // 读已释放的内存

两个指针都指向同一块堆内存。通过其中一个 delete 之后,另一个立刻变成悬空指针。

三条对策

对策一:声明时就初始化

int* p_number5 {};                 // 空指针
int* p_number6 {new int {82}};     // 或直接分配

不知道指向哪里就先给 nullptr,用之前再检查:

if (p_number6) {
    std::cout << *p_number6 << std::endl;
} else {
    std::cout << "Invalid address" << std::endl;
}

对策二:delete 之后立刻置空

delete p_number7;
p_number7 = nullptr;

这样后续任何检查 if (p) 都能发现它无效,而不是悄悄读写一块已易主的内存。

对策三:主从指针

多个指针指向同一块内存时,指定一个主指针负责 delete,其余都是从指针,只读不释放。使用前先检查主指针:

int* master {new int {382}};
int* slave {master};

if (master) {
    std::cout << *slave << std::endl;
}

delete master;
master = nullptr;

if (!master) {
    std::cout << "Memory already released" << std::endl;
}

这条纪律的核心是所有权唯一:一块内存只能有一个负责人。

当 new 失败

堆不是无限的。一次申请极大的内存,或在循环里不断 new 而不释放,都会让分配失败。默认情况下 new 失败会抛出 std::bad_alloc,程序若不捕获就会终止。

方案一:异常

try {
    int* data {new int[100000000]};
    // 使用 data
    delete[] data;
} catch (const std::exception& ex) {
    std::cout << "Something went wrong: " << ex.what() << std::endl;
}

失败时控制流跳到 catch,程序继续运行。ex.what() 返回描述错误的字符串,通常是 std::bad_alloc。

方案二:std::nothrow

#include <new>

int* data {new (std::nothrow) int[100000000]};
if (data) {
    // 分配成功
    delete[] data;
} else {
    std::cout << "Allocation failed" << std::endl;
}

std::nothrow 让 new 失败时返回 nullptr 而不是抛异常,适合不想引入异常处理的场景。

课程同时提醒:实践中 new 失败很罕见,所以很多代码根本不检查。但服务器这类长期运行的程序需要处理,否则一次失败就整体退出。

空指针安全

核心原则只有一句:使用指针前,确认它持有有效地址。

int* p_number {};
if (p_number) {
    std::cout << *p_number << std::endl;
} else {
    std::cout << "Invalid address" << std::endl;
}

if (p_number) 等价于 if (p_number != nullptr):指针可以隐式转换为 bool,空指针为假,非空为真。前者更简洁,两种写法都常见。

一个常被过度使用的地方是 delete:

int* p_number1 {};
delete p_number1;        // 完全安全,什么也不做

对空指针调用 delete 是合法的,标准规定它不做任何事。所以不需要写成:

if (p_number1) {         // 多余
    delete p_number1;
}

这一点值得记住:它让「释放后置空」的写法更自然——即使代码路径重复走到 delete,只要指针是空的就无害。

内存泄漏

泄漏是指丢失了指向堆内存的指针:内存仍被本程序占用,但程序再也无法访问它,也无法归还。

情形一:指针被重新赋值

int* p_number {new int {67}};
int number {55};
p_number = &number;      // 原来那块堆内存失去了唯一的指针

从此没有任何指针指向那块内存,delete 也无从下手。

情形二:重复分配

int* p_number1 {new int {55}};
p_number1 = new int {44};   // 第一块内存泄漏
delete p_number1;           // 只释放了第二块

情形三:指针随作用域销毁

{
    int* p_number2 {new int {57}};
    // 作用域结束,p_number2 销毁,但它指向的堆内存不会自动释放
}

局部指针变量在栈上,离开作用域就消失;堆上的内存不会跟着消失。此时既没有指针能访问它,也没有指针能释放它。

课程强调泄漏的长期后果:服务器程序可能连续运行数月,泄漏会不断累积,最终耗尽堆,导致后续 new 失败甚至进程被杀。

动态数组

栈数组的大小必须是编译期常量(或至少是 const),而动态数组允许运行期决定大小:

size_t size {10};

double* p_salaries {new double[size]};              // 未初始化
int* p_students {new (std::nothrow) int[size] {}};  // 全部置 0
int* p_scores {new (std::nothrow) int[size] {1, 2, 3, 4, 5}};  // 前五个赋值,其余 0

三种写法的差别只在初始化方式:不写花括号是未初始化(垃圾值),写 {} 是全部置 0,写部分初始值则剩余元素补 0——与栈数组的规则一致。

访问与释放

for (size_t i {0}; i < size; ++i) {
    std::cout << p_scores[i] << std::endl;      // 下标
    // 或 *(p_scores + i)                      // 指针算术
}

delete[] p_salaries;
p_salaries = nullptr;
delete[] p_students;
p_students = nullptr;
delete[] p_scores;
p_scores = nullptr;

数组必须用 delete[],不是 delete。混用是未定义行为。

两个关键限制

std::cout << std::size(p_scores);        // 编译错误
for (auto s : p_scores) { /* ... */ }    // 编译错误

原因是:p_scores 的类型是 int*,它已经退化为指针,不再是数组对象。std::size 需要知道数组类型才能算出元素个数,范围 for 也需要数组或带 begin()/end() 的容器。指针本身不携带长度信息,所以两者都失效。

解决方法是自己保存长度:

size_t count {10};
int* p_scores {new int[count] {1, 2, 3, 4, 5}};
for (size_t i {0}; i < count; ++i) {
    std::cout << p_scores[i] << std::endl;
}

对比之下,栈数组 int scores[10] 仍然可用 std::size 与范围 for,因为它还是数组对象。

一句话总结: 指针失效有三种来源,对应初始化、置空、明确所有权三条纪律;new 可能失败,用异常或 nothrow 处理;泄漏的根源是丢失指针;动态数组用 new[]/delete[],并且必须自己记住长度。

本章结论

  • 悬空指针的三种来源:未初始化、delete 后继续使用、多指针共享内存。
  • 三条对策:声明即初始化、delete 后置空、主指针唯一负责释放。
  • new 失败默认抛 std::bad_alloc;new (std::nothrow) 改为返回空指针。
  • 使用指针前用 if (p) 检查;对空指针 delete 是安全的,无需额外判断。
  • 内存泄漏的三种场景:指针被覆盖、重复分配、指针随作用域销毁。
  • 动态数组用 new T[n] 分配、delete[] 释放,大小可在运行期决定。
  • 动态数组退化为指针,因此 std::size 与范围 for 都不可用,长度需要自己保存。

理解检验

1. 悬空指针有哪三种来源?分别给出一个最小复现。

参考答案:第一,未初始化的指针:int* p; *p = 55;,p 里是垃圾地址。第二,delete 之后继续使用:int* p {new int{67}}; delete p; *p = 55;,内存已归还但指针未置空。第三,多个指针指向同一块内存:int* a {new int{83}}; int* b {a}; delete a; std::cout << *b;,b 在主指针释放后失效。

2. 为什么「使用已释放指针」有时看起来没有崩溃?这说明代码正确吗?

参考答案:delete 只是把内存归还给分配器,并不会立刻抹掉内容,也不会使指针变成空。若这块内存还未被重新分配,读到的仍是旧值,程序看起来「正常」。但这是未定义行为:内存随时可能被其他分配复用,读到垃圾、崩溃或破坏数据都可能发生,多次运行结果也可能不同。所以「这次没崩」不能作为正确性的证据。

3. new 失败时,try/catch 与 new (std::nothrow) 两种处理方式有什么区别?各适合什么场景?

参考答案:默认的 new 在失败时抛出 std::bad_alloc,不捕获就会终止程序;try/catch 捕获后可以继续运行并给出提示,适合项目已经统一使用异常处理的情况。new (std::nothrow) 则要求 new 不抛异常,失败时返回 nullptr,调用方用 if (data) 判断即可,适合不想引入异常、或局部处理分配失败的场景。两者都能避免「一次分配失败就整体崩溃」。

4. 对空指针调用 delete 会发生什么?为什么这会影响写代码的方式?

参考答案:对空指针调用 delete 是合法的,标准规定它什么也不做,不会崩溃。因此不需要在 delete 之前写 if (p) 检查。受益最大的是「释放后立即置空」的写法:即使某条代码路径重复执行 delete,只要指针已经被置空,重复调用也安全;这减少了分支,也让清理逻辑更简单。

5. 内存泄漏的三种典型场景是什么?为什么最后一种最隐蔽?

参考答案:一,指针被重新赋值,原本指向的堆内存失去唯一入口;二,对同一个指针再次 new,前一次分配的内存失去指针;三,指针是局部变量,作用域结束时指针销毁,而它指向的堆内存不会自动释放。第三种最隐蔽,因为代码里看不出「覆盖」或「重复分配」,泄漏发生在作用域边界处,而且局部指针本身在栈上,看起来「正常销毁」了。

6. 动态数组为什么不能用 std::size 和范围 for?应该怎么补足?

参考答案:new T[n] 返回的是 T*,它在类型上已经是指针,不再是数组对象。std::size 依赖数组类型来推导元素个数,范围 for 需要数组或提供 begin()/end() 的容器;指针两者都不满足,所以编译失败。补足办法是自己保存长度:用变量记录 n,再用传统 for 加下标(或指针算术)遍历。栈数组 int a[10] 仍然是数组对象,因此两种写法都能用。

7. 释放动态数组为什么必须用 delete[] 而不是 delete?

参考答案:new T[n] 分配的是「数组」,分配器除了元素本身还要记录元素个数,以便逐个调用析构函数并正确释放整块内存。delete[] 会按数组方式处理:析构每个元素并释放整个块;delete 只按单个对象处理,二者混用是未定义行为,可能只释放一部分或破坏分配器的内部记录。规则就是 new 配 delete、new[] 配 delete[]。

与前后章的关系

这一章把第 10 章引入的 new/delete 用到了边界:失败、悬空、泄漏、数组。下一章(第 12 章)讲引用——一种不能为空、不能改指向的「指针」,它能绕开本章一半的麻烦。再往后,第 18 章的类会引入构造函数与析构函数,把「分配与释放必须成对」这件事交给编译器自动完成,这正是本章这些手工纪律的最终归宿。

What this chapter is about

This is chapter 11 of a single 31-hour video, covering 11:05:24–12:22:23.

The previous chapter introduced new and delete; this one deals with every complication they bring. The structure is clear: first the three kinds of dangling pointer (pointers to addresses that are no longer valid) and the three rules that prevent them; then what to do when new fails; then the three typical shapes of memory leak; finally dynamic arrays — new[] and delete[] — and the two ways they differ from stack arrays.

There is almost no new syntax here. It is engineering discipline: how not to crash your program.

Timeline

TimeTopic
11:05:24What a dangling pointer is
11:06:32Case one: an uninitialised pointer
11:06:54Case two: using a pointer after delete
11:07:40Case three: several pointers to the same memory
11:08:32Three remedies: initialise, null after delete, master pointer
11:12:24Live coding: reproduce each case
11:24:20Why new can fail: the heap is finite
11:26:26Option one: try/catch for std::bad_alloc
11:27:46Option two: new (std::nothrow) returns null
11:37:51Null-pointer safety with if (p)
11:39:28delete on a null pointer is safe
11:45:27Leak one: the pointer is reassigned
11:46:52Leak two: the pointer is allocated twice
11:48:03Leak three: the pointer dies with its scope
11:55:39Dynamic arrays: new double[n]
11:57:28Initialising a dynamic array
11:59:04No std::size and no range-based for
12:00:21Live coding: allocate, walk, delete[]

Three kinds of dangling pointer

A dangling pointer refers to an address that is no longer valid. The course lists three sources.

Case one: uninitialised

int* p_number5;      // holds a garbage address
*p_number5 = 55;     // undefined behaviour

The pointer was never assigned, so it holds whatever bytes were left on the stack. That address may belong to another variable, another program, or the operating system.

Case two: used after delete

int* p_number1 {new int {67}};
delete p_number1;
*p_number1 = 55;     // undefined: the memory has been returned

delete returns the memory but does not clear the pointer. The pointer still holds the old address, which no longer belongs to you.

There is a counter-intuitive effect: the program sometimes appears to work and prints 67, because nothing has reused that memory yet. That is not a guarantee — the lecture runs it several times with unstable results. "It didn't crash this time" is not evidence of correctness.

Case three: several pointers to one allocation

int* p_number3 {new int {83}};
int* p_number4 {p_number3};   // both point at the same address

delete p_number3;             // memory returned
std::cout << *p_number4;      // reads freed memory

Both pointers refer to the same heap block. Once one of them deletes it, the other becomes dangling immediately.

Three remedies

Remedy one: initialise at declaration

int* p_number5 {};                 // null pointer
int* p_number6 {new int {82}};     // or allocate straight away

If you do not yet know what a pointer should refer to, give it nullptr and test before use:

if (p_number6) {
    std::cout << *p_number6 << std::endl;
} else {
    std::cout << "Invalid address" << std::endl;
}

Remedy two: null the pointer right after delete

delete p_number7;
p_number7 = nullptr;

Now any later if (p) sees it as invalid instead of silently reading memory that has changed hands.

Remedy three: a master pointer

When several pointers share one allocation, designate a master that owns the delete; the others are slaves that may read but never free. Check against the master before use:

int* master {new int {382}};
int* slave {master};

if (master) {
    std::cout << *slave << std::endl;
}

delete master;
master = nullptr;

if (!master) {
    std::cout << "Memory already released" << std::endl;
}

The rule underneath is single ownership: one allocation, one owner.

When new fails

The heap is finite. Asking for an enormous block, or calling new in a loop without releasing, eventually fails. By default new throws std::bad_alloc, and an uncaught exception terminates the program.

Option one: exceptions

try {
    int* data {new int[100000000]};
    // use data
    delete[] data;
} catch (const std::exception& ex) {
    std::cout << "Something went wrong: " << ex.what() << std::endl;
}

On failure control jumps to catch and the program keeps running. ex.what() returns a description, usually std::bad_alloc.

Option two: std::nothrow

#include <new>

int* data {new (std::nothrow) int[100000000]};
if (data) {
    // allocation succeeded
    delete[] data;
} else {
    std::cout << "Allocation failed" << std::endl;
}

std::nothrow makes new return nullptr instead of throwing, which suits code that does not use exceptions.

The lecture notes that failure is rare in practice, so a lot of code never checks. Long-running server programs, however, need to handle it: one failure would otherwise take the whole process down.

Null-pointer safety

The principle is one sentence: before using a pointer, confirm it holds a valid address.

int* p_number {};
if (p_number) {
    std::cout << *p_number << std::endl;
} else {
    std::cout << "Invalid address" << std::endl;
}

if (p_number) is equivalent to if (p_number != nullptr): a pointer converts implicitly to bool, false for null and true otherwise. The short form is more common; both are fine.

One place where the check is overused is delete:

int* p_number1 {};
delete p_number1;        // perfectly safe, does nothing

Deleting a null pointer is legal; the standard specifies that it does nothing. So this is unnecessary:

if (p_number1) {         // redundant
    delete p_number1;
}

This matters because it makes "free, then null" natural: even if a code path reaches delete twice, an already-null pointer is harmless.

Memory leaks

A leak means the pointer to a heap allocation is lost: the memory is still charged to this program, but the program can no longer reach it or return it.

Case one: the pointer is reassigned

int* p_number {new int {67}};
int number {55};
p_number = &number;      // the heap block loses its only pointer

Nothing points at that block any more, so no delete can reach it.

Case two: allocated twice

int* p_number1 {new int {55}};
p_number1 = new int {44};   // the first block leaks
delete p_number1;           // only the second is freed

Case three: the pointer dies with its scope

{
    int* p_number2 {new int {57}};
    // the scope ends, p_number2 is destroyed, the heap block is not
}

The local pointer lives on the stack and disappears at the closing brace; the heap block does not follow. Nothing can reach it and nothing can free it.

The lecture stresses the long-term effect: a server may run for months, leaks accumulate, the heap is exhausted, and later new calls fail or the process is killed.

Dynamic arrays

A stack array's size must be a compile-time constant (or at least const); a dynamic array allows the size to be decided at run time:

size_t size {10};

double* p_salaries {new double[size]};              // uninitialised
int* p_students {new (std::nothrow) int[size] {}};  // all zero
int* p_scores {new (std::nothrow) int[size] {1, 2, 3, 4, 5}};  // first five set, rest zero

The three forms differ only in initialisation: no braces means garbage, {} means all zero, and a partial list zero-fills the rest — exactly the rules for stack arrays.

Access and release

for (size_t i {0}; i < size; ++i) {
    std::cout << p_scores[i] << std::endl;      // subscript
    // or *(p_scores + i)                      // pointer arithmetic
}

delete[] p_salaries;
p_salaries = nullptr;
delete[] p_students;
p_students = nullptr;
delete[] p_scores;
p_scores = nullptr;

Arrays require delete[], not delete. Mixing them is undefined behaviour.

Two key limitations

std::cout << std::size(p_scores);        // compile error
for (auto s : p_scores) { /* ... */ }    // compile error

The reason: the type of p_scores is int*. It has decayed to a pointer and is no longer an array object. std::size needs the array type to compute the element count, and range-based for needs an array or a container with begin()/end(). A pointer carries no length information, so both fail.

The fix is to keep the length yourself:

size_t count {10};
int* p_scores {new int[count] {1, 2, 3, 4, 5}};
for (size_t i {0}; i < count; ++i) {
    std::cout << p_scores[i] << std::endl;
}

By contrast, the stack array int scores[10] is still an array object, so std::size and range-based for work there.

One-line summary: pointers go invalid in three ways, answered by initialise, null-after-delete, and single ownership; new can fail, handled by exceptions or nothrow; leaks come from losing the pointer; dynamic arrays need new[]/delete[] and a length you remember yourself.

Chapter takeaways

  • Dangling pointers come from three sources: uninitialised, used after delete, shared between pointers.
  • The three remedies: initialise at declaration, null after delete, one master pointer that frees.
  • A failed new throws std::bad_alloc by default; new (std::nothrow) returns null instead.
  • Check if (p) before use; delete on a null pointer is safe and needs no guard.
  • Leaks arise from a reassigned pointer, a second allocation, or a pointer dying with its scope.
  • Dynamic arrays use new T[n] and delete[], and their size may be decided at run time.
  • A dynamic array decays to a pointer, so std::size and range-based for do not apply; store the length separately.

Comprehension check

1. What are the three sources of dangling pointers? Give a minimal reproduction of each.

Reference answer: First, an uninitialised pointer: int* p; *p = 55;, where p holds a garbage address. Second, using a pointer after delete: int* p {new int{67}}; delete p; *p = 55;, where the memory is returned but the pointer is not nulled. Third, several pointers to one allocation: int* a {new int{83}}; int* b {a}; delete a; std::cout << *b;, where b is invalidated by the master's release.

2. Why does using a freed pointer sometimes appear to work? Does that mean the code is correct?

Reference answer: delete returns the memory to the allocator without wiping its contents or clearing the pointer. If the block has not been reused, the old value is still there and the program seems fine. This is still undefined behaviour: the memory may be reused at any moment, so garbage, corruption or a crash can occur, and runs may differ. "It didn't crash this time" is not evidence of correctness.

3. How do try/catch and new (std::nothrow) differ when allocation fails, and when would you use each?

Reference answer: A default new throws std::bad_alloc, and an uncaught exception terminates the program; wrapping it in try/catch lets the program report the problem and continue, which suits projects already using exceptions. new (std::nothrow) asks new not to throw and to return nullptr on failure, which the caller tests with if (data); it suits code that avoids exceptions or handles the failure locally. Both prevent a single failed allocation from bringing the whole process down.

4. What happens when you delete a null pointer, and how does that affect the way you write cleanup code?

Reference answer: Deleting a null pointer is legal; the standard says it does nothing and it will not crash. So no if (p) guard is needed before delete. The biggest benefit is for "free then null" cleanup: if a code path reaches delete twice and the pointer was nulled the first time, the repeat is harmless. That removes branches and simplifies teardown logic.

5. What are the three typical memory-leak scenarios, and why is the last one the subtlest?

Reference answer: One, the pointer is reassigned so the block loses its only entry point. Two, the same pointer is allocated again, orphaning the previous block. Three, a local pointer is destroyed at the end of its scope while the heap block it referenced is not. The third is subtlest because no overwrite or second allocation is visible in the code — the leak happens at a scope boundary, and the local pointer itself is destroyed normally on the stack, so nothing looks wrong.

6. Why can't a dynamic array be used with std::size or range-based for, and what replaces them?

Reference answer: new T[n] yields a T*, which is a pointer type rather than an array object. std::size needs the array type to deduce the element count, and range-based for needs an array or a container exposing begin()/end(); a pointer satisfies neither, so compilation fails. The replacement is to keep the length in a variable and use an ordinary for with subscripts or pointer arithmetic. A stack array int a[10] is still an array object, so both forms work there.

7. Why must a dynamic array be released with delete[] rather than delete?

Reference answer: new T[n] allocates an array, and the allocator must also record the element count so it can destroy each element and release the whole block. delete[] handles it as an array: it destroys every element and frees the block. delete treats it as a single object and is undefined behaviour here — it may free only part of the block or corrupt the allocator's bookkeeping. The rule is new with delete, new[] with delete[].

How this connects to the neighbouring chapters

This chapter pushes the new/delete of chapter 10 to its edges: failure, dangling, leaks, arrays. Chapter 12 introduces references — pointers that cannot be null and cannot be reseated — which avoid half of these problems. Later, classes in chapter 18 bring constructors and destructors that pair allocation with release automatically, which is the final home for the manual discipline taught here.

来源