指针与引用
这一课你将能做到
- 用
&取地址、用*解引用,说出指针变量本身存的是什么 - 写出指针传参与引用传参两种「被调函数改实参」的写法,并知道何时选哪种
- 用
nullptr判空防段错误,解释 C++ 引用与 Java 引用的关键区别
为什么要学
Java 里你天天写 User u = new User(),从来不想 u 底下是什么——JVM 替你管了。C++ 没有这层遮罩:指针就是「内存地址」这个原始概念本身,你既可以拿它做高效的事(链表、零拷贝传参、和判题沙箱里的系统调用交互),也可能用它造出段错误。理解指针引用不是怀旧,是看懂一切 C++ 代码(包括 STL 内部实现)的前提。
对 Java 背景的你最反直觉的一点:Java 的「引用」更接近 C++ 的指针(可重指向、可为 null),而 C++ 的引用更像「对象的别名」,一旦绑定不可更改、永不独立存在。把这两个词的语义对齐,后面所有课程都顺。
先建立图像
内存就像一排带门牌号的储物柜:
地址 内容
0x1000 int score = 87 ← 变量 score 住在 0x1000
0x2000 0x1000 ← 指针 p 住在 0x2000,里面存的是 0x1000
int* p = &score;
↑ ↑
类型 取地址运算符:拿到 score 的门牌号
"指向 int 的指针"
*p ← 解引用:顺着门牌号找到柜子里的东西(87)
引用则是另一回事:
int& r = score; // r 不是新柜子,只是 score 的第二个名字
跟着做
步骤 1:取地址与解引用
新建 pointers.cpp:
#include <iostream>
int main() {
int score = 87;
int* p = &score; // p 存的是 score 的地址
std::cout << "score 的值: " << score << "\n";
std::cout << "score 的地址: " << &score << "\n";
std::cout << "p 存的地址: " << p << "\n";
std::cout << "p 指向的值: " << *p << "\n";
*p = 95; // 通过指针修改 score
std::cout << "改后 score: " << score << "\n"; // 95
return 0;
}
g++ -std=c++17 pointers.cpp -o pointers && ./pointers
输出(地址每次运行可能不同):
score 的值: 87
score 的地址: 0x7ffd8c2a
p 存的地址: 0x7ffd8c2a
p 指向的值: 87
改后 score: 95
步骤 2:空指针与安全检查
指针可以「不指向任何东西」:
#include <iostream>
int* findScore(int* scores, int n, int target) {
for (int i = 0; i < n; ++i) {
if (scores[i] == target) return &scores[i];
}
return nullptr; // 没找到,返回空指针。C++11 前写 NULL 或 0
}
int main() {
int scores[] = {60, 87, 95};
int* hit = findScore(scores, 3, 100);
if (hit != nullptr) { // 也可以写 if (hit)
std::cout << "找到,值为 " << *hit << "\n";
} else {
std::cout << "未找到\n";
}
// 若不判空直接 *hit,就是对空地址解引用 → 段错误(Segmentation Fault)
// 判题沙箱里用户程序崩溃,最常见的死法就是解引用空指针/野指针
return 0;
}
步骤 3:引用——对象的别名
#include <iostream>
int main() {
int score = 87;
int& r = score; // r 与 score 是同一块内存的两个名字
r = 100; // 改 r 就是改 score
std::cout << score << "\n"; // 100
std::cout << &r << " == " << &score << "\n"; // 地址相同!
// 引用三条铁律:
// 1. 声明时必须初始化(没有「空引用」)
// 2. 绑定后不能改指别的对象(r = x 是赋值给 score,不是重新绑定)
// 3. 永远不会为 null
return 0;
}
步骤 4:传参实战——swap 的三种写法
#include <iostream>
void swapBroken(int a, int b) { // 值传递:复制副本,改不动外面
int t = a; a = b; b = t;
}
void swapByPointer(int* a, int* b) { // 指针传递:传地址,C 风格
int t = *a; *a = *b; *b = t;
}
void swapByRef(int& a, int& b) { // 引用传递:C++ 首选
int t = a; a = b; b = t;
}
int main() {
int x = 1, y = 2;
swapBroken(x, y);
std::cout << x << " " << y << "\n"; // 还是 1 2
swapByRef(x, y);
std::cout << x << " " << y << "\n"; // 2 1
swapByPointer(&x, &y);
std::cout << x << " " << y << "\n"; // 1 2 → 又换回来了
return 0;
}
调用体验上引用完胜:swapByRef(x, y) 不用取地址,语法干净;但指针能表达「可能没有对象」(nullptr),引用不能。参数一定有对象 → 引用;可能缺席 → 指针(现代 C++ 更多用 std::optional,见现代特性课)。
步骤 5:指针的经典战场——链表节点
判题沙箱维护「等待评测的提交队列」这类结构时,链表思想处处可见:
#include <iostream>
struct Submission { // struct:默认 public 的类,先当「数据包」用
int id;
Submission* next; // 指向下一个节点
Submission(int i) : id(i), next(nullptr) {}
};
int main() {
Submission a(101), b(102), c(103);
a.next = &b; // 手工串成链表:a → b → c
b.next = &c;
// 遍历:p 走到 nullptr 为止
for (Submission* p = &a; p != nullptr; p = p->next) {
std::cout << "提交 " << p->id << "\n";
}
// p->next 是 (*p).next 的语法糖:解引用再取成员
return 0;
}
注意这里节点都在栈上、指针只做「连线」,不涉及堆内存分配——所以没有泄漏风险。一旦 new 出堆对象,内存管理规则就变了,那是第 5 课的主题。
步骤 6:const 与指针的组合
int score = 87;
const int* p1 = &score; // 指向常量的指针:*p1 不可写,p1 可改指
int* const p2 = &score; // 常量指针:p2 不可改指,*p2 可写
const int* const p3 = &score; // 都不可改
void print(const int& x) { /* 只读参数,既快又安全 */ }
口诀:const 在 * 左边管「值」,在 * 右边管「指针」。
讲清楚要点
- 指针是变量,引用是别名:指针有自己的内存(8 字节)、可空、可重指、可做加减运算;引用无独立内存语义、不可空、绑定后不可改。函数参数默认优先引用。
- 段错误 = 访问了不该访问的内存:解引用空指针、野指针(指向已释放内存)、数组越界,都是 UB。判题沙箱捕获 SIGSEGV 信号就能把用户程序的崩溃转成 RE(Runtime Error)结果。
&和*是一对逆运算:&x从对象到地址,*p从地址回对象。声明里的*、&是类型的一部分(int* p),表达式里的*、&是运算符——同一符号两种身份,初学常混。->是「解引用 + 取成员」的糖:p->id等价(*p).id。对象用.,对象指针用->,看符号就知道你手里是对象还是指针。- 数组名会退化为指针:传数组进函数时
int arr[]实际是int*,长度信息丢失,所以 C 风格接口必须额外传n。现代 C++ 用std::vector/std::array自带长度,能少一半这类 bug。
你可能会懵的地方
-
Java 的引用和 C++ 的引用是一回事吗? 不是。Java 引用本质是「受控指针」:可空、可重指、GC 管回收,但不能做地址运算。C++ 引用是对象别名,不可空、不可重指。Java 引用的灵活度更接近 C++ 指针(但被砍掉了指针运算和地址暴露)。
-
什么时候必须用指针,引用替代不了? 三种场景:需要「可空」语义;需要改指针本身(如链表头插法
insert(Node** head));需要指针运算遍历内存。其余场景引用更安全。 -
int* p还是int *p? 语义完全相同,风格之争。int* p, q;里 q 只是 int 不是指针,为避免误导,很多团队规范一行只声明一个变量。 -
野指针和空指针什么区别? 空指针是「明确不指向」,判空即可防;野指针指向的内存已被释放或从未初始化,看起来正常用起来爆。防野指针三板斧:声明时就初始化(给 nullptr)、释放后立刻置空、优先用智能指针(第 5 课)。
-
指针变量本身占多大内存? 与目标平台有关:64 位程序 8 字节、32 位程序 4 字节,与它指向什么类型无关。
sizeof(int*)、sizeof(double*)都是 8(64 位平台)。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
int* p; *p = 5; | 野指针,写到随机地址,程序可能崩 | 声明即初始化:int* p = nullptr; 或指向真实对象 |
int& r; | 引用必须初始化,编译不过 | int& r = score; 绑定时就给对象 |
| 解引用前不判空 | 空指针解引用 → 段错误 | if (p) { ... } 先判空 |
return &localVar; | 返回局部变量地址,函数返回后内存失效 | 返回值、引用参数回填,或返回堆/静态对象 |
用完 delete 后继续用指针 | 悬垂指针(野指针的一种) | delete 后置 nullptr,或直接用 unique_ptr |
卡点急救
- 程序一运行就 Segmentation Fault:九成是空/野指针解引用。编译加
-g,用gdb ./程序跑,崩溃后bt看调用栈直接定位行号(第 11 课详解)。 - 改了形参外面没变:用了值传递。想改外面就传
T&;只读大对象传const T&。 - 打印指针输出乱码地址而不是值:
cout << p打的是地址,要看值写cout << *p;打印字符串指针时 cout 会自动输出内容,这是特例。
面试常问
-
指针和引用的区别? 引用必须初始化且不可重绑、无空引用、sizeof 是目标对象大小;指针可空、可重指、可运算、自身占 8 字节。引用更安全,指针表达能力更强。底层实现上引用通常也是指针,但语言层面把它当别名。
-
什么是野指针,如何避免? 指向无效内存的指针:未初始化、delete 后未置空、返回局部变量地址都会产生。避免手段:声明即初始化、delete 后置 nullptr、不返回局部对象地址、用智能指针接管生命周期。
-
const int* p、int* const p、const int* const p分别什么含义? 第一个:指向常量的指针,值不可改、指向可改;第二个:常量指针,指向不可改、值可改;第三个:两者都不可改。记忆法:const 在*左修饰所指之物,在*右修饰指针本身。
自测三问
p->id等价于哪段写法?- C++ 引用为什么不能表达「可能没有对象」?替代方案是什么?
- 数组传参时
int arr[]实际退化成什么类型?
小结一句话
指针是存地址的变量,引用是对象的别名;参数默认用引用、可空语义用指针(或 optional)、链表和地址运算是指针的主场——掌握这对概念,C++ 内存模型的大门就开了。
延伸
- cppreference:指针声明
- cppreference:引用声明
- 下一课:面向对象——类、构造析构与 RAII。