先从一个不少人都亲身经历过的修罗场说起。
你写完人生第一段 C++ 代码。
心情还挺美:「不就几行嘛,能出啥事?」
你点下运行。
屏幕一黑,程序当场去世,临走前留下一行冷冰冰的字:
Segmentation fault (core dumped)
你愣住。
IDE 也没报红。
就是多了几个 * 和 &,结果一跑就爆炸?
你开始加 log、单步调试、疯狂搜答案。
一顿操作之后,终于把凶手锁定在这两行小短句上:
int* p;
*p = 10;
看着一点杀气都没有。
结果能直接把整个程序送走。
很多人到了这一步,心里都会蹦出一句:
“指针这玩意,真的是新手劝退神器。”
而且说实话,不止新手。
老程序员也一样翻过车。
嘴上天天骂,真要写底层、抠性能、搞系统,大家还是老老实实用指针。
因为有个事实躲不过:
指针不是魔法。
它就是在说:把『内存地址』当成普通数据来玩。
听上去不吓人,对吧。
可只要你真的能随便玩地址,故事一下就刺激起来了:
- 你可以在函数之间传来传去,不用每次拷一大坨对象。
- 你可以在堆上
new东西,拼链表、树、图这些灵活动态结构。 - 你也可以,一不小心,指到一块根本不属于你的内存,让程序原地暴毙,还巨难排查。
指针一出生,就是为了能直接摸到内存。
顺带把风险值也直接拉满。
指针是怎么混进语言世界的?
指针不是 C++ 发明的。
这货其实是老前辈了。
回到大家还在写汇编那会儿。
那时候,“变量”这个词都还没出现,只有一个个地址:
; 伪代码:从地址 0x1000 读一个数
MOV R1, [0x1000]
你要操作什么数据,就得自己报门牌号。
久而久之,工程师都明白了:地址这东西虽然原始,
但它才是世界的底层长相。
后来 C 语言登场。
它干了一件当时挺硬核的事:
“我不把地址藏起来,我就承认:地址是世界的一等公民。”
于是你看到了这样的写法:
int age = 18;
int* p = &age; // p 里放的就是 age 的地址
C 在说的其实是两件事:
- 一方面,我给你高级语法,写起来总比汇编舒服多了。
- 另一方面,我保留“直接玩地址”的权限,你要贴着硬件跑也行。
后面 C++ 把这面大旗接过去。
它没砍掉指针,反而又往上叠了一层壳:
- 想要安全点?用引用、用 RAII。
- 想要少点手动释放的心累?上
std::unique_ptr、std::shared_ptr这类智能指针。
但不管外面套了多少层,最底下那层还是老熟人:
原始指针 + 内存地址。
1. 内存与地址:一切的起点
说了这么多故事,还是得回到那条「内存大街」。
想象一下,你面前有一张俯视图:
- 一条很长很长的街道。
- 街道两边排满了小房子,每一间房子大小一样。
这张图,就是你电脑里的内存。
每一间小房子 = 一个字节(1 Byte)。
门口还写着门牌号:
- 这些门牌号就是内存地址。
- 某几间连续的房子,被你用来存一个变量的值。
现在你写下这行代码:
int age = 18;
编译器会跟操作系统说一句:
“我这儿需要一块能装下一个
int的地方。”
于是系统会在那条「内存大街」上,找四间挨在一起的小房子(假设 int 是 4 字节),
把 18 这个数字拆成二进制丢进去。
顺带在小本本上记一笔:
age这个变量 → 从 0x1000 开始往后数 4 间房子。
这一串 0x1000 之类的东西,就是我们后面会反复提的:地址。
所以可以先记一个简单的对照表:
- 变量的「名字」:
age,是你在代码里叫它的那个名字。 - 变量的「住址」:某个十六进制数,比如
0x7ffee1xxxx。 - 变量的「内容」:真正存的值,比如
18。
理解了这一点,指针这玩意就已经被你拆开三分之一了。
2. 什么是指针?
现在有了「住址」这个概念,我们终于可以把指针拉出来亮相了。
一句话版定义:
指针也是一个变量,只不过它里面放的不是普通数字,而是——另一个变量的地址。
对比一下就很清楚:
- 普通变量:里面放的是值,比如
18、3.14、'A'。 - 指针变量:里面放的是地址,比如
0x7ffee1xxxx。
你可以把指针想象成一张小纸条:
- 纸条本身也要找个地方放,所以它本身也占内存。
- 纸条上写的,是「某个房子的门牌号」。
所以,当你看到 int* p; 的时候,可以在脑子里自动翻译成:
“给我一张纸条,这张纸条上,将来会写着某个
int变量的住址。”
后面我们要做的事,其实就两件:
- 把「纸条」和「住址」绑到一起。
- 顺着纸条上的地址,去敲门、取值、改值。
3. 声明与初始化:给纸条贴上类型标签
声明指针
指针也分种类,它只认自己那一挂的房子。
我们用 * 来声明一根指针:
int* p1; // 指向 int 的指针
double* p2; // 指向 double 的指针
char* p3; // 指向 char 的指针
可以简单粗暴地理解成:
int*:这根指针未来只会指向装int的房子。double*:这根指针未来只会指向装double的房子。
如果你拿一根 int* 去当成 double* 用,就等于:
「明明这间房子里按 4 字节的格式放东西,你偏要按 8 字节的方式去读」,
读出来的基本就是一坨随机垃圾。
获取地址(&):把住址写到纸条上
那纸条上的门牌号从哪来?
这时候就轮到取地址运算符 & 出场了:
int age = 18;
int* ptr = &age; // ptr 现在存放了 age 的地址
这行话的潜台词是:
“把
age那几间房子的门牌号写到纸条ptr上。”
从此以后,ptr 这张纸条就和 age 这块内存绑定在了一起。
打印地址:看一眼「黑魔法符号」
我们可以直接把地址打印出来看看:
#include <iostream>
int main() {
int age = 18;
int* ptr = &age;
std::cout << "age 的值: " << age << std::endl;
std::cout << "age 的地址: " << &age << std::endl;
std::cout << "ptr 的值: " << ptr << std::endl; // ptr 的值就是 age 的地址
return 0;
}
你会看到一串看起来很神秘的十六进制数字,那就是门牌号本人。
此刻可以先不用死记,只要脑子里有个映射:这是地址,就够了。
4. 解引用(*):顺着纸条去敲门
纸条上已经写好了门牌号,那接下来最自然的问题就是:
“我能不能顺着纸条,找到那间房子,把里面的值拿出来,甚至改一改?”
这个动作就叫:解引用(Dereference)。
还是看最经典的那段小代码:
int main() {
int age = 18;
int* ptr = &age;
// 读取数据
std::cout << "通过指针访问 age: " << *ptr << std::endl; // 输出 18
// 修改数据
*ptr = 20; // 相当于 age = 20
std::cout << "修改后的 age: " << age << std::endl; // 输出 20
return 0;
}
这里发生了两件事:
*ptr:顺着纸条上的门牌号,找到age的那几间房子,把里面的值读出来。*ptr = 20;:同样顺着地址过去,把房子里的值改成20,等价于age = 20。
这就是解引用的全部含义:沿着指针指向的地址,去操作那块内存里的内容。
很多人一开始会被 * 搞晕,其实它有两种完全不同的语境:
一定要区分
*的两种用法:
int* ptr;—— 在「声明」的时候,*表示「这是一个指针类型」。*ptr = 10;—— 在「使用」的时候,*表示「顺着纸条去敲门,操作那块内存」。
多写几次,你就会下意识把这两种场景分开了。
5. 空指针:nullptr vs NULL
前面一直在说「写着门牌号的小纸条」。
正常情况下,纸条上总会写点啥。
地址清清楚楚,你顺着走过去,就能敲到那扇门。
有时候剧情就变味了。
想象一个很常见的场景:
深夜赶项目,线上突然炸了,日志里写着一句:
XXX::DoSomething 中发生空指针访问
你一边灌咖啡,一边加 log、单步调试,
一路追踪到这样一行代码:
std::cout << user->name << std::endl;
表面上看没毛病,平平无奇一行输出。
结果真相是——user 根本没指向任何东西。
纸条还在你手里。
纸条上的内容,却是个「空气」。
这就是空指针:
样子看起来很正常,
实则谁也不认,谁也不管。
我们把这个概念钉死一下:
空指针 = 这个指针现在没有指向任何有效内存。
它活着。
但它背后没有一块「你可以放心乱摸」的内存。
你不能对它解引用,
也不能把它当成「已经 new 好、能随便用」的对象。
在 C/C++ 的世界里,「啥也没有」这件事,写法还挺多:
NULL:老前辈,底子通常就是整数0。nullptr:C++11 之后登场的新同学,专职干「空指针」这份活。- 直接写
0:史前写法,现在主要出没在老项目考古现场。
写成代码,大概是这样:
int* p1 = nullptr; // 现代 C++ 推荐写法
int* p2 = NULL; // 老代码常客,新项目不建议继续用
int* p3 = 0; // 还能用,但已经是考古级别了
那为什么大家都在疯狂安利 nullptr 呢?
- 它有自己独立的类型,编译器一眼就知道「这是空指针」。
- 不会和
int这些普通整数搅在一起,重载、比较都更清爽。 - 别人扫一眼代码,就懂你在说「这里没有对象」,而不是「这里真存了个 0」。
你可以直接把它当成一条潜规则记住就行:
想在 C++ 里表示「这个指针现在啥也没指向」,就用
nullptr,别纠结。
还有一个非常非常实用的小习惯,
能帮你少熬很多没有意义的夜:
在打算解引用一个指针之前,
先礼貌地问一句:你现在是不是空的?
if (ptr != nullptr) {
std::cout << *ptr << std::endl;
}
这两行保护代码,看起来没什么存在感。
真到线上翻车的时候,它就是你和崩溃之间的最后一条防线。
很多人都会说:
「这里我确定不是空指针的,放心用!」
结果现实是:
某个流程里被顺手设成了空,
或者某个失败分支里没初始化,
指针安安静静躺在那里,
等你来一句 *ptr,
程序当场原地升天,
只留下你抱着日志自闭。
所以,记住这两件事就够了:
- 想表达「这里啥也没有」,统一用
nullptr。 - 想对指针做
*这种操作,先看它是不是空。
后面你再看到「空指针访问」「nullptr」这几个词,
脑子里就不会只剩下问号了,
而是会自动浮现出那张「什么都没写的纸条」,
还有半夜抱着日志调 bug 的你自己。😄
6. 指针与数组:剪不断,理还乱的老搭档
说到指针,数组一定要出来串个场。
在 C++ 里,数组名在很多场景下会自动变成一个指向首元素的指针,这件事有个术语叫「退化(decay)」。
int arr[] = {10, 20, 30};
int* p = arr; // 等价于 int* p = &arr[0];
std::cout << *p << std::endl; // 输出 10
std::cout << *(p + 1) << std::endl; // 输出 20(指针运算)
可以这么理解:
arr:在很多地方就等价于「指向第一个元素的指针」。p:现在拿到了arr[0]的地址。
指针运算:不是加一格,而是跳一个元素
这里有个特别容易想错的点:
p + 1:不是把地址当成一个整数简单加 1。
对int*来说,它的含义是「向后移动一个int的距离」,一般是 +4 字节。p[i]:其实就是语法糖,相当于*(p + i)。
所以你可以一边走一边访问数组元素,比如:
*(p + 2) // 相当于 arr[2]
很多 C 风格的老代码里,到处都是 *(p + i) 这种写法。
一旦你把「每加 1 是跳一个元素」这个概念吃透,就不会再被这种写法吓到。
7. 常见陷阱:悬空指针与野指针
前面都在教你怎么「姿势正确」地用指针。
接下来上强度,看看它最爱下手的两个坑。
野指针(Wild Pointer):一出生就跑偏
可以先记一句话:
野指针 = 从来没被好好初始化的指针。
它里面装的到底是什么?没人知道。
大概率是内存里遗留下来的垃圾值,
指到一块你完全没资格碰的地方。
int* p; // 野指针!此时里面是随机地址
// *p = 10; // 这么写,很可能直接把程序干崩
问题不在这一行看起来多复杂,
而是在你以为「p 至少指着点啥」。
但事实是:它根本没家。
比较靠谱的做法只有两个选项:
- 声明的时候,就让它指向一块确定的内存;
- 暂时没家,就老老实实设成
nullptr,标个「现在别用我」。
int value = 0;
int* p1 = &value; // 有家
int* p2 = nullptr; // 明确标记:现在还不能用
很多诡异崩溃,最后追根溯源,
就是某个被你忘记初始化的指针,在后台瞎跑。
悬空指针(Dangling Pointer):房子没了,纸条还在
再看另一个高发选手:
悬空指针 = 指向的那块内存已经失效了,
但指针自己还蒙在鼓里。
最常见的造法,就是把局部变量的地址带出函数:
int* func() {
int a = 10;
return &a; // 危险!a 是局部变量
} // 函数结束,a 对应的内存就被回收了
函数一 return,a 的那几间房子就被系统收回。
但你手里还攥着那张写着门牌号的小纸条。
接下来会发生什么?
- 这块内存可能立刻被别的变量占用;
- 也可能被系统挪去干别的事。
你再顺着这张旧纸条去敲门,
行为完全不可预测,
bug 复现难度瞬间拉满。
应对思路可以简单粗暴记成两条:
- 不要返回局部变量的地址;
- 一旦对象生命周期结束,相关指针立刻设成
nullptr,别留「旧纸条」。
很多那种「查了一整天,结果是一行小问题」的事故,
背后十有八九,都站着一个悬空指针。
8. 总结:和指针和解
我们用一口气的时间,把今天讲的东西捋一遍:
- 电脑内存可以想象成一条街,变量住在某几间连续的房子里,门牌号就是地址。
- 指针本质上就是一张写着「门牌号」的小纸条,是一个存储内存地址的变量。
- 用
&可以拿到变量的地址,用*可以顺着纸条去敲门,读写那块内存里的值。 - 指针一定要有「归宿」:要么指向一块确定的内存,要么老老实实是
nullptr。 - 数组名在很多时候会自动变成「指向首元素的指针」,
p[i]其实就是*(p + i)。 - 野指针和悬空指针,是两类非常典型也非常阴险的坑,搞不好就让程序直接翻车。
如果你现在再回头看那行:
int* p;
*p = 10;
大概就不会只是感到「神秘」了,而是会在心里吐槽一句:
“这不就是一根没初始化的野指针在作死嘛。”
当你开始用这样的眼光看待指针,
- 它不再是一个抽象的「黑魔法关键字」,
- 而是一个很具体、很直观的工具:一张写着门牌号的小纸条。
等到后面你接触 new / delete、智能指针、对象生命周期这些话题,
再回到这篇文章,很多之前觉得「好晕」的地方,都会自然地拼到一起。