2026-04-02

C/C++ 之 指针与内存管理

✍️ liguiyu
C++C编程技术分享NUAA指针内存管理

写在前面

本文旨在帮助大一上学期 “程序设计(1)” 中C语言指针与内存管理没完全理解的同学,在大一下学期的C++里学会指针与内存管理
受限于篇幅限制,本文某些时候可能会直接使用C++里的一些语法,而不会使用C语言的语法,比如可能会使用newdelete而不是mallocfree
如有任何问题或错误,欢迎使用个人主页的联系方式联系我,也可以评论留言(需登录GitHub)

[!CAUTION] 如果大家感觉一看到这部分就害怕、头疼,没关系,作者向你承诺这篇文章绝对非常生动形象,你一定可以很轻松的看懂!

[!NOTE] 本文为了方便大家理解,某些表述方法或比喻可能不够精确,如有建议,欢迎与我联系、讨论!

[!TIP] 如何判断自己是否真正掌握了C语言的指针?

看看自己能不能回答以下问题:

  1. 定义int a[i]a[i]i[a](i+1)[a-1]为什么是等价的?
  2. 定义int a[10][20],传入这个二维数组的时候,为什么传入int a[][20]可以,而传入int a[10][]就不可以?(需要知道具体为什么不可以)
  3. 定义int a[10][20],a是什么类型?a[2]是什么类型?

    如果你无法回答上面的全部问题,那么说明你还没完全理解透彻!

第一节 指针

外卖柜

想象一下,你今天中午要去拿外卖。我们如何保证你能准确的拿到自己的外卖呢?
我们需要知道:你的外卖放在哪个格子里

计算机中的外卖柜

好了,我们现在来类比一下
我们如何在计算机中描述一个数据存储的位置呢?
首先我们要知道,计算机中不同的类型占用的内存是不同的,比如,大部分情况下char占用1个字节,int4个,long long8个。很多时候,我们可能要连续使用好几个外卖柜的格子才能取到外卖

所以我们想准确描述一个变量在计算机中的存储地址,就需要知道:

  • 数据从哪里开始存放 (第一个外卖在哪)
  • 数据存了多长 (连续放了几个外卖格子)

指针到底是怎么取到外卖的

首先,我们定义一个指针

int a;
int* p = &a;
*p = 3;

先简单讲解一下基本语法。

  • 我们首先定义了一个变量a
  • 然后定义了一个整型指针变量(int*类型)p
  • 接着对a进行取址&,把a的地址给p
  • 最后我们对p进行解引用*,解引用就是找到这个地址对应的东西,*p = 3;也就是让p地址对应的东西(也就是a)的值变成3

[!CAUTION] 注意,int* p;*p = 5;,这两个*p虽然长得差不多,但是有本质不同!int* p*可以理解为是跟着前面的int的,意思是定义了一个类型是int*(整型指针类型)的变量p,而*p = 5;*是上面提到的解引用,是找到这个地址对应的东西

那么,指针变量p到底是怎么工作的?

我们把计算机内存里的一个字节看作是一个小格子

  • p里存放的值就是a在计算机内存里的第一个小格子的编号,也就是起始地址(一般来说作为int类型的a会占4个字节) -> 对应第一个外卖格子的编号
  • 那么,谁来记录数据的长度,也就是连续存了几个格子呢?其实这个长度信息存在了指针的类型里。int*类型的指针,它用它的类型int*隐含了“连续存了4个格子”的信息。
  • 同样的,long long *隐含了“连续存了8个格子”的信息、char*隐含了“只存放了1个格子”的信息

好了,至此,指针的基础部分就结束了

第二节 数组与指针

申请一块连续的空间

new与delete

  • 可以理解为new是在贷款,delete是在还款,你不能一直贷款而从不还款
  • 所以,new申请出来的内存一定在使用完后应该使用delete即使还清

基本语法:

  • 申请单个类型为T的变量的内存:new T

  • 删除指针变量p指向的单个内存:delete p

  • 申请连续n个类型为T的变量的内存:new T[n]

  • 删除指针变量p指向的一片连续内存:delete[] p

数组与指针

int a[100];

int* p;
p = new int[100];

这两种写法的区别是什么?

  • int a[100];:在内存里用一块连续的空间(100个int就是100*4),存了100个int数据,把第一个数据的地址存在了a里,a就是一个指针,a这个指针的值是不能修改的,一旦修改,数组名 a 的地址将不能代表原先的连续存储,从而导致后续元素丢失/错误访问,后面的数全都丢了
  • int* p:定义了一个整型指针变量p。这个p是你自己的,你想怎么用怎么用。p = new int[100];:在内存中申请了一块连续的空间,大小是100个int(100*4),把这个空间的第一个int的地址赋值给了p

指针的+与-

有些抽象一点是,指针可以++--+=5-=6
意思是:在当前位置 向左(-)/向右(+) 移动几个大单位
比如一个long long*类型的pp++就会往右移动一个long long占用的整个空间(也就是大多数情况下的8个字节),同理,int*pp -= 2就会往左移动两个元素(通常为 2 × 4 = 8 字节)

解密 a[i]

那么对于一个数组int a[100],a[i]是什么呢?

其实很简单,a[i]本质上就是*(a + i)
a[i]在使用的时候,会先计算a + i,给指针a(记录着整个数组的起始元素的地址)向后偏移i位,然后解引用,找出第0位的地址往后i位的地址存储的数据,就巧妙地实现了a[i]能访问数组ai项的功能

所以:

  • a[i] -> *(a + i)
  • i[a] -> *(i + a) -> *(a + i)
  • (i+1)[a-1] -> *(i + 1 + a - 1) -> *(a + i) 所以说这三个写法等价

更高维的数组

刚刚我们使用了一维的数组,现在我们来试试二维
很多同学觉得二维数组是一个“方阵”,像 Excel 表格一样。但在电脑的底层,内存是一条笔直的走廊
定义 int a[10][20],本质上是你租下了 200 个连续的房间($10 \times 20$)。

1. 为什么 a[ ][20] 行,a[10][ ] 不行?

想象你是一个导游,要带游客去 a[3][5](第 4 层,第 6 个房间)。
由于房间全是连着的,你必须通过数学计算算出这是走廊里的第几个房间:

[!NOTE] 定位公式: 跳过的层数 × 每层房间数 + 目标层内的偏移量

  • 如果你知道每层有 20 个房间:a[3][5] = 走廊第 $(3 \times 20) + 5 = 65$ 个房间。计算机可以轻松计算出正确的位置
  • 如果你不知道每层有几个(即 a[10][]):a[3][5] = 走廊第 $(3 \times \text{?}) + 5$ 个房间。
    人类很容易能看懂,但计算机没有层的概念,所以它报错:array has incomplete element type(数组含有不完整类型的元素),要你告诉它一层到底有几个房间

结论: 传参时,第二维(列数)绝对不能省。它是电脑计算地址的“步长”。至于总共有多少层(第一维),电脑并不关心,它只要知道每层多宽,就能顺着走廊摸过去。


2. a 是什么类型? a[2] 是什么类型?

我们用“身份等级”来理解这两个符号:

a 的身份:高级主管(指向“整行”的指针)

  • 含义: 它代表的是“整栋楼”的起始点。
  • 特殊能力: 如果你执行 a + 1,它会非常有排场地直接跳过 20 个房间(一整行)。
  • 专业术语: 它的类型是 int (*)[20],即数组指针。它手里拿着的是“某一层”的钥匙。

a[2] 的身份:楼层经理(指向“单个房间”的指针)

  • 含义: 当你写出 a[2] 时,说明你已经进到了第 3 层楼的走廊里。
  • 特殊能力: 如果你执行 a[2] + 1,它只会小心翼翼地挪动 1 个房间(一个 int)。
  • 专业术语: 它的类型是 int *。它手里拿着的是“具体某个房间”的钥匙。

总结

“二维数组传参时,必须交出‘每层宽度’的地图;a 看的是‘层’,a[i] 看的是‘间’。”


动态造楼:手动申请二维数组

有时候我们不确定数组到底要多大,这时候就需要用到 new 来动态申请。这就像是在内存走廊里“按需盖房”。

1. 第一步:申请“楼层索引”

我们需要先申请一个能存放 100 个指针 的数组。

int** p = new int*[100];

2. 第二步:给每一层“盖房间”

for (int i = 0; i < 100; ++i) {
    p[i] = new int[20]; // 每一层申请 20 个 int 空间
}
  • 发生了什么: 循环结束后,你拥有了 $100 \times 20$ 个房间。
  • 注意: 这里的每一层在内存中是连续的,但层与层之间在内存里可能是不连续的!这和 int a[100][20] 这种“一整块”的申请方式有本质区别。

3. 第三步:记得“拆迁”(释放内存)

动态申请的内存,编译器不会帮你自动回收。怎么盖的,就要倒着怎么拆。

// 1. 先拆每一层的房间
for (int i = 0; i < 100; ++i) {
    delete[] p[i];
}

// 2. 再拆楼层索引表
delete[] p;

[!CAUTION] 防坑指南: 如果你直接 delete[] p;,那么除第0层外,另外 99 层楼的房间就会变成“幽灵房”永远留在内存里,造成内存泄漏。 因为这种动态的申请,只有每一层里的内存是连续的,各层和各层之间是不同批次调用的new,所以内存并不一定是连续的!

深度对比:静态 VS 动态二维数组

理解了“手动盖楼”的过程后,我们来看看这两种方式在内存里的本质区别:

特性 静态数组 int a[100][20] 动态数组 int** p
分配位置 栈区 (Stack) 或全局区 堆区 (Heap)
内存连贯性 绝对连续(像一条笔直的长走廊) 行内连续,行间不一定(像散落在城里的公寓)
灵活性 大小在编译时就得定死,不能改 运行到一半想盖多少盖多少,甚至每行长度能不同
生命周期 函数结束自动回收,省心 必须手动 delete[],否则会内存泄漏
访问速度 极快(只需一次地址计算) 略慢(需要先找索引表,再跳到目标行)

为什么程序员要折腾 int

虽然静态数组 a[100][20] 用起来简单,但它有两个致命弱点:

  1. 栈溢出: 栈区的空间通常很小(通常只有 1MB~8MB),如果你开一个 a[1000][1000] 的大数组,程序会直接崩溃。
  2. 无法变通: 如果你写一个处理图片的程序,用户上传 100x100 的图和 4K 的图,静态数组就没法应付了。

掌握了 int** 的动态申请,才算真正拿到了 C++ 自由分配内存的“绿卡”。**

延伸:不要被“指针数组”给骗了!

在学习指针时,你会遇到两个长得很像的东西:

  1. 数组指针(int (*p)[20]):
    • 像“大巴车”: 只有一辆车(一个指针),但车里规定必须坐 20 个人。它指向的是“一整行”。
  2. 指针数组(int *p[20]):
    • 像“停车场”: 这里有 20 个车位(一个数组),每个车位里都停了一辆独立的小轿车(20 个独立的指针)。每个指针可以指向天南地北。

更更高维的数组 与 更更更高维的数组:

int a[10][10][10];
int a[10][10][10][10];

这些与二维数组本质没有区别,就是反复套娃就好了,目前暂时不会过多考察,限于篇幅,暂时不做展开

避免“野指针”!

定义了一个指针,不初始化就访问是绝对禁止的

int *p;   // 危险!这是野指针,它指向的地址是随机的
*p = 100; // 极大概率导致程序崩溃(Segmentation Fault)

扩展内容

1. NULL与nullptr

在老版本的 C/C++ 中,我们习惯用 NULL 来表示“空指针”。但本质上,NULL 只是一个被强转为指针的 0

  • 危险点: 因为 NULL 本质是 0,有时候编译器会把它误认为是 int 类型,导致函数重载时调错方向。
  • 新规矩(C++11): 全面使用 nullptr
    • nullptr 是专门为指针设计的“空”。
    • 它不能被当成整数 0,安全性更高。

2. const 与 * 的纠缠

看到 const int *pint * const p ,看似长得一样,实则大有不同

* 为界,看 const 在哪边:

const* 左边:锁住的是“内容”

const int *p = &a;

  • 解释: const 修饰的是 int(内容)。
  • 结果: 你不能通过 p 去改 a 的值(*p = 10 ❌),但你可以让 p 指向别的变量(p = &b ✅)。
  • 类比: 你有一把万能钥匙,但这把钥匙只能看,不能动屋里的东西。

const* 右边:锁住的是“指向”

int * const p = &a;

  • 解释: const 修饰的是 p 本身(指针变量)。
  • 结果: 你可以通过 pa 的值(*p = 10 ✅),但你这辈子只能指向 a 了(p = &b ❌)。
  • 类比: 你的钥匙被焊死在 a 家的锁孔里了,虽然能开门改装修,但拔不出来去开别家的门。

③ 两边都有:双重锁死

const int * const p = &a;

  • 结果: 内容不能改,指向也不能改。

[!NOTE] 到这里,指针和内存管理的基础就基本结束了
希望这些可以帮助大家更好的学习程序设计
会了这些,才能真正理解接下来C++中类的基础部分里为什么要写拷贝构造函数 拷贝赋值运算符 析构函数
加油!

评论 (0)