如果你从 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 的值: 42x 的地址: 0x7ffee4b3c8acptr 存的值: 0x7ffee4b3c8ac解引用 ptr: 42修改后 x: 100两个操作符,记住:
&(取地址符):放在变量前面,得到它的地址*(解引用符):放在指针前面,通过地址访问原始数据
ptr 本身只是一个装着地址的变量。*ptr 才是”去那个地址,看那里存的东西”。
指针的类型
为什么指针要区分类型?int* 和 char* 都是存地址,有什么区别?
区别在于:类型告诉编译器,从那个地址开始读多少字节,以及怎么解释这些字节。
int x = 42;int* pi = &x; // 从 &x 读 4 字节,解释成 intchar* 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 指向: 10p+1 指向: 20p+2 指向: 30p 的地址: 0x7ffeeb1234a0p+1 的地址: 0x7ffeeb1234a4 ← 差了 4(int 的大小)p+2 的地址: 0x7ffeeb1234a8 ← 又差了 4p + 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 = 100std::cout << x; // 100看起来就像直接操作变量,但底层几乎总是编译成指针。用 g++ -O0 看汇编,int& ref = x 生成的代码和 int* ptr = &x 几乎相同。
引用的三条规则
- 必须在声明时初始化:
int& ref;是编译错误 - 不能绑定到 nullptr:引用保证总是有效
- 不能重新绑定:一旦绑定了某个变量,就永远是那个变量
int a = 1, b = 2;int& ref = a;
ref = b; // 这不是让 ref 指向 b! // 这是把 b 的值赋给 a,ref 还是绑定在 a 上std::cout << a; // 2const 引用与临时对象
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_ptr、std::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,不要用 NULL 或 0 表示空指针。 NULL 是 C 遗留下来的历史包袱,在 C++ 里它只是个伪装成空指针的整数。
小结
- 指针是存着内存地址的变量,就是一个数字,没有魔法
- 类型决定了从地址开始读多少字节、如何解释
void*只存地址,不知道类型,需要手动转型才能使用- 64 位系统上所有指针都是 8 字节,和指向的类型无关
- 指针算术中
p + 1移动的是sizeof(T)字节,不是 1 字节 - 引用是更安全的指针语法糖,必须初始化,不能为 null,不能重新绑定
- 函数参数优先用引用;需要表达”可选”或需要重新指向时用指针
指针是 C++ 和”底层”之间的桥梁。搞清楚它,后面的动态内存、数据结构、性能优化才有根基可谈。