Lecture 9: 指针、数组与动态内存管理(Pointers, Arrays & Dynamic Memory)(对应课程真实讲座 L16–L17)

目录 · ← l8 · l10 →

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++ 提供 newnew 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 / 解引用 *pO(1)O(1)拷贝一个地址 / 一次间接寻址
push(动态数组栈)O(1)O(n)平时直接写栈顶 O(1);扩容那次要搬运全部旧元素
pop / peek / size / isEmptyO(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 intdeletenew 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——比较”在哪里”与比较”是什么”是两回事。

问题 2int *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 的引擎盖看个明白。