Lecture 9: 指针、数组与动态内存管理(Pointers, Arrays & Dynamic Memory)(对应课程真实讲座 L16–L17)
Lecture 9: 指针、数组与动态内存管理(Pointers, Arrays & Dynamic Memory)(对应课程真实讲座 L16–L17)
概述
本讲解决一个关键痛点:局部变量随函数返回而”死亡”,导致返回大容器只能靠昂贵的整份拷贝;我们需要的是一块”比函数活得久”的内存。为此先用两天搭建地基——数组与指针(L16),再引入 new/delete 在堆上手动管理内存(L17),最后把它们与面向对象结合,亲手实现一个会自动扩容的数组版栈(ArrayBasedStack),为今后实现各种 ADT 备好全部工具。(官方对应:2026 夏季学期 L16, Tuesday, July 21 — Pointers and Arrays;L17, Wednesday, July 22 — Dynamic Memory Management。)
核心概念与算法原理
1. 动机:让对象”长生”
问题定义:vector<int> createRandoVector(int n) 在函数里造好一个 vector 再返回,返回的是拷贝而不是原物——因为原物在 return 时已死。官方在 L17 开头用两种方法证明:打印地址能看到两个 vector 地址不同;给 Quokka 类加打印析构函数,能看到函数一返回对象就”R.I.P.”。返回拷贝既慢(要把每个元素复制一遍),又没法”把大对象本身带出去”。
解决方案预览:用 new 在堆上申请内存,对象就活过了函数的寿命;函数只返回一个指针(8 字节地址),飞快。而指针正是 L16 的主角。
2. 数组:连续内存与它的危险
它是什么:数组是能装多个同类型值的变量,格子(cell)从 0 编号,长度 n 则有格子 0..n-1。元素存储在一块连续的内存里,arr[i] 就是”从首地址向前跳 i 格”,因此按下标访问是 O(1)。与 vector 相比:数组大小定死、没有 size()/add() 等成员函数、是 C++ 语言内建类型(vector 内部往往就藏着一个数组)。
危险:不初始化数组 → 垃圾值;越界不检查 → 可能覆盖无关内存、程序崩溃且报错信息毫无帮助。vector 每次访问都会查界并给出明确错误,而裸数组把这层保护撤掉了——责任回到了程序员身上。官方把越界造成的崩溃称作分段错误(segmentation fault)。
3. 内存地址与指针
它是什么:每个变量在内存里都有一个地址(通常用十六进制表示)。指针就是”存地址的变量”——像一个只存一个号码的通讯录。声明指针要先说明它准备存”哪种东西的地址”:int *p; 表示 p 能存一个 int 的地址。
执行 int x = 55; int *p = &x; 之后(地址为示意,每次运行都不同):
┌──────────────┐
x: │ 55 │ ← 一块 int 内存
└──────────────┘
▲
│ p 里存的是 x 的地址(即 &x)
┌─────┴────────┐
p: │ 0x7fff…c0c4 │
└──────────────┘
*p = 30 ⇒ 沿着箭头找到 x 的盒子,把 55 改成 30(p 本身不变)
& 的上下文双义(超级重点):在声明里 int &r = x; 中的 & 制造的是引用(别名);对已存在的变量 &x 中的 & 是取地址。二者语法相同、语义完全不同。
- 的上下文双义*:在声明里
int *p;中的 * 声明 p 是指针;在表达式里*p = 30是解引用——”去 p 里那个地址看看,操作那里的变量”。多个指针可同时指向同一变量(通讯录副本),任一解引用都能改到同一个 x。声明风格提醒:int* p, q;里只有 p 是指针、q 是普通 int,所以更稳妥的写法是int *p;一个变量一行或每行都带*。
4. 指针与数组的关系
它是什么:裸数组名(不带方括号)就是首元素的地址:arr 等价于 &arr[0]。方括号对指针同样生效:让 int *p = arr; 之后,p[i] 就是 *(p + i),于是可以完全用指针遍历数组。区别在于:指针可以被重新赋值指向别处,而数组名被”焊死”在自己的数组上,不能再指向他处。
5. nullptr 与悬垂指针
它是什么:暂时不指向任何有用东西的指针应赋 nullptr(空指针)。对 nullptr 解引用会段错误,所以使用指针前先判空是好习惯。另一种危险是悬垂指针(dangling):返回”局部变量的地址”,函数一结束那块栈内存已被回收,拿着地址再去访问就是访问死人的遗物,可能崩溃或得到垃圾——这正是官方强调”局部变量随函数消亡”的原因。
6. 动态内存:new / delete 与栈 vs 堆
问题定义:栈空间(static allocation)随函数调用自动分配、自动回收,无法产生”比函数长寿”的对象。C++ 提供 new:new DataType 在堆空间(heap)申请一块内存并返回其地址;new int[5] 申请数组;数组长度可以是运行时才知道的变量。堆上对象不随函数返回消失,但必须由我们手动归还。
操作步骤:① int *p = new int[5]; 申请;② 使用;③ 用完 delete[] p; 归还。单对象用 delete p;,数组必须用 delete[] p;(带方括号),二者不可混用。
栈(static allocation) 堆(heap / 动态分配)
自动分配,函数返回即自动释放 new 申请;不随函数返回消失,必须手动 delete
┌────────────────────────────────┐ ┌──────────────────────────────────┐
│ main() │ │ │
│ ArrayBasedStack s; │ │ s._elements 指向的数组 │
│ ┌────────────────────────────┐ │ │ (new int[2] 申请而来) │
│ │ s._elements ──────────────┼─┼────►│ ┌────┬────┐ │
│ │ s._size = 1 │ │ │ │ 10 │ ?? │ │
│ │ s._capacity = 2 │ │ │ └────┴────┘ │
│ └────────────────────────────┘ │ │ 0 1 │
│ main 里所有普通局部变量都在这 │ │ │
│ (x、p、对象 s 的"壳"……) │ │ main 返回时数组仍存活; │
└────────────────────────────────┘ │ 只有 s 的析构 delete[] 才能归还它 │
└──────────────────────────────────┘
对象 s 只是"壳":壳在栈上、随函数消亡;
但壳里的指针指向的数组在堆上,可以活得比函数久——这就是"长生"的真相。
内存泄漏:堆内存不会自己回家。若丢了最后指向它的指针(没 return、被覆盖)或忘了 delete,这块内存就成了孤儿,程序一直占着它。官方警告:在循环里调用一个”new 了却不归还、也不返回地址”的函数一亿次,内存会被悄悄吃光。规矩:每个 new 必须配一个 delete,且要在丢失最后一个指针之前执行。
delete 后的危险:delete p 只是归还内存、并没有删除 p 这个变量,p 里仍留着那块地址。此时再 *p 解引用就像去翻垃圾桶喝隔夜咖啡——可能还有味儿,也可能已经中毒。官方原话的精神是:绝不要对已 delete 的地址再解引用,稳妥做法是随后把 p 置为 nullptr。
指针访问成员:对结构/类对象用 . 取成员;对指向它的指针用箭头 ->(即 (*p).成员 的简写,后者又丑又长,别用)。
7. const 成员函数
读函数(如 peek()、size()、isEmpty())不会修改对象状态,应在声明与定义处写成 int peek() const;——const 让编译器替我们保证该函数改不了任何成员变量,改就编译失败。
8. 浅拷贝灾难与 Rule of Three(预告)
当一个类的成员是指向堆内存的指针(如 ArrayBasedStack 的 int *_elements),默认拷贝构造/赋值做的是浅拷贝:只复制指针值,于是两个对象的 _elements 指向同一块堆数组——任一方修改影响另一方,更糟的是双方析构都会 delete[] 同一块内存,造成双重释放(double free)崩溃。要安全支持拷贝,必须自己实现三件套(Rule of Three):拷贝构造、拷贝赋值、析构,让每个对象各自 new 自己的数组并逐元素深拷贝。本讲先把”为什么必须”讲透,链表那一周的课程会完整实现。
代码示例与实现详解
示例 1:指针基本功综合演示(取址/解引用/指针传参/数组与指针/箭头操作符)
#include <iostream>
using namespace std;
// 用指针交换两个变量的值:拿到地址,函数内解引用即可改回 main 里的变量
void swapByPointer(int *a, int *b) {
int temp = *a; // *:表达式里 = 解引用,去 a 指向的地址取值
*a = *b;
*b = temp;
}
struct Point { // 小型结构:用来演示 -> 箭头操作符
int x;
int y;
};
int main() {
int x = 55;
int *p = &x; // &x = 取地址;声明里的 * = p 是指针
cout << "x = " << x << ", &x = " << &x << endl;
cout << "p = " << p << ", *p = " << *p << endl;
*p = 30; // 表达式里的 * = 解引用:把 30 放进 x
cout << "修改后 x = " << x << endl; // 30
int a = 10, b = 20;
swapByPointer(&a, &b); // 传地址;不传地址则函数内改不到原变量
cout << "交换后 a = " << a << ", b = " << b << endl;
int arr[5] = {7, 11, 13, 17, 19};
int *q = arr; // 裸数组名 = 首元素地址(arr == &arr[0])
for (int i = 0; i < 5; i++)
cout << "arr[" << i << "] = " << q[i]
<< " (地址 " << (q + i) << ")" << endl; // q[i] ≡ *(q + i)
Point pt{3, 4};
Point *pp = &pt;
pp->x = 99; // -> 等价于 (*pp).x,但简洁得多
cout << "pt = (" << pt.x << ", " << pt.y << ")" << endl;
// int *bad = nullptr; *bad = 5; ← 解引用空指针 = 段错误!注释掉,别运行
return 0;
}
【代码做什么】 建立 x 与 p 后打印值、地址、指针内容,用 *p = 30 间接改写 x;swapByPointer 通过两个 int 指针交换 main 里 a、b 的值(对比”按值传参改不到原变量”);把数组名交给指针 q,用 q[i] 与 q + i 两种视角遍历数组;再用 pp->x 修改结构成员。
【实现机制解说】
int *p = &x;一行同时出现两种运算符语义:声明上下文里的*(p 是指针)与表达式上下文里的&(取 x 的地址)。*p = 30则是表达式里的*(解引用)。官方特意强调:& 与 * 在不同上下文含义不同,这是初学最绕的地方——判断标准是看它出现在声明里还是对已存在变量的操作里。- 数组访问
q[i]本质上就是*(q + i):方括号先让指针”跳 i 个格子”,再解引用。这解释了为何数组元素地址连续、按下标访问 O(1)。 swapByPointer(&a, &b)若忘写两个 &,函数收到的是 a、b 的拷贝值,交换只发生在函数内部——传地址是”能改到外面变量”的前提。
示例 2:ArrayBasedStack——用动态数组实现的自动扩容栈(new[]/delete[] 完整配套)
把指针 + 动态内存 + 类三样工具合体:对象 s 是栈上的”壳”,壳里的 _elements 指向堆上数组;push 满了就”买新房子、搬东西、拆旧房”。这是官方 L17 的重点示例,也是作业 5 的前奏。
#include <iostream>
using namespace std;
class ArrayBasedStack {
public:
ArrayBasedStack(); // 构造:new 一块初始数组
~ArrayBasedStack(); // 析构:delete[] 释放堆数组,防泄漏
void push(int value); // 入栈;满了先扩容(容量 ×2 + 1)
int pop(); // 出栈并返回栈顶
int peek() const; // 只看栈顶(const:保证不改状态)
int size() const;
bool isEmpty() const;
private:
int *_elements; // 指向堆上数组首元素
int _size; // 当前元素个数
int _capacity; // 数组容量
};
ArrayBasedStack::ArrayBasedStack() {
_capacity = 2; // 故意给个小容量,便于观察扩容
_elements = new int[_capacity];
_size = 0;
cout << "构造:容量 " << _capacity << endl;
}
ArrayBasedStack::~ArrayBasedStack() {
delete[] _elements; // 铁律:每个 new[] 配一个 delete[]
cout << "析构:堆数组已归还系统" << endl;
}
void ArrayBasedStack::push(int value) {
if (_size >= _capacity) { // 满了?先扩容
int *newArray = new int[_capacity * 2 + 1]; // 新家(×2+1:容量为 0 时也能变大)
for (int i = 0; i < _size; i++)
newArray[i] = _elements[i]; // 逐个搬运旧元素
delete[] _elements; // 拆旧房——必须先释放再换指针,否则旧地址丢失 = 泄漏
_elements = newArray; // 壳里的指针指向新家
_capacity = _capacity * 2 + 1;
cout << "扩容:容量 " << _capacity << endl;
}
_elements[_size] = value; // 数组下标当栈顶用
_size++;
}
int ArrayBasedStack::pop() {
if (isEmpty()) { cout << "错误:空栈不能 pop" << endl; return -1; }
int result = _elements[_size - 1]; // 栈顶 = 下标 _size-1
_size--; // 逻辑删除即可,不必清掉旧值
return result;
}
int ArrayBasedStack::peek() const {
if (isEmpty()) { cout << "错误:空栈不能 peek" << endl; return -1; }
return _elements[_size - 1];
}
int ArrayBasedStack::size() const { return _size; }
bool ArrayBasedStack::isEmpty() const { return _size == 0; }
int main() {
ArrayBasedStack s; // 对象壳在栈上,数组在堆上
for (int i = 1; i <= 10; i++)
s.push(i * 10); // 观察容量:2 → 5 → 11
while (!s.isEmpty())
cout << s.pop() << " ";
cout << endl;
return 0; // s 离开作用域 → 析构自动 delete[]
}
运行输出:构造:容量 2、两次 扩容:容量 5 / 容量 11、倒序弹出 100 到 10、析构:堆数组已归还系统。
【代码做什么】 构造时用 new int[_capacity] 在堆上申请数组并清零计数;push 先检查 _size >= _capacity,满了就申请一块 ×2+1 的新数组、逐元素搬运、delete[] 旧数组、再把 _elements 指过去;pop/peek 都只操作下标 _size-1;析构函数 delete[] _elements 完成”善后”。
【实现机制解说】
- new/delete 配对铁律:本例中每块
new int[...]都对应一个delete[]——构造申请、析构归还、扩容时先释放旧数组。顺序也讲究:扩容时必须先 delete[] 旧数组再改_elements,若先改指针,旧地址从此丢失,那块内存就泄漏了。 - 扩容公式为什么是 ×2+1:官方给出的理由——若某结构初始容量恰为 0,×2 永远是 0,永远扩不了容;+1 保证任何初始容量都能增长。翻倍增长还保证了均摊 O(1)(见复杂度表)。
const成员函数:peek/size/isEmpty 声明为 const,编译器强制它们不得修改_size、_elements等成员,是”读操作”的身份证。- 浅拷贝的双释放灾难:若有人写
ArrayBasedStack s2 = s1;,默认拷贝构造把 s2 的_elements也指向 s1 那块数组——两个壳共享一份堆内存;main 结束时 s2、s1 各自析构,对同一块内存delete[]两次,程序直接崩溃。这正是”有堆指针成员的类必须实现拷贝三件套”的原因(示例 2 为聚焦指针主题刻意未实现,见下补充)。
补充——深拷贝三件套(Rule of Three)的修法思路:
// 拷贝构造:自己 new 一块新数组,逐元素深拷贝
ArrayBasedStack::ArrayBasedStack(const ArrayBasedStack& other) {
_capacity = other._capacity; _size = other._size;
_elements = new int[_capacity];
for (int i = 0; i < _size; i++) _elements[i] = other._elements[i];
}
// 拷贝赋值:先释放自己的旧资源,再深拷贝对方
ArrayBasedStack& ArrayBasedStack::operator=(const ArrayBasedStack& other) {
if (this == &other) return *this; // 防自赋值
delete[] _elements; // 归还旧数组
_capacity = other._capacity; _size = other._size;
_elements = new int[_capacity];
for (int i = 0; i < _size; i++) _elements[i] = other._elements[i];
return *this;
}
// 再配合已有的析构 delete[],三件套齐了:s2 = s1 后各有一块数组,互不影响、各删各的。
复杂度分析
| 操作 | 均摊/平均 | 最坏 | 原因 |
|---|---|---|---|
| 按下标访问数组元素 arr[i] | O(1) | O(1) | 基址 + 偏移直接定位,无需查找 |
| 取地址 &x / 解引用 *p | O(1) | O(1) | 拷贝一个地址 / 一次间接寻址 |
| push(动态数组栈) | O(1) | O(n) | 平时直接写栈顶 O(1);扩容那次要搬运全部旧元素 |
| pop / peek / size / isEmpty | O(1) | O(1) | 只碰栈顶位置与两个计数器 |
| 空间 | O(n) | O(n) | 容量按 2 倍+1 增长,与元素个数同阶 |
| new / delete 单次申请 | O(1)(均摊) | 视分配器 | 内存管理由运行库负责,调用本身是常数级操作 |
要点:扩容虽偶发 O(n),但容量翻倍使”搬运成本”被摊薄到每次 push 上,故 push 整体均摊 O(1)——这正是 vector 内部 add 高效的原因。
关键要点
- 指针 = 存地址的变量;& 与 * 在”声明”与”表达式”里含义不同:声明里 & 造引用、* 造指针,表达式里 & 取地址、* 解引用。
- 数组是连续内存、下标从 0 开始、越界不检查;裸数组名就是首元素地址,
arr[i]即*(arr + i)。 - 栈自动管理、随函数消亡;new 在堆上申请的内存活得比函数久,但必须手动归还——每个 new 配一个 delete(数组用 delete[])。
- delete 只归还内存不删除指针,绝不要对已 delete 的地址再解引用,之后顺手置 nullptr;也不要解引用 nullptr。
- 类持有堆指针成员时,默认浅拷贝会导致双释放;需要拷贝构造 + 拷贝赋值 + 析构三件套(Rule of Three)做深拷贝。
常见陷阱与注意事项
- 未初始化数组直接读:格子是垃圾值;要么声明时初始化
int arr[5] = {0};,要么先赋值再读。 - 数组越界:C++ 不查界,越界可能悄悄改写别的内存或段错误;自己盯紧 0..size-1 边界。
- 忘 delete / 丢指针:泄漏;在丢失最后一个指针前完成 delete,或用析构函数统一善后。
- delete 后仍解引用:那块内存可能已被别人占用,行为未定义;delete 后置 nullptr 再判空使用。
- 单对象与数组混淆:
new int配delete,new int[n]必须配delete[],混用是未定义行为。 - 声明
int* p, q;的错觉:只有 p 是指针;每行写清int *p; int *q;更不易错。 - 函数想改原变量却忘传地址/引用:按值传参只改副本;要么传引用
int &a,要么传地址int *a。 - 对 nullptr 解引用 / 对悬垂指针解引用:前者段错误,后者是”访问死人的遗物”;返回局部变量的地址前先想想它是否已死。
- 浅拷贝双释放:把含堆指针的类按值拷贝(传参、赋值、塞进容器)前,先确认该类实现了拷贝三件套或禁止拷贝。
- 扩容顺序写反:先换指针后释放旧数组 = 旧地址丢失 = 泄漏;务必”先 delete[] 旧的,再让 _elements 指向新的”。
思考题(带答案)
问题 1:写一个函数 bool samePlace(int *p, int *q) 判断两个指针是否指向同一块内存,再写 bool sameValue(int *p, int *q) 判断指向的值是否相等(参考官方练习题)——两者差别在哪? 答案:samePlace 直接比较指针值:return p == q;(比地址)。sameValue 必须先解引用再比:return *p == *q;。两个不同地址里可以存相同的值(如 a=11、b=11),所以 samePlace(&a,&b) 为 false 而 sameValue(&a,&b) 为 true——比较”在哪里”与比较”是什么”是两回事。
问题 2:int *p = new int[100]; 之后若直接执行 p = new int[50];(忘了先释放),会发生什么?若改成先 delete[] p; 再赋新值呢? 答案:第一种写法把旧数组唯一的地址覆盖了,100 个 int 的堆内存永远无法归还——内存泄漏(官方把这种”完全失去指针记录”的块叫 orphaned memory)。第二种写法先 delete[] p; 把旧数组还给系统,再申请新数组,新旧交替毫无泄漏——这就是”每个 new 都要配 delete,且在丢失指针之前”的含义。
问题 3:ArrayBasedStack 的扩容为什么用 new int[_capacity * 2 + 1] 而不是 * 2?如果把 int *_elements 换成 std::vector<int>,哪些问题会自动消失? 答案:若初始容量为 0,* 2 永远得 0,永远无法扩容,+1 保证容量严格增长(官方在练习里给出的理由)。换用 vector 后,扩容、拷贝、释放都由 vector 自己管理,双释放与泄漏风险消失——但这正是我们看不见”幕后发生了什么”的原因,学本讲就是要掀开 vector 的引擎盖看个明白。
