C++ 指针详解

地址 · 解引用 · 运算 · 动态内存 · 易错点

指针基础与声明

指针的概念

指针 (Pointer) 本质上是一个变量,但它比较特殊,存储的不是普通数据,而是另一个变量在内存中的**地址**。

想象一下:你有一本书(变量),书放在图书馆的某个书架上(内存地址)。指针就像一张记录了这本书所在书架号的小纸条。通过这张纸条,你就能找到这本书。

  • * (星号): 在声明时,表示这是一个指针变量,例如 int *p;。在使用时(非声明),称为**解引用 (Dereference)** 运算符,用于访问指针所指向地址处存储的**值**。相当于“通过纸条找到书”。
  • & (和号): **取地址 (Address-of)** 运算符,用于获取一个变量的内存地址。相当于“查看书在哪个书架上,并记下书架号”。

指针声明与初始化

声明指针时,需要指定它将要指向的数据类型。格式为:类型名 *指针名;

一个典型的例子:

int score = 100;     // 定义一个整型变量 score
int *ptr_score;    // 声明一个指向整型(int)的指针 ptr_score
ptr_score = &score;  // 将 score 的地址赋给 ptr_score

// 现在 ptr_score 存储了 score 的内存地址
// *ptr_score 就等于 score 的值,即 100

可以直接在声明时初始化:

double price = 99.8;
double *p_price = &price; // 声明并初始化指针 p_price
⚠️ 易错点提示
  • 野指针 (Wild Pointer): 声明了指针但没有初始化 (int *p;),它会指向一个随机的、未知的内存地址。对野指针进行解引用 (*p) 极其危险,可能导致程序崩溃或数据损坏!
  • 初始化好习惯: 声明指针时,要么立即指向一个有效的变量地址,要么初始化为 nullptr (空指针,稍后介绍)。
  • 类型匹配: 指针类型必须与其指向的变量类型匹配(或兼容)。不能用 int * 指向 double 变量。
  • 对非变量取地址: 不能对常量值或表达式结果取地址,例如 &100&(a+b) 是错误的。
变量 (score) ?
0x1000
指针 (ptr_score) ?
0x2000
ptr_score

空指针与野指针

空指针 (nullptr)

空指针是一个特殊的指针,它明确地表示**不指向任何有效的内存地址**。在 C++11 及之后版本中,推荐使用关键字 nullptr 来表示空指针。

为什么需要空指针?

  • 作为一个安全的初始值:当你声明一个指针但暂时还不能确定它要指向哪里时,初始化为 nullptr 是个好习惯。
  • 作为函数返回失败的标记:某些函数如果执行失败,可能会返回一个空指针。
  • 作为指针不再有效的标记:当指针指向的内存被释放后,将指针设为 nullptr 可以防止它变成悬挂指针(一种野指针)。
int *p1 = nullptr; // 推荐的空指针初始化
int *p2 = NULL;    // C++11 之前的常用方式 (NULL 通常是 (void*)0 或 0)
int *p3 = 0;       // 也可以用整数 0,但不推荐,可读性差

if (p1 == nullptr) {
    std::cout << "p1 是一个空指针" << std::endl;
}

注意: 对空指针进行解引用 (*p1) 同样是未定义行为,通常会导致程序崩溃。

野指针 (Wild Pointer)

野指针是指向**无效或未知内存区域**的指针。使用野指针是 C++ 编程中最常见的错误之一,后果严重。

野指针主要有以下几种成因:

  1. 未初始化指针: 声明指针时没有赋初值,它指向内存中的某个随机位置。
  2. 悬挂指针 (Dangling Pointer): 指针指向的内存已经被释放 (delete) 或回收(例如,函数返回了局部变量的地址),但指针本身没有被置为 nullptr,它仍然指向那块“废墟”。
  3. 指针越界: 指针运算超出了其有效的内存范围(例如,访问数组外的元素)。
⚠️ 易错点与最佳实践
  • 始终初始化指针: 要么指向有效地址,要么用 nullptr
  • 使用前检查: 在解引用指针 (*p) 或访问其成员 (p->member) 之前,务必检查它是否为 nullptrif (p != nullptr) { ... } 或简写 if (p) { ... }
  • 释放后置空: 使用 deletedelete[] 释放内存后,立即将对应的指针设置为 nullptrdelete p; p = nullptr;
  • 避免返回局部变量地址: 函数内的局部变量(栈内存)在函数返回时会被销毁,返回它们的地址会导致悬挂指针。
指针 (p_null) nullptr
0x3000
野指针 (p_wild) 0x????
0x4000
p_wild

指针运算与数组

指针算术运算

指针可以进行加法和减法运算,但这与普通整数运算不同。对指针加(或减)一个整数 n,实际上是将指针的地址**移动 n 个它所指向的数据类型的大小**。

  • ptr + n: 指针向内存地址**增加**的方向移动 n * sizeof(*ptr) 个字节。
  • ptr - n: 指针向内存地址**减少**的方向移动 n * sizeof(*ptr) 个字节。
  • ptr1 - ptr2: 如果 ptr1ptr2 指向**同一个数组**中的元素,它们相减的结果是两个指针之间相隔的**元素个数**(类型为 ptrdiff_t)。
  • ptr++ (后增量): 使用 ptr 当前指向的值,然后将 ptr 向前移动一个元素的大小。
  • ++ptr (前增量): 先将 ptr 向前移动一个元素的大小,然后使用移动后的 ptr 指向的值。

关键点:指针运算的单位是它所指向的“数据块”的大小,而不是字节。

数组与指针的密切关系

在 C++ 中,数组名在大多数表达式中会被隐式转换成指向数组**第一个元素**的常量指针。

int numbers[5] = {10, 20, 30, 40, 50};
int *p = numbers; // 等价于 int *p = &numbers[0];

// 访问数组元素的方式:
std::cout << numbers[1]; // 输出 20 (数组下标方式)
std::cout << *(numbers + 1); // 输出 20 (指针运算+解引用方式)

std::cout << p[2];      // 输出 30 (指针也可以用下标访问)
std::cout << *(p + 2);  // 输出 30 (指针运算+解引用)

这意味着你可以使用指针来遍历数组:

int *ptr = numbers; // 指向数组开头
for (int i = 0; i < 5; ++i) {
    std::cout << *ptr << " "; // 输出当前元素
    ptr++; // 移动指针到下一个元素
}
// 输出: 10 20 30 40 50

注意:数组名本身是常量指针,不能被赋值(numbers = ...; 是错误的),但指向数组的指针变量 p 是可以改变指向的。

⚠️ 易错点提示
  • 指针越界 (Out of Bounds): 进行指针运算时,必须确保结果仍在有效的内存范围内(通常是数组内部)。访问数组外的内存(如 *(numbers + 5)p[-1])是未定义行为,非常危险!
  • 指针相减的前提: 只有指向**同一个数组**(或刚刚超过数组末尾的位置)的两个指针相减才有意义。
  • 不同类型指针运算: 不要对不同类型的指针进行算术运算或比较(除非强制类型转换,但通常不推荐)。
  • 数组名 vs 指针变量: 数组名是常量指针,sizeof(数组名) 得到整个数组的大小;而 sizeof(指针变量) 只得到指针本身的大小(例如 4 或 8 字节)。

动态内存分配

为什么需要动态内存?

之前我们看到的变量(如 int score;)和数组(如 int arr[10];)通常在**栈 (Stack)** 上分配内存。栈内存分配快速、自动管理,但大小在编译时就固定了,且生命周期受限于其作用域(如函数内部)。

当我们需要在程序**运行时**根据需要决定分配多少内存,或者希望内存的生命周期不局限于某个函数或代码块时,就需要使用**堆 (Heap)** 内存。动态内存分配就是在堆上申请和释放内存。

`new` 和 `delete`

C++ 使用 new 运算符在堆上分配内存,并返回指向该内存块的指针。使用 delete 运算符释放由 new 分配的内存。

  • 分配单个对象: 类型 *指针变量 = new 类型(初始值);
  • 释放单个对象: delete 指针变量;
  • 分配数组: 类型 *指针变量 = new 类型[数组大小];
  • 释放数组: delete[] 指针变量; (注意方括号!)
// 分配单个 int
int *p_num = new int(42); // 在堆上创建了一个 int,值为 42
std::cout << *p_num;    // 输出 42

// ... 使用 p_num ...

delete p_num;          // 释放内存
p_num = nullptr;       // 好习惯:置为 nullptr

// 分配包含 5 个 double 的数组
double *d_array = new double[5];
d_array[0] = 1.1;
d_array[1] = 2.2;
// ... 初始化其他元素 ...

// ... 使用 d_array ...

delete[] d_array;      // 释放整个数组 (必须用 delete[])
d_array = nullptr;     // 好习惯
⚠️ 易错点与内存管理陷阱
  • 内存泄漏 (Memory Leak): 分配了内存 (newnew[]) 但忘记释放 (deletedelete[])。程序运行时间越长,可用内存越少,最终可能导致程序崩溃或系统变慢。
  • 悬挂指针 (Dangling Pointer): 释放了内存后,没有将指针设为 nullptr,如果后续不小心再次使用该指针,就会访问无效内存。
  • 重复释放 (Double Free): 对同一块内存执行多次 deletedelete[]。这是严重的错误,行为未定义,通常导致程序崩溃。
  • `delete` 与 `delete[]` 混用: 用 delete 释放由 new[] 分配的数组,或者用 delete[] 释放由 new 分配的单个对象。这两种情况都是未定义行为!必须配对使用。
  • 忘记检查 `new` 的返回值: 虽然现代 C++ 中 new 分配失败会抛出 std::bad_alloc 异常,但在某些旧代码或特定编译选项下,可能会返回 nullptr。需要注意处理内存分配失败的情况。

建议: 优先使用 C++ 标准库提供的容器(如 std::vector, std::string)和智能指针(如 std::unique_ptr, std::shared_ptr),它们能自动管理内存,大大减少手动管理内存带来的风险。

堆内存区域 (Heap) - 当前为空

高级指针:指向指针 & 函数指针

指向指针的指针 (Pointer to Pointer)

既然指针本身也是一个存储地址的变量,那么它自己也占用内存空间,也有自己的地址。我们可以定义一个指针,让它指向另一个指针的地址,这就是**指向指针的指针**,也常被称为**二级指针**。

声明方式:在基本类型后加两个星号 *

int value = 42;
int *p_value = &value;    // p_value 是 int* 类型, 指向 value (一级指针)

int **pp_value = &p_value; // pp_value 是 int** 类型, 指向 p_value (二级指针)

// 访问原始值 value:
std::cout << value;        // 直接访问: 42
std::cout << *p_value;     // 通过一级指针解引用: 42
std::cout << **pp_value;   // 通过二级指针解引用两次: 42

// 修改原始值 value:
*p_value = 100;          // 通过一级指针修改
**pp_value = 200;        // 通过二级指针修改

// 修改一级指针 p_value 的指向:
int another_value = 99;
*pp_value = &another_value; // 现在 p_value 指向 another_value 了
                            // *pp_value 等价于 p_value

二级(或多级)指针常用于:

  • 在函数内部修改外部指针变量的指向(通过传递指针的地址)。
  • 管理指针数组(数组的元素是指针)。
  • 动态分配二维数组的某些实现方式。

函数指针 (Function Pointer)

函数在内存中也有地址。函数指针就是存储函数入口地址的指针变量。通过函数指针,我们可以像调用普通函数一样调用它所指向的函数。

声明方式:返回类型 (*指针名)(参数类型列表);

关键:指针名两边的括号 (*) 非常重要,不能省略!

// 假设有两个函数:
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }

// 声明一个函数指针 func_ptr,它可以指向任何接受两个 int 参数并返回 int 的函数
int (*func_ptr)(int, int);

// 将 add 函数的地址赋给 func_ptr (函数名就是地址)
func_ptr = add;
// 调用 add 函数通过 func_ptr
int result1 = func_ptr(5, 3); // result1 = 8

// 将 subtract 函数的地址赋给 func_ptr
func_ptr = subtract;
// 调用 subtract 函数通过 func_ptr
int result2 = func_ptr(5, 3); // result2 = 2

// 也可以直接用函数名初始化
int (*op)(int, int) = add;
int result3 = op(10, 5); // result3 = 15

函数指针常用于:

  • 实现回调函数 (Callback)。
  • 实现策略模式或状态模式等设计模式。
  • 构建函数表,根据条件动态选择执行哪个函数。
⚠️ 易错点提示
  • 二级指针解引用混淆: 记住 *pp 得到的是一级指针 p,而 **pp 才是最终指向的值。
  • 函数指针签名匹配: 函数指针的**返回类型**和**所有参数的类型、个数、顺序**必须与它要指向的函数**完全一致**,否则编译错误或运行时错误。
  • 函数指针声明括号: int *func(int, int); 声明的是一个名为 `func` 的**函数**,该函数返回 int* 指针。而 int (*func)(int, int); 才是声明一个名为 `func` 的**指针**,指向返回 `int` 的函数。括号必不可少!
value 42
0xA000
p_value (int*) 0xA000
0xB000
p_value
pp_value (int**) 0xB000
0xC000
pp_value

const 与指针的组合

关键字 const 用于表示常量,当它与指针结合时,情况会变得稍微复杂,因为它既可以修饰指针本身,也可以修饰指针所指向的数据。理解它们的区别对于编写安全、意图明确的代码至关重要。

三种主要组合

主要有以下三种形式,关键在于 const 的位置:

  1. 指向常量的指针 (Pointer to Constant)
    const int *p;int const *p; (两种写法等价)
    含义: 指针 p 所指向的**值**是常量,不能通过指针 p 来修改它。但是,指针 p 本身**可以**改变指向,去指向另一个(可能是 const 或非 const 的)int 变量。
    记忆: const* 左边(或紧挨着类型名),限制的是 *p (解引用后的值)。
    int x = 10;
    int y = 20;
    const int *p = &x; // p 指向 x
    // *p = 15; // 错误!不能通过 p 修改 x 的值
    p = &y;    // 正确!p 可以指向其他变量
    
  2. 常量指针 (Constant Pointer)
    int * const p;
    含义: 指针 p 本身是常量,它**必须**在声明时初始化,并且之后**不能**再指向其他内存地址。但是,它所指向的**值可以**通过指针 p 来修改(只要指向的不是 const 变量)。
    记忆: const* 右边,限制的是 p (指针本身)。
    int x = 10;
    int y = 20;
    int * const p = &x; // p 必须初始化,且永远指向 x
    *p = 15;           // 正确!可以通过 p 修改 x 的值 (现在 x = 15)
    // p = &y;          // 错误!p 不能改变指向
    
  3. 指向常量的常量指针 (Constant Pointer to Constant)
    const int * const p;int const * const p;
    含义: 指针 p 本身是常量,**不能**改变指向;同时,它所指向的值也是常量,**不能**通过指针 p 修改。
    记忆: * 两边都有 const,指针和指向的值都被限制。
    int x = 10;
    const int * const p = &x; // p 必须初始化,永远指向 x,且不能通过 p 修改 x
    // *p = 15; // 错误!不能通过 p 修改值
    // p = &y;  // 错误!p 不能改变指向
    

快速区分小结表

声明方式 指针指向可变? (p = ...) 指向的值可变? (*p = ...)
int *p ✅ 可以 ✅ 可以
const int *p ✅ 可以 ❌ 不可以
int * const p ❌ 不可以 ✅ 可以
const int * const p ❌ 不可以 ❌ 不可以
⚠️ 易错点与应用场景
  • 阅读顺序: 从右往左读有助于理解。例如 int * const p 读作 "p is a constant pointer to int" (p 是一个常量指针,指向 int)。const int * p 读作 "p is a pointer to const int" (p 是一个指针,指向 const int)。
  • 函数参数: 在函数参数中使用 `const` 指针非常常见:
    • `void print(const int *p)`: 告诉调用者,函数 `print` 不会修改 `p` 指向的值,可以安全地传递常量或变量的地址。
    • `void process(int * const p)`: 表明函数 `process` 可能会修改 `p` 指向的值,但保证不会让 `p` 指向别处(虽然这种用法相对少见)。
  • 类型转换: 将非 `const` 指针赋值给 `const` 指针通常是安全的(增加限制),但反过来(去掉 `const` 限制)通常需要显式类型转换 (const_cast),且这样做可能很危险,应谨慎使用。

巩固练习:选择题与编程

选择题 (共 5 题)

C++ 指针总结

  • 核心概念: 指针是存储内存地址的变量。使用 & 获取地址,使用 * 解引用访问值。
  • 关键类型: nullptr 表示空指针,是安全的标记。野指针(未初始化、悬挂)是危险的根源。
  • 指针运算: 加减运算以所指类型大小为单位,常用于数组遍历。务必警惕越界访问。
  • 动态内存: 使用 new/deletenew[]/delete[] 在堆上分配/释放内存。必须配对使用,谨防内存泄漏和重复释放。
  • 高级指针: 理解二级指针 **p 的解引用层级。掌握函数指针 (*fptr)() 的声明与调用。
  • `const` 结合: 区分 const T* p (指向常量)、T* const p (常量指针) 和 const T* const p (都常量)。
💡 学习与调试建议
  • 多实践: 亲手编写、编译、运行涉及指针的代码,观察结果和错误。
  • 使用调试器 (Debugger): 学会使用 GDB、Visual Studio Debugger 等工具,单步执行代码,观察指针变量的值(地址)和它指向的内存内容。这是理解指针行为的最佳方式。
  • 画内存图: 对于复杂的指针操作,手动绘制内存布局图有助于理清指针的指向关系。
  • 拥抱现代 C++: 尽可能使用 std::vector, std::string, std::unique_ptr, std::shared_ptr 等来避免手动内存管理的陷阱。
  • 编译警告: 开启编译器的警告选项 (如 `-Wall -Wextra` in g++),它们能帮助发现潜在的指针问题。