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* pp 是指向 int 的指针。
  • *p:访问 p 指向的对象。
  • p + 1:移动到下一个 int 的位置,不是地址数值简单加 1。

函数参数如果按值传递,只会修改副本;如果传地址,就能修改外部对象。

void inc(int* p) {
    (*p)++;
}

int x = 0;
inc(&x); // x == 1

这里必须写 (*p)++

如果写成 *p++

后置 ++ 的优先级比解引用 * 高。

它做的是:

  1. 先取当前 p 指向的值
  2. 然后让指针 p 自己往后移动一个 int 的位置
  3. 但取出来的那个值没有被修改

几个容易混的式子:

(*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&&:右值引用,先认识语法和语境。
  • 引用折叠:模板转发相关,先记规则。