前言
指针是 C++ 中最基础也最容易出错的类型之一。它把「对象在哪里」和「对象是什么类型」分开表达:指针变量里存的是一个地址,而指针的类型决定了解引用时读多少字节、按什么方式解释、以及指针加一时前进多远。理解这一点,很多看似奇怪的现象就都能解释,例如int*加一前进 4 字节,而char*加一只前进 1 字节。
初学者常见的误解有四个。第一,把指针当成「一个整数」——地址值确实可以用整数表示,但指针的类型信息、对齐要求、以及不同对象之间的比较规则都不是整数能替代的。第二,认为「数组就是指针」——数组名在多数表达式里会退化成指向首元素的指针,但数组本身有大小,sizeof的结果完全不同。第三,分不清const int*和int* const——前者是「指向常量的指针」,后者是「常量指针」,一个限制解引用后的写操作,一个限制指针本身的赋值。第四,认为只要指针不为空就安全——悬垂指针、越界指针、类型不匹配的指针都可能非空,却仍然导致未定义行为(undefined behavior, UB)。
本文从指针的表示讲起,依次讨论const的组合、数组与指针算术、函数指针,最后落到现代 C++ 的替代方案。代码以 C++17 为基准,本机没有 C++ 工具链,示例只做语义说明。
一、指针的表示:地址、类型与解引用
声明int* p = &x;时,&x取得x的地址,p保存这个地址,*p则回到该地址上的那个int对象。这里三个符号各有分工:&是取地址,*在声明中是「指针类型」的一部分,在表达式中是解引用。声明里的*和表达式里的*容易混淆,建议把int* p整体读作「p 是指向 int 的指针」。
指针的类型决定了三件事:
- 解引用的宽度和解释方式:
int*解引用得到int,double*解引用得到double。 - 指针算术的步长:
p + 1前进sizeof(*p)个字节,而不是一个字节。 - 可执行的操作:
void*可以保存任意对象地址,但不能直接解引用,必须先转回具体类型。
void*的存在是为了传递「未知类型的地址」,例如malloc的返回值。把void*转回原类型再解引用是合法的,但转成错误的类型再访问对象,违反严格别名规则,属于 UB。现代 C++ 中应尽量用模板或明确的接口类型代替void*。
未初始化的指针保存的是不确定值,读取它本身就是未定义行为,更不用说解引用。指针变量应在声明时初始化:要么指向一个真实对象,要么写成nullptr。nullptr是 C++11 引入的空指针字面量,类型为std::nullptr_t,可以隐式转换为任意对象指针和函数指针;它比NULL和0更明确,也不会在重载决议中意外匹配整数版本。
// 适用于 C++17
int x = 42;
int* p = &x; // 指向 x
int* q = nullptr; // 明确为空
// int* r; // 未初始化,不要读取
if (p != nullptr) {
*p = 43; // 通过指针修改 x
}指针本身也是对象,因此可以有「指向指针的指针」:int** pp = &p;。多级指针在需要修改调用方指针时有用,例如让函数分配内存并写回指针;但层数一多可读性急剧下降,通常应改用引用或返回智能指针。
二、const 与指针的三种组合
const和*的相对位置决定了谁被限定。一个实用的读法是从右往左读:
| 声明 | 读法 | 能否改指向 | 能否通过它改值 |
|---|
int* p | p 是指向 int 的指针 | 能 | 能 |
const int* p | p 是指向 const int 的指针 | 能 | 不能 |
int* const p | p 是指向 int 的 const 指针 | 不能 | 能 |
const int* const p | p 是指向 const int 的 const 指针 | 不能 | 不能 |
int** p | p 是指向(指向 int 的指针)的指针 | 能 | 能(两层都可改) |
注意const int*和int const*完全等价,const修饰的是它左边的类型;当const位于*左边时修饰被指向的对象,位于*右边时修饰指针本身。
// 适用于 C++17
int a = 1;
int b = 2;
const int* pc = &a; // 指向常量的指针
pc = &b; // 可以改指向
// *pc = 3; // 错误:不能通过 pc 修改对象
int* const cp = &a; // 常量指针
*cp = 3; // 可以改值
// cp = &b; // 错误:不能改指向
const int* const cc = &a;
// cc = &b; // 错误
// *cc = 4; // 错误这里有一个常被忽略的点:const int*并不要求它指向的对象本身是const。上例中a不是 const,但通过pc不能写;如果另有一个非 const 指针指向a,仍然可以修改。const限制的是「这条访问路径」,不是对象的物理属性。
函数参数中,顶层const会被忽略:void f(int* const p)和void f(int* p)是同一个函数类型,不能构成重载。而底层const会保留:void f(const int*)和void f(int*)是不同参数类型,可以重载。另外,int**不能隐式转换为const int**——如果允许,就能借由这个指针把 const 对象的地址写进去,破坏 const 正确性。需要时只能显式const_cast,并自行确保不写入。
三、数组、指针算术与函数指针
数组名在大多数表达式中会转换成指向首元素的指针,这叫数组到指针的退化(array-to-pointer decay)。但退化不是「数组就是指针」:数组类型包含元素个数,sizeof(arr)得到整个数组的字节数,而sizeof(ptr)只是指针本身的大小。数组作为函数参数时会退化成指针,长度信息丢失,因此函数应额外接收长度,或改用std::array、std::vector、std::string_view这类自带大小的类型。
// 适用于 C++17
#include <cstddef>
void sum_array(const int* data, std::size_t n) {
int sum = 0;
for (std::size_t i = 0; i < n; ++i) {
sum += data[i]; // 等价于 *(data + i)
}
// ...
}
int main() {
int arr[5] = {1, 2, 3, 4, 5};
sum_array(arr, 5); // arr 退化为 &arr[0]
}指针算术只在一个数组(或一个对象内部)范围内有定义,包括「指向末尾后一个位置」的指针。超出这个范围做加减是 UB;对「末尾后一个位置」解引用同样是 UB。两个指针相减,只有指向同一数组(或同一对象)的元素时才有定义,结果是元素个数之差。比较两个指向无关对象的指针的大小关系,结果未指定,不应作为逻辑依据。
// 适用于 C++17
int arr[5] = {1, 2, 3, 4, 5};
int* begin = arr;
int* end = arr + 5; // 合法的 one-past-the-end
for (int* it = begin; it != end; ++it) {
// *it 有效
}
// int bad = *end; // UB:不能解引用末尾后一个位置函数指针用于把「函数」作为值传递,例如回调表。声明时*必须和函数名一起加括号:
// 适用于 C++17
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main() {
int (*op)(int, int) = &add; // & 可省略,函数名会退化为指针
int r1 = op(3, 4); // 通过函数指针调用
op = ⊂
int r2 = op(3, 4);
return r1 + r2;
}函数指针的类型包含返回类型和参数类型;默认参数不属于函数类型,所以通过函数指针调用时不能省略参数。成员函数指针更复杂,还需要对象才能调用,日常业务代码中通常用 lambda 或std::function代替。
四、现代 C++:先问「谁拥有这个对象」
裸指针本身没有错,错的是用它表达所有权。一个指针可以表示三种关系:拥有对象并负责释放、借用对象但不负责释放、仅仅观察地址。传统 C 风格代码把三者混在一起,导致「谁该 delete」变成口头约定。现代 C++ 的做法是用类型表达所有权:
- 拥有独占所有权:
std::unique_ptr<T>,不可拷贝、可移动,用std::make_unique创建(C++14)。 - 共享所有权:
std::shared_ptr<T>,用std::make_shared创建;循环引用用std::weak_ptr打破。 - 不拥有、可为空、可重新指向:裸指针或引用;函数参数优先用
T&,需要「可能没有」时用T*或std::optional<std::reference_wrapper<T>>。 - 连续只读视图:
std::string_view(C++17)或std::span(C++20)。
// 适用于 C++17
#include <memory>
#include <string>
#include <vector>
struct Node {
int value = 0;
std::unique_ptr<Node> next;
};
void print_value(const Node* node) { // 借用,不负责释放
if (node != nullptr) {
// 只读访问
}
}
int main() {
auto head = std::make_unique<Node>();
head->value = 1;
head->next = std::make_unique<Node>();
head->next->value = 2;
print_value(head.get());
print_value(head->next.get());
}这里head拥有整条链,print_value只借用指针。函数签名用const Node*明确表达了「可能为空、不拥有」;如果约定不会为空,用const Node&更直接。std::unique_ptr析构时会自动释放对象,不需要手写delete,也不会因为提前 return 或异常而泄漏。
对数组则要注意new[]必须配delete[],而new必须配delete。混用是 UB,可能只释放部分内存或破坏堆结构。更好的做法是根本不用裸数组:用std::vector<T>或std::array<T, N>,它们自己管理内存并携带大小。
常见坑点
- ❌ 声明指针后不初始化,直接解引用。
✅ 初始化为nullptr或有效对象地址;解引用前检查是否为空。
- ❌ 返回局部变量的地址。
✅ 局部对象在函数返回时销毁,返回其地址是悬垂指针;应返回值、std::unique_ptr,或由调用方传入缓冲区。
- ❌ 对
new[]分配的内存使用delete。
✅new[]配delete[],new配delete,绝不混用;优先用容器。
- ❌ 认为
const int*表示对象本身一定是常量。
✅ 它只限制通过该指针写入;对象是否 const 取决于定义。
- ❌ 把数组名当作指针后仍用
sizeof求元素个数。
✅ 数组退化后sizeof得到指针大小;用std::size(arr)(C++17)或容器自带的size()。
- ❌ 指针加减超出数组范围(包括越过 one-past-the-end)。
✅ 指针算术只允许在数组范围内,末尾后一个位置只能比较、不能解引用。
- ❌ 用
void*保存对象地址后转成错误类型解引用。
✅ 转回原始类型再访问;类型不匹配的访问违反严格别名规则,是 UB。
- ❌ 用两个裸指针共享所有权,各自
delete一次。
✅ 用std::shared_ptr表达共享所有权;裸指针只做不拥有的观察者。
总结
| 主题 | 关键结论 |
|---|
| 表示 | 指针 = 地址 + 类型;类型决定解引用方式和算术步长 |
| 空值 | 用nullptr,不要用NULL或未初始化指针 |
| const | const int*限制写值,int* const限制改指向 |
| 数组 | 数组名会退化,但数组不是指针;传参需另传长度 |
| 算术 | 仅在数组范围内有定义,one-past-the-end 不可解引用 |
| 函数指针 | 写法为返回类型 (*名字)(参数),默认参数不随类型传递 |
| 所有权 | 用unique_ptr/shared_ptr表达拥有,裸指针只做借用 |
指针的核心不是「记住星号放哪里」,而是始终清楚两件事:这个指针指向的对象是否还活着,以及谁负责释放它。把所有权交给智能指针、把借用关系写进函数签名、把边界检查交给容器,绝大多数指针相关的崩溃都可以在编译期或接口设计阶段被挡掉。