C/C++ 之 指针与内存管理
写在前面
本文旨在帮助大一上学期 “程序设计(1)” 中C语言指针与内存管理没完全理解的同学,在大一下学期的C++里学会指针与内存管理
受限于篇幅限制,本文某些时候可能会直接使用C++里的一些语法,而不会使用C语言的语法,比如可能会使用new、delete而不是malloc、free
如有任何问题或错误,欢迎使用个人主页的联系方式联系我,也可以评论留言(需登录GitHub)
[!CAUTION] 如果大家感觉一看到这部分就害怕、头疼,没关系,作者向你承诺这篇文章绝对非常生动形象,你一定可以很轻松的看懂!
[!NOTE] 本文为了方便大家理解,某些表述方法或比喻可能不够精确,如有建议,欢迎与我联系、讨论!
[!TIP] 如何判断自己是否真正掌握了C语言的指针?
看看自己能不能回答以下问题:
- 定义
int a[i],a[i]与i[a]、(i+1)[a-1]为什么是等价的?- 定义
int a[10][20],传入这个二维数组的时候,为什么传入int a[][20]可以,而传入int a[10][]就不可以?(需要知道具体为什么不可以)- 定义
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*类型的p,p++就会往右移动一个long long占用的整个空间(也就是大多数情况下的8个字节),同理,int*的p,p -= 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]能访问数组a第i项的功能
所以:
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] 用起来简单,但它有两个致命弱点:
- 栈溢出: 栈区的空间通常很小(通常只有 1MB~8MB),如果你开一个
a[1000][1000]的大数组,程序会直接崩溃。 - 无法变通: 如果你写一个处理图片的程序,用户上传 100x100 的图和 4K 的图,静态数组就没法应付了。
掌握了 int** 的动态申请,才算真正拿到了 C++ 自由分配内存的“绿卡”。**
延伸:不要被“指针数组”给骗了!
在学习指针时,你会遇到两个长得很像的东西:
- 数组指针(
int (*p)[20]):- 像“大巴车”: 只有一辆车(一个指针),但车里规定必须坐 20 个人。它指向的是“一整行”。
- 指针数组(
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 *p 和 int * const p ,看似长得一样,实则大有不同
以 * 为界,看 const 在哪边:
① const 在 * 左边:锁住的是“内容”
const int *p = &a;
- 解释:
const修饰的是int(内容)。 - 结果: 你不能通过
p去改a的值(*p = 10❌),但你可以让p指向别的变量(p = &b✅)。 - 类比: 你有一把万能钥匙,但这把钥匙只能看,不能动屋里的东西。
② const 在 * 右边:锁住的是“指向”
int * const p = &a;
- 解释:
const修饰的是p本身(指针变量)。 - 结果: 你可以通过
p改a的值(*p = 10✅),但你这辈子只能指向a了(p = &b❌)。 - 类比: 你的钥匙被焊死在
a家的锁孔里了,虽然能开门改装修,但拔不出来去开别家的门。
③ 两边都有:双重锁死
const int * const p = &a;
- 结果: 内容不能改,指向也不能改。
[!NOTE] 到这里,指针和内存管理的基础就基本结束了
希望这些可以帮助大家更好的学习程序设计
会了这些,才能真正理解接下来C++中类的基础部分里为什么要写拷贝构造函数拷贝赋值运算符析构函数
加油!
评论 (0)
登录 后即可评论