2717 字
14 分钟
C++ 指针与引用

如果你从 Python 或 Java 过来,大概从来没想过”变量存在哪”这个问题。Python 里,x = 42 就是一个对象,你不用关心它在内存的哪个角落。C++ 不一样——它把内存直接暴露给你,而指针就是操控内存的工具。

理解指针,是真正理解 C++ 的第一步。好消息是:指针没有任何魔法。它就是一个存着数字的变量,这个数字恰好是某块内存的地址。仅此而已。


内存是什么#

先建立一个心智模型。

你可以把程序运行时的内存想象成一条巨长的字节数组:

地址: 0x00 0x01 0x02 0x03 0x04 ...
内容: [??] [??] [??] [??] [??] ...

每个格子存 1 个字节(8 bit)。每个格子都有一个编号,就是它的地址。当你声明一个 int x = 42,操作系统会在这条数组里找 4 个连续的字节,把它们分配给 x,然后把 42 写进去。

指针就是存着这个”格子编号”的变量。


声明和使用指针#

#include <iostream>
int main() {
int x = 42;
// & 取地址:获取 x 在内存中的地址
int* ptr = &x;
std::cout << "x 的值: " << x << "\n"; // 42
std::cout << "x 的地址: " << &x << "\n"; // 0x7ffee4b3c8ac (类似这样)
std::cout << "ptr 存的值: " << ptr << "\n"; // 0x7ffee4b3c8ac (和上面一样)
std::cout << "解引用 ptr: " << *ptr << "\n"; // 42
// * 解引用:通过指针修改原始变量
*ptr = 100;
std::cout << "修改后 x: " << x << "\n"; // 100
}

输出大概长这样:

x 的值: 42
x 的地址: 0x7ffee4b3c8ac
ptr 存的值: 0x7ffee4b3c8ac
解引用 ptr: 42
修改后 x: 100

两个操作符,记住:

  • &(取地址符):放在变量前面,得到它的地址
  • *(解引用符):放在指针前面,通过地址访问原始数据

ptr 本身只是一个装着地址的变量。*ptr 才是”去那个地址,看那里存的东西”。


指针的类型#

为什么指针要区分类型?int*char* 都是存地址,有什么区别?

区别在于:类型告诉编译器,从那个地址开始读多少字节,以及怎么解释这些字节。

int x = 42;
int* pi = &x; // 从 &x 读 4 字节,解释成 int
char* pc = (char*)&x; // 从 &x 读 1 字节,解释成 char

类型是给编译器看的元数据。运行时的地址本身只是一个数字,不携带任何类型信息。

void*:纯地址,无类型#

void* 是特殊的:它存地址,但不知道指向什么类型。

int x = 42;
void* vp = &x; // OK,任何指针都可以隐式转换成 void*
// *vp = 100; // 错误!void* 不能解引用,因为不知道读几个字节
// vp + 1; // 错误!不知道步长
// 要用就必须先转回有类型的指针
int* ip = (int*)vp;
*ip = 100; // OK

这也是为什么 C 的 malloc 返回 void*

void* malloc(size_t size);
int* arr = (int*)malloc(10 * sizeof(int));

malloc 只负责分配一块内存,它不知道你要用来存什么类型的数据,所以返回 void*,由调用者自己决定怎么解释。C++ 的 new 则省去了这个手动转型。


指针的大小:永远是 8 字节(64 位系统)#

这是一个常见的误解点:指针的大小和它指向的类型无关

#include <iostream>
int main() {
int x = 0;
double d = 0.0;
char c = 0;
int* pi = &x;
double* pd = &d;
char* pc = &c;
std::cout << sizeof(pi) << "\n"; // 8
std::cout << sizeof(pd) << "\n"; // 8
std::cout << sizeof(pc) << "\n"; // 8
}

输出全是 8。道理很简单:在 64 位系统上,地址空间是 64 位,所以任何地址都需要 64 bit = 8 字节来存。不管你指向的是 1 字节的 char 还是 8 字节的 double,地址的大小都一样。

(32 位系统上指针是 4 字节,现在基本不用操心这个了。)


指针算术:加 1 不是加 1 字节#

这是初学者最容易踩的坑:

#include <iostream>
int main() {
int arr[3] = {10, 20, 30};
int* p = arr; // p 指向 arr[0]
std::cout << "p 指向: " << *p << "\n"; // 10
std::cout << "p+1 指向: " << *(p + 1) << "\n"; // 20
std::cout << "p+2 指向: " << *(p + 2) << "\n"; // 30
// 看看地址的变化
std::cout << "p 的地址: " << (void*)p << "\n";
std::cout << "p+1 的地址: " << (void*)(p + 1) << "\n";
std::cout << "p+2 的地址: " << (void*)(p + 2) << "\n";
}

输出大概:

p 指向: 10
p+1 指向: 20
p+2 指向: 30
p 的地址: 0x7ffeeb1234a0
p+1 的地址: 0x7ffeeb1234a4 ← 差了 4(int 的大小)
p+2 的地址: 0x7ffeeb1234a8 ← 又差了 4

p + 1 让指针向后移动了 sizeof(int) = 4 个字节,而不是 1 个字节。编译器知道你在操作 int*,所以它自动帮你乘以 sizeof(int)

这个机制让数组访问非常自然,因为数组元素在内存里本来就是紧挨着排列的。


nullptr:不指向任何地方#

int* p = nullptr; // p 存的是 0,不指向任何有效地址
if (p != nullptr) {
*p = 42; // 只有 p 有效时才解引用
}

nullptr 在底层就是地址 0(或者说全 0 的位模式)。解引用一个 nullptr 是未定义行为——通常会直接崩溃(段错误),这其实是好事,总比默默地读写错误内存强。


引用:更安全的指针语法#

引用是 C++ 在指针之上加的一层语法糖,让代码更安全、更易读。

int x = 42;
int& ref = x; // ref 是 x 的引用
ref = 100; // 等价于 x = 100
std::cout << x; // 100

看起来就像直接操作变量,但底层几乎总是编译成指针。用 g++ -O0 看汇编,int& ref = x 生成的代码和 int* ptr = &x 几乎相同。

引用的三条规则#

  1. 必须在声明时初始化int& ref; 是编译错误
  2. 不能绑定到 nullptr:引用保证总是有效
  3. 不能重新绑定:一旦绑定了某个变量,就永远是那个变量
int a = 1, b = 2;
int& ref = a;
ref = b; // 这不是让 ref 指向 b!
// 这是把 b 的值赋给 a,ref 还是绑定在 a 上
std::cout << a; // 2

const 引用与临时对象#

const 引用有一个特殊的能力:可以绑定到临时对象(右值),并延长它的生命周期:

// 普通引用不行
// int& r = 42; // 错误:不能绑定右值
// const 引用可以
const int& r = 42; // OK,42 的生命周期被延长到 r 的作用域结束
std::cout << r; // 42

这在函数参数里非常常见:接受 const std::string& 既能接受具名变量,也能接受字符串字面量,还避免了拷贝。


指针 vs 引用:用哪个?#

指针引用
是否可为 null可以(nullptr不可以
是否可重新绑定可以不可以
语法需要 *&透明,像普通变量
安全性较低较高

函数参数的经验法则:

// 1. 需要修改外部变量 → 用引用
void increment(int& n) {
n++;
}
// 2. 可选参数(可以传 null 表示"没有")→ 用指针
void process(const Config* config = nullptr) {
if (config) {
// 有配置
} else {
// 用默认行为
}
}
// 3. 只读,不修改 → 用 const 引用
void print(const std::string& s) {
std::cout << s;
}

完整例子:综合演示#

#include <iostream>
void doubleValue(int* ptr) {
if (ptr != nullptr) {
*ptr *= 2;
}
}
void tripleValue(int& ref) {
ref *= 3;
}
int main() {
int x = 5;
std::cout << "初始值: " << x << "\n"; // 5
doubleValue(&x);
std::cout << "乘2后: " << x << "\n"; // 10
tripleValue(x);
std::cout << "乘3后: " << x << "\n"; // 30
// 指针算术
int arr[] = {1, 2, 3, 4, 5};
int* p = arr;
for (int i = 0; i < 5; i++) {
std::cout << *(p + i) << " ";
}
std::cout << "\n"; // 1 2 3 4 5
// void*
void* raw = &x;
int* back = static_cast<int*>(raw);
std::cout << "通过 void* 读取: " << *back << "\n"; // 30
}

箭头运算符#

访问结构体或类的成员时,如果手上是指针,你会写:

(*ptr).member

先解引用,再访问成员。问题是括号不能省——*ptr.member 会被解析成 *(ptr.member),完全是另一个意思,因为 . 的优先级比 * 高。每次都写括号,繁琐且容易出错。

-> 就是为了解决这个问题存在的。ptr->member(*ptr).member 完全等价,编译器生成相同的汇编代码,只是写起来更干净:

struct Entity { int x; void Print() { std::cout << x; } };
Entity e{42};
Entity* ptr = &e;
(*ptr).Print(); // 能用,但丑
ptr->Print(); // 一样的效果,简洁

链式访问也很自然:

struct Node { Node* next; int val; };
Node* head = ...;
head->next->val; // 等价于 (*((*head).next)).val,但没人想写那个

另外,-> 是可以重载的。智能指针(std::unique_ptrstd::shared_ptr)就是靠重载 operator->() 让自己用起来和裸指针一样:你写 ptr->method(),它在内部做一些额外的事(比如检查引用计数),然后再访问真正的成员。这是 C++ 零成本抽象的一个典型例子。


nullptr 与 NULL#

在 C 里,NULL 被定义成 ((void*)0)——一个空指针。但 C++ 不允许 void* 隐式转换成其他指针类型,所以 C++ 的标准库通常把 NULL 直接定义成整数字面量 0

这就埋了一个坑。假设你有两个重载:

void f(int n) { std::cout << "f(int)\n"; }
void f(int* p) { std::cout << "f(int*)\n"; }
f(NULL); // 调用 f(int)!因为 NULL 就是 0,是整数
f(nullptr); // 调用 f(int*),符合你的预期

你的本意是传一个空指针,但因为 NULL 是整数,编译器选了 f(int),完全是错误的重载。这类 bug 不会有编译错误,只会在运行时行为不对,很难追。

nullptr 是 C++11 引入的解决方案。它的类型是 std::nullptr_t,可以隐式转换为任意指针类型,但不能隐式转换为整数。这意味着:

  • f(nullptr) → 编译器只匹配指针重载,不会有歧义
  • int n = nullptr; → 编译错误,行为明确,符合预期

结论很简单:永远用 nullptr,不要用 NULL0 表示空指针。 NULL 是 C 遗留下来的历史包袱,在 C++ 里它只是个伪装成空指针的整数。


小结#

  • 指针是存着内存地址的变量,就是一个数字,没有魔法
  • 类型决定了从地址开始读多少字节、如何解释
  • void* 只存地址,不知道类型,需要手动转型才能使用
  • 64 位系统上所有指针都是 8 字节,和指向的类型无关
  • 指针算术p + 1 移动的是 sizeof(T) 字节,不是 1 字节
  • 引用是更安全的指针语法糖,必须初始化,不能为 null,不能重新绑定
  • 函数参数优先用引用;需要表达”可选”或需要重新指向时用指针

指针是 C++ 和”底层”之间的桥梁。搞清楚它,后面的动态内存、数据结构、性能优化才有根基可谈。

博客桌宠