本文是 C++ 学习记录系列 的第二篇。
这个事情还得从疫情期间说起,当时我正上高二,因为在学校旁边租了房子所以不怎么去上晚自习。母上大人作为家委会成员和班主任的关系还很不错(这个家委会的职位纯粹是想和老师打好关系在学校监视我才做的)。有一天晚上班主任给我妈打电话了说我们班有一个密切和患者接触过的人,于是我妈让我从家出发去班里通知————开(放)始(假)网(回)课(家)。正如我所说的,租的房子距离学校很近,所以我五分钟就到了给他们宣布了这好消息。但是我依旧等了很长时间————当时不只我一个人走读,还有同学正在回来取书的路上。
我当时就在教室里等待着我的同桌————看过前言的朋友应该都知道这是我对我同桌是什么意思了————总而言之,等到她来了以后,我就做了青春期小男生都会做的事情,我开始尽可能找话题聊天。
走运的是我确实要到了她的联系方式,但是我真不知道人家的地址————友情提醒,在任何情况下贸然了解非熟人的地址都是一件涉及隐私的行为,慎行。但是请不要看到这就说我 Galileo 标题党,恰恰相反,虽然有了联系方式,但是我在最后还不如没有呢!想知道后面发生了什么,请你耐心地读到到后面的 C++ 系列吧。:)
不过回到 C++,情况就简单多了。
现实里不能随便问人家的地址,但程序里的变量很大方:只要你写一个 &,它就会把自己的内存地址交出来。
这篇文章要讲的指针,本质上就是在回答一个问题:如果我知道了一个变量的地址,我到底能做什么?
1. 先从变量开始
在理解指针之前,先要理解一个普通变量在程序里至少包含几层信息:
- 变量名:代码里用来访问它的名字
- 变量类型:决定这块数据应该如何解释
- 变量值:变量当前保存的数据
- 变量地址:变量在内存中的位置
比如下面这段代码:
#include <iostream>
int main() {
int a = 10;
std::cout << "a = " << a << '\n';
std::cout << "&a = " << &a << '\n';
return 0;
}
一次可能的运行结果:
a = 10
&a = 0x16b7fad4c
这里的 a 表示变量的值,也就是 10。
而 &a 表示变量 a 的地址,也就是它在内存中的位置。
需要注意的是,地址每次运行都可能不一样。我们不需要记住某个具体地址,只需要理解:变量除了有值之外,也有自己的内存地址。
如果继续用前面的故事类比,变量名有点像“我在代码里怎么称呼它”,变量值是它当前保存的内容,而变量地址才是它在内存里的具体位置。
联系方式和地址不是一回事,变量名和变量地址也不是一回事。
2. 指针到底是什么
指针本质上也是一个变量。
只不过普通变量保存的是普通数据,而指针变量保存的是地址。
也就是说,指针不是变量本身,而是一个“记着变量地址的小纸条”。
(啊这让我想到了我的好homie,当时他也在追一个姑娘,可以说死缠烂打,终于又一次在晚自习时写在纸条上的表白被答应了,但是刚答应,纸条就被班主任收走看见了,从严格意义上来讲,班主任是第一个知道女生小心思的人,纸条返回的地址输出给班主任了,他没看到)
纸条上写着地址,你顺着地址才能找到真正的数据。
#include <iostream>
int main() {
int a = 10;
int* p = &a;
std::cout << "a = " << a << '\n';
std::cout << "&a = " << &a << '\n';
std::cout << "p = " << p << '\n';
std::cout << "*p = " << *p << '\n';
return 0;
}
一次可能的运行结果:
a = 10
&a = 0x16d7dad4c
p = 0x16d7dad4c
*p = 10
这段代码里有三个符号特别关键:
&a:取出变量a的地址p:保存变量a的地址*p:根据p里保存的地址,找到对应变量并访问它的值
所以这行代码:
int* p = &a;
可以理解为:
创建一个 int* 类型的指针变量 p,让它保存 a 的地址。
这里的 int* 表示:p 是一个指针,它指向的对象类型是 int。
所以标题里说“变量给了我地址”,放到代码里就是这句:
int* p = &a;
a 把地址交出来,p 负责把这个地址保存好。
3. 通过指针修改变量
因为指针保存的是变量地址,所以我们可以通过指针找到原来的变量,并修改它。
#include <iostream>
int main() {
int a = 10;
int* p = &a;
*p = 20;
std::cout << "a = " << a << '\n';
std::cout << "*p = " << *p << '\n';
return 0;
}
运行结果:
a = 20
*p = 20
关键点在这一句:
*p = 20;
它不是在修改指针变量 p 本身,而是在修改 p 指向的那块内存。
也就是说:
p = &a;
表示让 p 保存 a 的地址。
而:
*p = 20;
表示通过 p 找到 a,然后把 a 改成 20。
这里最容易误会的是:p 只是保存地址的变量,真正被改的是地址指向的对象。
打个不太浪漫但是很准确的比方:你改的不是纸条上的地址,而是按地址找到房间以后,改了房间里的东西。
4. 指针自己也有地址
指针是变量,所以指针自己也会占内存,也有自己的地址。
#include <iostream>
int main() {
int a = 10;
int* p = &a;
std::cout << "&a = " << &a << '\n';
std::cout << "p = " << p << '\n';
std::cout << "&p = " << &p << '\n';
return 0;
}
一次可能的运行结果:
&a = 0x16b5dad4c
p = 0x16b5dad4c
&p = 0x16b5dad40
这里很容易混:
p是指针变量的值,它保存的是a的地址&p是指针变量p自己的地址
所以可以把它拆成两层看:
p 这个变量自己住在一个地址里,而它里面保存的值又是另一个变量的地址。
换成刚才的小纸条类比就是:纸条上写着 a 的地址,但纸条本身也得放在某个地方。
p 是纸条上写的内容,&p 是这张纸条自己的位置。
这可不要弄混,不然就会像我的学长一样,本来是想加p学姐和人家认识认识聊聊人生,但是加成了&p学姐,这下好了,他用了一个月发现了error,但是你的报错可是会瞬间给你一巴掌。
5. 指针类型为什么重要
指针保存的是地址,但 C++ 里的指针不只是一个地址。
它还带着类型信息。
比如 int* 表示这个指针指向的是 int,double* 表示这个指针指向的是 double。
类型至少影响两件事:
- 解引用时,编译器应该按什么类型读取数据
- 指针做加法时,地址应该移动多少字节
看下面这个例子:
#include <iostream>
int main() {
int arr[3] = {10, 20, 30};
int* p = arr;
std::cout << "p = " << p << '\n';
std::cout << "p + 1 = " << p + 1 << '\n';
std::cout << "*p = " << *p << '\n';
std::cout << "*(p+1) = " << *(p + 1) << '\n';
std::cout << "sizeof(int) = " << sizeof(int) << '\n';
return 0;
}
一次可能的运行结果:
p = 0x16f9dad40
p + 1 = 0x16f9dad44
*p = 10
*(p+1) = 20
sizeof(int) = 4
如果 int 占 4 个字节,那么 p + 1 不是让地址加 1,而是让地址向后移动 1 个 int 的大小,也就是 4 个字节。
所以指针加法不是简单的数字加法,而是和指针指向的类型有关。
这就像只知道“地址”还不够,你还得知道那里住的到底是什么类型的对象。
如果 p 指向的是 int,那往后走一步就是下一个 int;如果指向的是 double,往后走一步就要按 double 的大小来走。
C++ 不会只问“你要去哪”,它还会问“你要按什么规格走”。
6. 空指针 nullptr
如果一个指针暂时不指向任何有效对象,应该让它等于 nullptr。
如果暂时没有地址,就老老实实说没有地址。
最怕的是明明不知道指向哪里,还装作自己知道。
#include <iostream>
int main() {
int* p = nullptr;
if (p == nullptr) {
std::cout << "p does not point to anything now\n";
}
return 0;
}
nullptr表示空指针。
它不是一个有效对象的地址,所以不能对空指针解引用:
int* p = nullptr;
// 错误示范:不要这样写
std::cout << *p << '\n';
对空指针解引用会导致未定义行为,程序可能直接崩溃。
这件事也很好理解:你手里没有地址,却非要按地址去找东西,那程序只能当场迷路。
nullptr 的意义不是让你去访问它,而是提醒你:这个指针现在还没有目标。
7. 指针作为函数参数
指针常见的用途之一,是把某个变量的地址传给函数,让函数可以修改外面的变量。
这就开始有点像“把地址告诉别人,让别人能找到原来的东西”了。
普通传值只是给别人一份复印件;传指针则是把原对象的位置告诉别人。
先看普通传值:
#include <iostream>
void changeByValue(int x) {
x = 100;
}
int main() {
int a = 10;
changeByValue(a);
std::cout << "a = " << a << '\n';
return 0;
}
运行结果:
a = 10
这里 changeByValue 拿到的是 a 的一份拷贝,函数内部修改的是拷贝,不会影响外面的 a。
再看传指针:
#include <iostream>
void changeByPointer(int* p) {
if (p == nullptr) {
return;
}
*p = 100;
}
int main() {
int a = 10;
changeByPointer(&a);
std::cout << "a = " << a << '\n';
return 0;
}
运行结果:
a = 100
这里传进去的是 a 的地址。
函数内部通过 *p = 100; 找到外面的 a,并修改它。
这也是很多 C 风格接口喜欢传指针的原因:函数可以通过地址访问调用者传进来的对象。
所以传指针的威力更大,责任也更大。
你把地址交出去了,函数就不只是看看,它是真的可能改到原来的对象。
8. 几个我踩过的坑
8.1 未初始化指针
int* p;
// 错误示范:p 没有被初始化,不知道指向哪里
*p = 10;
未初始化的指针里可能是任意地址。
直接解引用它非常危险。
这就像手里拿了一张没写清楚的纸条,却硬说它是情书,上面记了人家要和你表白的地址。
你顺着它走,走到哪里完全看运气,而写程序最不应该靠运气,感情也是,万一那是年级主任钓鱼用的呢。
如果暂时不知道指向谁,先写成:
int* p = nullptr;
你要是写情书的话另说,这东西不兴有模版或者定义之类的。
8.2 空指针解引用
int* p = nullptr;
// 错误示范
std::cout << *p << '\n';
nullptr 不指向任何有效对象,不能通过 *p 访问。
它至少比未初始化指针诚实:未初始化指针是不知道自己去哪还乱跑,nullptr 是明确告诉你“我现在哪也不去”。
8.3 悬空指针
int* p = nullptr;
{
int a = 10;
p = &a;
}
// 错误示范:a 的生命周期已经结束
std::cout << *p << '\n';
a是代码块里的局部变量。
当代码块结束后,a 的生命周期结束,p 还保存着原来的地址,但那个地址对应的对象已经不存在了。
这种指针叫悬空指针。
这个坑很像你保存了一个旧地址,但那个人早就搬走了。
地址看起来还像个地址,但你再按它去找,找到的已经不是原来的对象。
9. 总结
这一篇先抓住几句话就够了:
- 变量有值,也有地址
&a表示取出变量a的地址- 指针变量保存的是地址
*p表示根据指针保存的地址访问对象- 指针类型会影响解引用和指针运算
- 不确定指向谁的指针,先初始化为
nullptr
类比一下就是:
变量名像称呼,变量值像当前内容,变量地址像它在内存里的位置,指针像一张专门保存地址的纸条。
但 C++ 比现实世界更严格:你可以拿地址,也可以顺着地址改东西,可一旦地址是假的、空的、过期的,程序就会用崩溃或者未定义行为提醒你,这事不能乱来。尤其是当女生给你了假的联系方式的时候(当然人家肯定没给我假的,不然也没后面的事了)
如果把这几个点想清楚,后面再看引用、数组、动态内存、对象生命周期,都会顺很多。