C++复健笔记 Vol.2:指针和引用
摘要
从 C 的指针语义过渡到 C++ 引用语义,顺手复习空指针、野指针、数组退化、const 引用和右值引用。
书接上回,在写了两个非常简单的宝宝巴士程序后,今天来点儿幼儿园巴士:指针和引用。
地址与指针
先从取地址开始。
#include <iostream>
using namespace std;
int main() {
int x = 42;
cout << x << '\n';
cout << &x << '\n';
return 0;
}
&x 取到的是 x 的地址。指针变量可以保存这个地址,再通过解引用访问它指向的对象。
int x = 42;
int* p = &x;
*p = 100; // x == 100
这里几个符号的含义是:
&x:取x的地址。int* p:p是指向int的指针。*p:访问p指向的对象。p + 1:移动到下一个int的位置,不是地址数值简单加 1。
函数参数如果按值传递,只会修改副本;如果传地址,就能修改外部对象。
void inc(int* p) {
(*p)++;
}
int x = 0;
inc(&x); // x == 1
这里必须写 (*p)++。
如果写成 *p++
后置 ++ 的优先级比解引用 * 高。
它做的是:
- 先取当前 p 指向的值
- 然后让指针 p 自己往后移动一个 int 的位置
- 但取出来的那个值没有被修改
几个容易混的式子:
(*p)++; // 指向的值后置自增
++*p; // 指向的值前置自增,等价于 ++(*p)
*p++; // 取当前值,然后 p 后移,等价于 *(p++)
*++p; // p 先后移,再取新位置的值
空指针和野指针
现代 C++ 用 nullptr 表示空指针。
int* p = nullptr;
if (p != nullptr) {
cout << *p << '\n';
}
空指针可以比较,不能解引用。
野指针一般来自两类情况。第一种是未初始化:
int* p; // 未初始化
*p = 10; // UB
第二种是指向已经失效的位置,比如返回局部变量地址:
int* bad() {
int x = 10;
return &x; // 返回局部变量地址
}
- 指针声明时初始化,不知道指向谁就设为
nullptr。 - 解引用前确认指针有效。
- 不返回局部变量地址。
- 能用引用或容器时,不为了“像 C”而手写裸指针。
数组与指针
数组名在很多表达式里会退化为指向首元素的指针。
int a[3] = {10, 20, 30};
cout << *a << '\n'; // 10
cout << *(a + 1) << '\n'; // 20
cout << a[2] << '\n'; // 30
a[i] 可以理解成 *(a + i)。但数组不是指针本身:
int a[3] = {10, 20, 30};
int* p = a;
cout << sizeof(a) << '\n'; // 3 * sizeof(int)
cout << sizeof(p) << '\n'; // sizeof(int*)
函数参数里的数组比较特殊:
void f(int a[]) {
cout << sizeof(a) << '\n'; // 这里 a 已经是 int*
}
也就是说,数组作为函数参数时会退化成指针,长度信息会丢掉。机考里如果没有特殊需求,我还是优先用 vector,省掉手动传长度和越界风险。
指针数组和数组指针
非常适合拿来折磨人。
指针数组:本质是数组,数组里的每个元素都是指针。
int a = 1;
int b = 2;
int c = 3;
int* p[3] = {&a, &b, &c};
cout << *p[0] << '\n'; // 1
p 先和 [3] 结合,所以 p 是一个数组;数组元素类型是 int*。
数组指针:本质是指针,指向一个数组。
int arr[3] = {1, 2, 3};
int (*p)[3] = &arr;
cout << (*p)[0] << '\n'; // 1
cout << (*p)[2] << '\n'; // 3
这里括号不能省。如果写成:
int* p[3];
那就是指针数组;写成:
int (*p)[3];
才是数组指针。
数组指针如果作为函数返回值,声明会更难认:
int (*getArray())[3] {
static int arr[3] = {1, 2, 3};
return &arr;
}
这里 getArray 是一个函数,它返回 int (*)[3],也就是“指向含 3 个 int 的数组的指针”。调用时可以这样写:
int (*p)[3] = getArray();
cout << (*p)[0] << '\n';
这种声明可读性比较差。实际写 C++ 时可以用 using 起别名:
using IntArray3 = int[3];
IntArray3* getArray() {
static int arr[3] = {1, 2, 3};
return &arr;
}
这样就清楚很多:getArray 返回的是 IntArray3*。
引用
引用可以理解成对象的别名。它声明时必须绑定,之后不能改绑。
int x = 42;
int& r = x;
r = 100; // x == 100
引用传参可以替代很多“传指针进去改外部变量”的写法:
void inc(int& x) {
x++;
}
int a = 0;
inc(a); // a == 1
和指针传参对比:
void inc1(int* p) { (*p)++; }
void inc2(int& x) { x++; }
inc1(&a);
inc2(a);
引用版本更像普通变量,调用点也更干净;指针版本则能显式表达“可能为空”“可能改指向”等语义。机考里的普通修改参数,引用通常更顺。
作为练习,我手写了两个版本的 swap。
引用版本:
void swap(int& a, int& b) {
int t = a;
a = b;
b = t;
}
指针版本:
void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
调用点区别也很清楚:
int x = 1;
int y = 2;
swap(x, y); // 引用版本
swap(&x, &y); // 指针版本
当然真正写题时直接用标准库:
swap(a, b);
范围 for 里的引用
范围 for 默认按值拷贝。
vector<int> a = {1, 2, 3};
for (int x : a) {
x *= 2;
}
// a 仍然是 {1, 2, 3}
要修改原容器元素:
for (int& x : a) {
x *= 2;
}
只读且避免拷贝:
for (const string& s : names) {
cout << s << '\n';
}
这里的判断可以简单一点:
- 小类型只读:
int x - 需要修改:
T& x - 大对象只读:
const T& x
const 引用
const T& 是 C++ 函数参数里非常常见的写法:避免拷贝,同时承诺不修改。
long long sum(const vector<int>& a) {
long long ans = 0;
for (int x : a) {
ans += x;
}
return ans;
}
如果写成:
long long sum(vector<int> a)
会复制整个 vector。数据量一大,这种拷贝没有必要。
const 和指针放在一起时也需要恢复一下:
const int* p1; // 指向 const int,不能通过 p1 改值,p1 可改指向
int* const p2 = &x; // const 指针,p2 不可改指向,可通过 p2 改值
const int* const p3 = &x; // 值和指向都不能改
读法技巧:从变量名开始往外读。p2 先遇到 const,说明指针本身 const;p1 指向的是 const int。
左值引用和右值引用
普通引用 T& 是左值引用,通常绑定到有名字的对象。
int x = 10;
int& r = x;
非常量左值引用不能绑定临时值:
int& r = 10; // error
但 const T& 可以绑定临时值:
const int& r = 10;
右值引用写作 T&&:
int&& r = 10;
它主要服务于移动语义和完美转发。机考里通常不需要手写复杂右值引用,但至少要能区分:
if (a && b) { } // 逻辑与
int&& x = 10; // 右值引用
同样是 &&,一个在表达式里,一个在类型里。
引用折叠
引用折叠主要在模板里出现。
template <typename T>
void f(T&& x) {
}
这里的 T&& 在模板推导中不一定是右值引用。折叠规则:
& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&
记忆:只要有一个 &,结果就是 &;只有全是 &&,结果才是 &&。
这就是所谓 forwarding reference / universal reference 的基础。现阶段先能看懂,不展开 std::forward。
小结
- 指针:地址对象,核心操作是取地址和解引用。
- 空指针:
nullptr,能比较,不能解引用。 - 野指针:未初始化、悬垂地址、生命周期结束后继续使用。
- 数组:表达式中常退化成首元素指针,但数组本身不是指针。
- 引用:对象别名,声明时绑定,不能改绑。
- 引用传参:修改外部对象时比指针更顺手。
const T&:避免拷贝且只读,传大对象时常用。T&&:右值引用,先认识语法和语境。- 引用折叠:模板转发相关,先记规则。