本文是 C++ 系列 的第二篇。
指针是 C++ 里最容易被低估的基础概念之一。
表面上看,指针只是“保存地址的变量”。但一旦把它放进实际代码,就会继续牵出对象、类型、解引用、数组、函数参数、生命周期和未定义行为。
本文先不讨论动态内存和智能指针,只关注最小模型:
- 变量有值,也有地址
- 指针变量保存某个对象的地址
- 解引用可以通过地址访问对象
- 指针类型决定如何解释那块内存
- 只有指向有效对象的指针才能被解引用
1. 变量、对象和地址
在 C++ 里,一个普通变量至少包含几层信息:
- 变量名:代码中访问它的名字
- 类型:决定这块数据应该按什么格式解释
- 值:当前保存的数据
- 地址:对象在内存中的位置
看一个最小例子:
#include <iostream>
int main() { int a = 10;
std::cout << "a = " << a << '\n'; std::cout << "&a = " << &a << '\n';
return 0;}一次可能的运行结果:
a = 10&a = 0x16b7fad4c这里:
a表示变量的值,也就是10&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 = 0x16d7dad4cp = 0x16d7dad4c*p = 10这段代码里最关键的是:
int* p = &a;它可以拆成两部分理解:
int*:p是一个指针,指向的对象类型是int&a:取出a的地址,并把这个地址保存到p里
所以:
p的值等于&a*p表示沿着p保存的地址访问对应对象- 因为
p指向a,所以*p读到的就是a的值
需要注意声明写法:
int* p1, p2;这行里只有 p1 是 int*,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 指向的对象这是理解指针时最重要的分界线:
p是指针变量*p是指针指向的对象&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 = 0x16b5dad4cp = 0x16b5dad4c&p = 0x16b5dad40这里有两层地址:
p的值是a的地址&p是指针变量p自己的地址
所以 p 和 &p 不是一回事。
如果继续取地址,&p 的类型是 int**,也就是“指向 int* 的指针”。这就是二级指针的来源,后面遇到函数修改指针本身、二维数组、命令行参数时还会继续用到。
5. 指针类型为什么重要
指针保存的是地址,但 C++ 里的指针不只是一个裸地址。
指针类型至少影响两件事:
- 解引用时,编译器应该按什么类型解释这块内存
- 指针运算时,地址应该按多大步长移动
先看数组和指针加法:
#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 = 0x16f9dad40p + 1 = 0x16f9dad44*p = 10*(p + 1) = 20sizeof(int) = 4如果 int 占 4 个字节,那么 p + 1 不是让地址数值加 1,而是移动到下一个 int 的位置,也就是向后移动 4 个字节。
这就是指针类型的作用:
int* p;表示 p 指向的是 int,所以:
*p会按int读取或写入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,不要继续使用 NULL 或 0 表示空指针。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。
从接口设计角度看,指针参数通常表达两层含义:
- 函数可能通过这个参数访问或修改外部对象
- 这个参数可能为空,因此函数需要考虑
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. 小结
这一篇先记住这些规则:
&a表示取出变量a的地址int* p = &a;表示p保存一个int对象的地址p是指针变量本身,*p是指针指向的对象,&p是指针变量自己的地址- 指针类型决定解引用方式,也决定
p + 1移动多少字节 - 未初始化指针、空指针、悬空指针都不能解引用
- 指针参数常用来表达“函数可能访问或修改外部对象”,也可能表达“这个参数允许为空”
理解指针的关键不是背概念,而是把每个符号对应到明确含义上:
int a = 10;int* p = &a;
p; // 保存的地址*p; // 地址指向的 int 对象&p; // 指针变量 p 自己的地址后面再看引用、数组、动态内存、对象生命周期、智能指针和迭代器失效,都会不断回到这几个基本问题:这个对象在哪里,谁保存了它的地址,它现在是否还有效。