4. 指针与引用:指针的算术运算、野指针、悬空指针、智能指针初探
指针这东西,C/C++ 程序员绕不开的坎儿。我入行头三年,至少有一半的线上崩溃跟指针有关。说实话,指针用好了是利器,用砸了就是自残。今天咱们就把指针的算术运算、野指针、悬空指针这些老生常谈但又容易翻车的地方捋一遍,最后再聊聊智能指针——这玩意儿能救你命。
4.1 指针的算术运算:别把它当普通整数
很多新手以为指针就是个地址,加减就是地址加减。其实不然。指针的加减运算,单位是“指向类型的大小”。
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr; // 指向 arr[0]
p++; // 实际地址增加 sizeof(int) = 4 字节
printf("%d\n", *p); // 输出 20
你看,p++ 不是地址加1,而是加了一个 int 的尺寸。我当年刚学的时候,在嵌入式平台上踩过这个坑——以为指针加1就是下一个字节,结果读出来的数据全乱了。
核心规则:指针 + n 等价于地址增加 n * sizeof(指向类型)。
指针减法同理。两个同类型指针相减,得到的是它们之间相差的元素个数,不是字节数。
int* p1 = &arr[0];
int* p2 = &arr[3];
ptrdiff_t diff = p2 - p1; // 结果是 3,不是 12
嗯,这里要注意:ptrdiff_t 是有符号的,别拿 size_t 去接,否则负数会变成超大正数,逻辑判断直接崩。
4.2 野指针:没初始化就敢用?
野指针就是声明了但没赋值的指针。它指向哪里?鬼知道。可能是任意内存地址。
int* p; // 野指针诞生了
*p = 42; // 写入未知地址,大概率段错误
我见过最离谱的一次:同事在栈上声明了一个指针没初始化,然后条件分支里只在一个路径下赋值,另一个路径直接用了。线上跑了三个月才复现,查得我头发都白了。
避坑指南:声明指针时立即初始化。不知道指向哪里?那就 int* p = nullptr;。至少解引用时会立刻崩溃,而不是随机崩溃。
4.3 悬空指针:指向已释放的内存
悬空指针比野指针更阴险。它曾经是合法的,但指向的内存被释放了。你拿着它继续用,就是典型的 use-after-free。
int* p = new int(42);
delete p;
*p = 100; // 悬空指针!写入已释放的内存
为什么会这样?因为 delete 只是把内存标记为“可用”,并不会把指针置空。p 里存的地址还在,但那块内存可能已经被别人用了。你写进去,别人的数据就坏了;别人写进去,你读出来就是脏数据。
我曾经维护过一个老项目,里面有个全局指针,在某个模块里被 delete 了,另一个模块毫不知情还在用。崩溃日志千奇百怪,有时候是乱码,有时候是死循环,有时候直接 segfault。最后靠 AddressSanitizer 才揪出来。
我的习惯:每次 delete 之后立刻把指针置为 nullptr。虽然不能解决所有问题(比如还有别的指针指向同一块内存),但至少能避免当前指针被误用。
4.4 智能指针初探:让 RAII 替你擦屁股
手动管理内存,说白了就是跟自己的记忆力较劲。你记得 new 就得记得 delete,记得 malloc 就得记得 free。人不是机器,总有忘的时候。智能指针就是来干这个的。
智能指针的核心思想是 RAII(资源获取即初始化)。说白了,把资源的生命周期绑定到对象的生命周期上。对象构造时获取资源,对象析构时释放资源。你只管用,不用管释放。
| 智能指针类型 | 所有权模型 | 适用场景 |
|---|---|---|
std::unique_ptr |
独占所有权 | 一个对象只有一个所有者 |
std::shared_ptr |
共享所有权(引用计数) | 多个对象需要共享同一资源 |
std::weak_ptr |
弱引用,不增加引用计数 | 打破 shared_ptr 循环引用 |
先看 unique_ptr。它独占资源,不能拷贝,只能移动。你想想看,这就像一把钥匙只能给一个人,别人拿不走。
#include <memory>
std::unique_ptr<int> p1 = std::make_unique<int>(42);
// std::unique_ptr<int> p2 = p1; // 编译错误!不能拷贝
std::unique_ptr<int> p2 = std::move(p1); // 可以移动,p1 变成空
再看 shared_ptr。它用引用计数跟踪有多少个指针指向同一块内存。计数归零时自动释放。
std::shared_ptr<int> sp1 = std::make_shared<int>(100);
std::shared_ptr<int> sp2 = sp1; // 引用计数变为 2
// 当 sp1 和 sp2 都离开作用域时,内存自动释放
但 shared_ptr 有个坑:循环引用。A 指向 B,B 又指向 A,引用计数永远不为零,内存泄漏了。
struct Node {
std::shared_ptr<Node> next;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 循环引用!两个对象都释放不了
这时候 weak_ptr 就派上用场了。它像个观察者,不参与引用计数。需要访问资源时,先通过 lock() 获取一个临时的 shared_ptr,如果资源还在就用,不在就返回空。
经验之谈:能用 unique_ptr 就别用 shared_ptr。引用计数有开销(原子操作),而且容易搞出循环引用。我一般默认用 unique_ptr,真需要共享了再换成 shared_ptr + weak_ptr 组合。
4.5 知识体系一览
下面这张图把本章的核心逻辑串起来了。指针的算术运算、野指针、悬空指针是“手动挡”时代的三大痛点,智能指针则是“自动挡”时代的解决方案。
说白了,指针的算术运算、野指针、悬空指针,这些都是 C 时代留下的老问题。C++ 的智能指针不是银弹,但确实能帮你挡掉 80% 的内存错误。剩下的 20%,靠的是编码习惯和工具链(比如 AddressSanitizer、Valgrind)。
我个人习惯是:新代码一律用智能指针,老代码逐步迁移。遇到性能敏感的场景,用 unique_ptr 零开销抽象;遇到复杂的所有权关系,用 shared_ptr 加 weak_ptr 拆循环。别跟自己过不去,手动 new/delete 的时代该翻篇了。
可可精品资料库