2769 words
14 minutes
C++ 指针入门:地址、解引用与内存访问

本文是 C++ 系列 的第二篇。

上一篇:C++ 系列前言:从语法到工程能力

指针是 C++ 里最容易被低估的基础概念之一。

表面上看,指针只是“保存地址的变量”。但一旦把它放进实际代码,就会继续牵出对象、类型、解引用、数组、函数参数、生命周期和未定义行为。

本文先不讨论动态内存和智能指针,只关注最小模型:

  1. 变量有值,也有地址
  2. 指针变量保存某个对象的地址
  3. 解引用可以通过地址访问对象
  4. 指针类型决定如何解释那块内存
  5. 只有指向有效对象的指针才能被解引用

1. 变量、对象和地址#

在 C++ 里,一个普通变量至少包含几层信息:

  1. 变量名:代码中访问它的名字
  2. 类型:决定这块数据应该按什么格式解释
  3. 值:当前保存的数据
  4. 地址:对象在内存中的位置

看一个最小例子:

#include <iostream>
int main() {
int a = 10;
std::cout << "a = " << a << '\n';
std::cout << "&a = " << &a << '\n';
return 0;
}

一次可能的运行结果:

a = 10
&a = 0x16b7fad4c

这里:

  1. a 表示变量的值,也就是 10
  2. &a 表示对变量 a 取地址

地址每次运行都可能不同,这和栈布局、地址空间随机化等因素有关。理解指针时不需要记住某个具体地址,只需要记住:变量除了有值,也有对应的存储位置

从语言层面看,&a 的结果类型是 int*,意思是“指向 int 对象的指针”。

2. 指针是保存地址的变量#

指针本身也是变量。

普通 int 变量保存整数,double 变量保存浮点数,而指针变量保存的是地址。

#include <iostream>
int main() {
int a = 10;
int* p = &a;
std::cout << "a = " << a << '\n';
std::cout << "&a = " << &a << '\n';
std::cout << "p = " << p << '\n';
std::cout << "*p = " << *p << '\n';
return 0;
}

一次可能的运行结果:

a = 10
&a = 0x16d7dad4c
p = 0x16d7dad4c
*p = 10

这段代码里最关键的是:

int* p = &a;

它可以拆成两部分理解:

  1. int*p 是一个指针,指向的对象类型是 int
  2. &a:取出 a 的地址,并把这个地址保存到 p

所以:

  1. p 的值等于 &a
  2. *p 表示沿着 p 保存的地址访问对应对象
  3. 因为 p 指向 a,所以 *p 读到的就是 a 的值

需要注意声明写法:

int* p1, p2;

这行里只有 p1int*p2 是普通 int。如果要声明两个指针,应该写成:

int* p1;
int* p2;

或者:

int *p1, *p2;

更推荐每行只声明一个变量,避免在指针声明上制造歧义。

3. 解引用:通过地址访问对象#

*p 叫解引用。

如果 p 保存了某个有效对象的地址,那么 *p 就表示那个对象本身。

这意味着我们不仅可以通过指针读取变量,也可以通过指针修改变量。

#include <iostream>
int main() {
int a = 10;
int* p = &a;
*p = 20;
std::cout << "a = " << a << '\n';
std::cout << "*p = " << *p << '\n';
return 0;
}

运行结果:

a = 20
*p = 20

关键点在这里:

*p = 20;

这不是修改 p 这个指针变量本身,而是修改 p 指向的对象。

可以把两种赋值分开看:

p = &a; // 修改指针变量 p 保存的地址
*p = 20; // 修改 p 指向的对象

这是理解指针时最重要的分界线:

  1. p 是指针变量
  2. *p 是指针指向的对象
  3. &p 是指针变量自己的地址

4. 指针自己也有地址#

因为指针也是变量,所以指针自己也需要存储空间,也有自己的地址。

#include <iostream>
int main() {
int a = 10;
int* p = &a;
std::cout << "&a = " << &a << '\n';
std::cout << "p = " << p << '\n';
std::cout << "&p = " << &p << '\n';
return 0;
}

一次可能的运行结果:

&a = 0x16b5dad4c
p = 0x16b5dad4c
&p = 0x16b5dad40

这里有两层地址:

  1. p 的值是 a 的地址
  2. &p 是指针变量 p 自己的地址

所以 p&p 不是一回事。

如果继续取地址,&p 的类型是 int**,也就是“指向 int* 的指针”。这就是二级指针的来源,后面遇到函数修改指针本身、二维数组、命令行参数时还会继续用到。

5. 指针类型为什么重要#

指针保存的是地址,但 C++ 里的指针不只是一个裸地址。

指针类型至少影响两件事:

  1. 解引用时,编译器应该按什么类型解释这块内存
  2. 指针运算时,地址应该按多大步长移动

先看数组和指针加法:

#include <iostream>
int main() {
int arr[3] = {10, 20, 30};
int* p = arr;
std::cout << "p = " << p << '\n';
std::cout << "p + 1 = " << p + 1 << '\n';
std::cout << "*p = " << *p << '\n';
std::cout << "*(p + 1) = " << *(p + 1) << '\n';
std::cout << "sizeof(int) = " << sizeof(int) << '\n';
return 0;
}

一次可能的运行结果:

p = 0x16f9dad40
p + 1 = 0x16f9dad44
*p = 10
*(p + 1) = 20
sizeof(int) = 4

如果 int 占 4 个字节,那么 p + 1 不是让地址数值加 1,而是移动到下一个 int 的位置,也就是向后移动 4 个字节。

这就是指针类型的作用:

int* p;

表示 p 指向的是 int,所以:

  1. *p 会按 int 读取或写入
  2. p + 1 会移动 sizeof(int) 个字节

如果是:

double* q;

那么 q + 1 会移动 sizeof(double) 个字节。

顺便注意一件事:在大多数表达式里,数组名 arr 会转换成指向首元素的指针,也就是 &arr[0]。所以下面两行在这个例子里效果相同:

int* p1 = arr;
int* p2 = &arr[0];

但数组本身和指针不是同一种类型,后面讲数组、函数参数和 sizeof 时需要单独区分。

6. nullptr:明确表示“不指向对象”#

如果一个指针暂时不指向任何有效对象,应该初始化为 nullptr

#include <iostream>
int main() {
int* p = nullptr;
if (p == nullptr) {
std::cout << "p does not point to anything now\n";
}
return 0;
}

nullptr 表示空指针,它不指向任何有效对象。

所以不能对空指针解引用:

int* p = nullptr;
// 错误示范:未定义行为
std::cout << *p << '\n';

对空指针解引用是未定义行为。程序可能崩溃,也可能表现出别的异常现象,不能依赖任何结果。

在现代 C++ 里,优先使用 nullptr,不要继续使用 NULL0 表示空指针。nullptr 有独立类型 std::nullptr_t,在重载解析等场景下更清晰。

7. 指针作为函数参数#

指针常见用途之一,是把对象地址传给函数。

如果函数拿到的是值,它修改的是副本:

#include <iostream>
void changeByValue(int x) {
x = 100;
}
int main() {
int a = 10;
changeByValue(a);
std::cout << "a = " << a << '\n';
return 0;
}

运行结果:

a = 10

如果函数拿到的是指针,它就可以通过地址修改调用者传入的对象:

#include <iostream>
void changeByPointer(int* p) {
if (p == nullptr) {
return;
}
*p = 100;
}
int main() {
int a = 10;
changeByPointer(&a);
std::cout << "a = " << a << '\n';
return 0;
}

运行结果:

a = 100

这里传进去的是 a 的地址。函数内部执行 *p = 100; 时,修改的是外面的 a

从接口设计角度看,指针参数通常表达两层含义:

  1. 函数可能通过这个参数访问或修改外部对象
  2. 这个参数可能为空,因此函数需要考虑 nullptr

如果函数要求参数一定存在,而且只是想直接操作对象,很多时候引用会更合适:

void changeByReference(int& x) {
x = 100;
}

简单规则是:可以为空时用指针,必须存在时优先考虑引用。当然,具体选择还要结合接口语义和已有代码风格。

8. 常见错误#

8.1 未初始化指针#

int* p;
// 错误示范:p 没有被初始化
*p = 10;

局部指针变量如果没有初始化,它的值是不确定的。此时直接解引用,程序会访问一个不可预期的地址,属于未定义行为。

如果暂时不知道它应该指向谁,先写成:

int* p = nullptr;

8.2 空指针解引用#

int* p = nullptr;
// 错误示范:nullptr 不指向有效对象
std::cout << *p << '\n';

nullptr 的作用是表达“当前没有指向对象”,不是提供一个可以访问的默认对象。

使用可能为空的指针前,先判断:

if (p != nullptr) {
std::cout << *p << '\n';
}

8.3 悬空指针#

#include <iostream>
int main() {
int* p = nullptr;
{
int a = 10;
p = &a;
}
// 错误示范:a 的生命周期已经结束
std::cout << *p << '\n';
return 0;
}

a 是代码块里的局部变量。代码块结束后,a 的生命周期结束,p 仍然保存原来的地址,但那个地址上已经没有一个有效的 int a 对象。

这种指针叫悬空指针。

悬空指针最危险的地方在于:它的值看起来仍然像一个地址,但这个地址已经不能再按原来的对象使用。

8.4 越界指针访问#

int arr[3] = {10, 20, 30};
int* p = arr;
// 错误示范:越过数组有效元素
std::cout << *(p + 3) << '\n';

对于长度为 3 的数组,合法元素是:

arr[0], arr[1], arr[2]

p + 3 可以作为“尾后位置”参与比较,但不能解引用。

int* begin = arr;
int* end = arr + 3;
for (int* it = begin; it != end; ++it) {
std::cout << *it << '\n';
}

这里 end 表示范围结束位置,只作为边界,不作为可访问元素。

9. 小结#

这一篇先记住这些规则:

  1. &a 表示取出变量 a 的地址
  2. int* p = &a; 表示 p 保存一个 int 对象的地址
  3. p 是指针变量本身,*p 是指针指向的对象,&p 是指针变量自己的地址
  4. 指针类型决定解引用方式,也决定 p + 1 移动多少字节
  5. 未初始化指针、空指针、悬空指针都不能解引用
  6. 指针参数常用来表达“函数可能访问或修改外部对象”,也可能表达“这个参数允许为空”

理解指针的关键不是背概念,而是把每个符号对应到明确含义上:

int a = 10;
int* p = &a;
p; // 保存的地址
*p; // 地址指向的 int 对象
&p; // 指针变量 p 自己的地址

后面再看引用、数组、动态内存、对象生命周期、智能指针和迭代器失效,都会不断回到这几个基本问题:这个对象在哪里,谁保存了它的地址,它现在是否还有效。