Skip to content

指针运算与数组

如果你已经理解了"指针就是地址"这件事,那么接下来我们要面对一个更深层的真相——在 C++ 里,指针和数组在最底层的本质上几乎是同一枚硬币的两面。(笔者很不建议混淆指针和数组的概念,因为这只会在工程逻辑上害人)

这一章我们把指针算术运算、数组到指针的退化、C 风格字符串的指针操作全部串起来。如果你之前总觉得数组和指针之间"好像有关系但又说不清楚",今天我们就把这个结彻底解开。

学习目标 完成本章后,你将能够:

  • [ ] 理解数组到指针退化的机制和触发条件
  • [ ] 掌握指针加减运算的实际字节数与元素数的关系
  • [ ] 用指针遍历数组和 C 风格字符串
  • [ ] 理解 [] 运算符本质是指针算术的语法糖

环境说明

我们接下来的所有实验都在这个环境下进行:

  • 平台:Linux x86_64(WSL2 也可以)
  • 编译器:GCC 13+ 或 Clang 17+
  • 编译选项:-Wall -Wextra -std=c++17

数组名不是指针——但绝大多数时候它装得像

我们先从一个最经典的操作开始。声明一个数组,然后把它的名字赋给一个指针:

cpp
#include <iostream>

int main()
{
    int arr[5] = {10, 20, 30, 40, 50};
    int* p = arr;  // 合法!数组名可以直接赋给指针

    std::cout << "arr 的地址:  " << arr << "\n";
    std::cout << "p 的值:      " << p << "\n";
    std::cout << "arr[0] 的地址: " << &arr[0] << "\n";
    std::cout << "*p:          " << *p << "\n";

    return 0;
}

运行结果:

text
arr 的地址:  0x7ffd3a2b1c00
p 的值:      0x7ffd3a2b1c00
arr[0] 的地址: 0x7ffd3a2b1c00
*p:          10

三个地址完全一样。这就引出了 C++ 里一个极其重要的概念——数组到指针退化(array-to-pointer decay)。当你写下 arr 这个名字的时候,编译器在绝大多数上下文里不会把它当成"整个数组",而是把它当成"指向数组第一个元素的指针",也就是 &arr[0]

所以说"数组名就是指针"这句话严格来说是错的。arr 的类型是 int[5],它是一个完整的数组类型,包含了 5 个 int,占 20 字节。但一旦你把它用在需要指针的语境里(比如赋值给 int*、传给函数、做算术运算),编译器就会自动把它退化成 int*。这个退化的过程是不可逆的——退化了就回不去了,你丢失了数组长度信息。

说了"绝大多数上下文",那什么时候不退化呢?只有三种情况:sizeof(arr) 返回整个数组的大小,&arr 得到的是"指向数组的指针"(类型是 int(*)[5] 而非 int*),以及用字符串字面量初始化字符数组时。除此之外,数组名一律退化。

指针加减运算——按元素步进,不是按字节

指针最强大的能力之一就是算术运算。但这里的运算规则和我们平常理解的不太一样——指针加 1 不是移动 1 个字节,而是移动一个所指向类型的大小

指针加法的实际效果

我们直接用代码来看,对比 int*char* 的步进:

cpp
#include <iostream>

int main()
{
    int numbers[4] = {100, 200, 300, 400};
    char chars[4]  = {'A', 'B', 'C', 'D'};

    int* pi = numbers;
    char* pc = chars;

    std::cout << "=== int* 步进 ===\n";
    std::cout << "pi:     " << pi << " -> *pi = " << *pi << "\n";
    std::cout << "pi + 1: " << (pi + 1) << " -> *(pi+1) = " << *(pi + 1) << "\n";
    std::cout << "pi + 2: " << (pi + 2) << " -> *(pi+2) = " << *(pi + 2) << "\n";

    std::cout << "\n=== char* 步进 ===\n";
    std::cout << "pc:     " << static_cast<void*>(pc)
              << " -> *pc = " << *pc << "\n";
    std::cout << "pc + 1: " << static_cast<void*>(pc + 1)
              << " -> *(pc+1) = " << *(pc + 1) << "\n";
    std::cout << "pc + 2: " << static_cast<void*>(pc + 2)
              << " -> *(pc+2) = " << *(pc + 2) << "\n";

    return 0;
}

运行结果:

text
=== int* 步进 ===
pi:     0x7ffd4e3a1c00 -> *pi = 100
pi + 1: 0x7ffd4e3a1c04 -> *(pi+1) = 200
pi + 2: 0x7ffd4e3a1c08 -> *(pi+2) = 300

=== char* 步进 ===
pc:     0x7ffd4e3a1bf0 -> *pc = A
pc + 1: 0x7ffd4e3a1bf1 -> *(pc+1) = B
pc + 2: 0x7ffd4e3a1bf2 -> *(pc+2) = C

注意看地址的差值。int* 每加 1 地址增加了 4(从 ...c00...c04),而 char* 每加 1 地址只增加了 1(从 ...bf0...bf1)。这就是指针算术的核心规则:p + n 实际移动了 n * sizeof(*p) 个字节。编译器会自动根据指针指向的类型来计算实际的字节偏移量,所以你不需要手动乘以 sizeof

char* 的输出我们用了 static_cast<void*> 来强制以十六进制打印地址,原因是 std::ostreamchar* 有特殊处理——它会当它是 C 字符串然后一直打印到遇到 '\0' 为止,这个坑等下还会再遇到。

指针减法——计算元素距离

两个指向同一个数组的指针可以做减法,结果是它们之间相隔多少个元素(不是字节数):

cpp
int arr[5] = {10, 20, 30, 40, 50};
int* p1 = &arr[1];  // 指向 20
int* p2 = &arr[4];  // 指向 50

std::cout << "p2 - p1 = " << (p2 - p1) << "\n";  // 3

p2 - p1 的结果是 3,因为从 arr[1]arr[4] 中间隔了 3 个元素。这个特性在很多算法里都非常有用——比如计算某个元素在数组中的下标,你只需要 ptr - arr 就能得到。

指针减法只能对**指向同一个数组(或同一块连续内存)**的两个指针进行。如果你拿两个毫无关系的指针做减法,结果是未定义行为,编译器连警告都不一定会给。

用指针遍历数组

既然 arr + i 就等于 &arr[i],那我们完全可以用指针从头走到尾来遍历数组,而不需要下标:

cpp
#include <iostream>

int main()
{
    int arr[5] = {10, 20, 30, 40, 50};

    // 指针遍历
    std::cout << "指针遍历: ";
    for (int* p = arr; p != arr + 5; ++p) {
        std::cout << *p << " ";
    }
    std::cout << "\n";

    // 下标遍历
    std::cout << "下标遍历: ";
    for (int i = 0; i < 5; ++i) {
        std::cout << arr[i] << " ";
    }
    std::cout << "\n";

    // range-for 遍历
    std::cout << "range-for: ";
    for (int x : arr) {
        std::cout << x << " ";
    }
    std::cout << "\n";

    return 0;
}

运行结果:

text
指针遍历: 10 20 30 40 50
下标遍历: 10 20 30 40 50
range-for: 10 20 30 40 50

三种写法的结果完全一样。那么问题来了——该用哪种?

说实话,在日常开发中,优先用 range-for。它最简洁、最不容易出错,而且编译器优化后性能和指针遍历完全一致。指针遍历的优势在于那些需要更精细控制的场景——比如你只需要遍历数组的一部分(从某个符合条件的元素开始),或者你需要同时操作多个位置。但如果你只是要把整个数组过一遍,range-for 就是最好的选择。

这里有个非常常见的坑:arr + 5 这个"尾后指针"是合法的,你可以用它做比较,但绝对不能解引用它*(arr + 5) 是未定义行为,因为它指向的位置已经超出了数组的范围。C++ 标准只允许你计算这个地址,不允许你读取或写入它指向的内容。这和标准库容器的 end() 迭代器是同一个思路——它标记的是"最后元素的下一个位置",本身不是有效元素。

指针与 C 风格字符串

C 风格字符串本质上就是一个以 '\0'(空字符)结尾的 char 数组。既然是数组,那所有关于指针和数组的关系在这里全都适用。我们在 C++ 代码里写下的 "hello" 这样的字符串字面量,类型是 const char[6](5 个字符加 1 个 '\0'),在大多数上下文里会退化成 const char*

cpp
#include <iostream>

int main()
{
    const char* s = "hello";

    std::cout << "字符串: " << s << "\n";
    std::cout << "首字符: " << *s << "\n";
    std::cout << "第3个字符: " << s[2] << "\n";

    // 手动计算字符串长度——模拟 strlen
    std::size_t len = 0;
    while (s[len] != '\0') {
        ++len;
    }
    std::cout << "长度: " << len << "\n";

    return 0;
}

运行结果:

text
字符串: hello
首字符: h
第3个字符: l
长度: 5

现在我们用纯指针的方式来重写这个长度计算,也就是不使用任何下标:

cpp
const char* str_len_demo(const char* s)
{
    const char* start = s;
    while (*s != '\0') {
        ++s;
    }
    std::cout << "长度 = " << (s - start) << "\n";
    return s;
}

这个模式在 C 标准库的实现里无处不在。strlenstrcpystrchr 这些函数的底层都是类似的指针遍历——从头开始一个字符一个字符地走,直到碰到 '\0' 为止。s - start 利用了我们前面讲的指针减法,直接得到中间跨越了多少个元素。

这里又是一个非常经典的踩坑点:const char* s = "hello";s 指向了一个字符串字面量。字符串字面量存储在程序的只读数据段中,你绝对不能通过这个指针去修改内容s[0] = 'H'; 会导致未定义行为——在大多数系统上会直接触发段错误(segmentation fault)。如果你需要一个可修改的字符串,请用字符数组 char s[] = "hello";,这样会把内容拷贝到栈上的数组里,修改就安全了。

下标运算符的本质

现在我们已经有了足够的铺垫来揭开一个真相:[] 运算符本质上就是指针算术的语法糖

当编译器看到 arr[n] 的时候,它实际做的事情是 *(arr + n)。先对指针 arr 加上偏移量 n,然后解引用。因为数组名在表达式中会退化成指针,所以整个过程完全是指针操作。这也解释了为什么数组传给函数之后,函数内部就丢失了数组长度——函数拿到的只是一个指针,sizeof 只能得到指针本身的大小,而不是原来的数组大小。

既然 arr[n] 就是 *(arr + n),而加法满足交换律,那 n[arr] 也就是 *(n + arr)——完全等价。是的,5[arr] 这种写法是合法的,和 arr[5] 的效果完全一样。

cpp
int arr[5] = {10, 20, 30, 40, 50};

std::cout << arr[3] << "\n";  // 40
std::cout << 3[arr] << "\n";  // 也是 40——但这纯粹是 trivia,别在实际代码里这么写

我们提这个冷知识不是为了让你在代码里耍花枪,而是为了加深理解:下标从来不是什么魔法,它就是指针加法加上解引用。当你真正明白了这一点,很多之前觉得奇怪的现象就都解释得通了——比如为什么数组传参后 sizeof 不对、为什么负数下标在某些场景下是合法的(p[-1] 就是 *(p - 1),只要你保证 p - 1 指向有效内存)。

多维数组与指针——点到为止

多维数组是指针和数组关系里最容易让人头疼的部分。我们先给出一个简单的示例,点到为止,不做深入展开:

cpp
int matrix[3][4] = {
    {1,  2,  3,  4},
    {5,  6,  7,  8},
    {9, 10, 11, 12}
};

int (*row_ptr)[4] = matrix;  // 指向"含4个int的数组"的指针

std::cout << row_ptr[1][2] << "\n";  // 7

matrix 的类型是 int[3][4],退化后变成指向第一行的指针,类型是 int(*)[4]——"指向含有 4 个 int 的数组的指针"。注意这个 (*row_ptr) 的括号是必须的,因为 [] 的优先级比 * 高,int* row_ptr[4] 声明的是"含有 4 个 int* 的数组",完全不是一回事。

多维数组的指针关系确实比较绕,如果你现在觉得有点晕也没关系——实际项目中直接用裸指针操作多维数组的场景并不多,后面学到 std::arraystd::span 以后会有更安全的方式来处理这类问题。

实战:综合演示 ptr_arith.cpp

我们把前面讲的内容整合到一个完整的程序里,涵盖指针遍历、指针减法求距离、以及用指针操作 C 字符串:

cpp
#include <cstddef>
#include <iostream>

int main()
{
    // --- 1. 多种方式遍历数组 ---
    int data[6] = {5, 12, 7, 23, 18, 9};

    std::cout << "=== 指针遍历 ===\n";
    for (int* p = data; p != data + 6; ++p) {
        std::cout << *p << " ";
    }
    std::cout << "\n";

    // --- 2. 指针减法计算元素距离 ---
    int* first = &data[0];
    int* last  = &data[5];
    std::cout << "\n=== 指针距离 ===\n";
    std::cout << "first 和 last 之间隔了 "
              << (last - first) << " 个元素\n";

    // 用指针减法找到某个值的下标
    int target = 23;
    for (int* p = data; p != data + 6; ++p) {
        if (*p == target) {
            std::cout << "值 " << target << " 的下标是: "
                      << (p - data) << "\n";
            break;
        }
    }

    // --- 3. 用指针实现 strlen ---
    const char* msg = "pointer";
    const char* scan = msg;
    while (*scan != '\0') {
        ++scan;
    }
    std::cout << "\n=== 手写 strlen ===\n";
    std::cout << "\"" << msg << "\" 的长度: "
              << (scan - msg) << "\n";

    // --- 4. 用指针反转数组 ---
    std::cout << "\n=== 反转数组 ===\n";
    std::cout << "反转前: ";
    for (int x : data) {
        std::cout << x << " ";
    }
    std::cout << "\n";

    int* left  = data;
    int* right = data + 5;
    while (left < right) {
        int temp = *left;
        *left  = *right;
        *right = temp;
        ++left;
        --right;
    }

    std::cout << "反转后: ";
    for (int x : data) {
        std::cout << x << " ";
    }
    std::cout << "\n";

    return 0;
}

编译运行:

bash
g++ -Wall -Wextra -std=c++17 ptr_arith.cpp -o ptr_arith && ./ptr_arith

运行结果:

text
=== 指针遍历 ===
5 12 7 23 18 9

=== 指针距离 ===
first 和 last 之间隔了 5 个元素
值 23 的下标是: 3

=== 手写 strlen ===
"pointer" 的长度: 7

=== 反转数组 ===
反转前: 5 12 7 23 18 9
反转后: 9 18 23 7 12 5

这个程序把本章的核心知识点全部串起来了:指针遍历、指针减法算距离、C 字符串的指针扫描、以及用双指针技术原地反转数组。其中反转数组的"双指针"技巧——一头一尾两个指针往中间走、边走边交换——是面试和算法题中的常客。

小结

我们来回顾一下这一章的核心要点:

  • 数组名在绝大多数表达式中会退化为指向首元素的指针,退化后丢失长度信息
  • 指针加减运算按所指向类型的大小步进,p + 1 实际移动 sizeof(*p) 字节
  • 两个指向同一数组的指针可以做减法,结果是元素间隔数
  • [] 运算符本质是 *(p + n) 的语法糖,这也解释了数组传参后 sizeof 失效
  • C 风格字符串是 '\0' 结尾的 char 数组,指针遍历到 '\0' 即为字符串末尾
  • 日常遍历数组优先使用 range-for,指针遍历用于需要精细控制的场景

常见错误

错误原因解决方法
sizeof(arr) 在函数内返回指针大小数组退化,函数参数实际是指针另传长度参数,或使用 std::array/std::span
解引用尾后指针 *(arr + len)尾后指针只用于比较,不可访问遍历条件用 != 而非 <=,且不解引用
修改字符串字面量 s[0] = 'H'字面量在只读段,写入触发段错误char s[] 拷贝到栈上再修改
对无关指针做减法两个指针必须指向同一块内存始终确保参与运算的指针属于同一数组

练习

练习一:手写 strlen

不用任何标准库函数,纯指针实现字符串长度计算。要求函数签名为 std::size_t my_strlen(const char* s)

验证方法:对比 my_strlen("hello world")std::strlen("hello world") 的结果是否一致。

练习二:双指针反转数组

我们已经在上面的实战代码中演示了双指针反转。现在尝试把它封装成一个函数 void reverse_array(int* begin, int* end),其中 end 是尾后指针。注意:函数内部不需要知道数组长度,只靠两个指针就能完成反转。

练习三:指针实现字符串比较

实现 int my_strcmp(const char* a, const char* b):逐字符比较,如果完全相同返回 0,如果 a 的第一个不同字符小于 b 中对应字符则返回负数,否则返回正数。这是一个稍难的练习,需要同时遍历两个字符串并判断结束条件。


下一站:指针是强大的,但也是危险的。接下来我们认识"引用"——C++ 提供的一种更安全的替代方案,在很多场景下可以取代裸指针,让代码既安全又清晰。

基于 VitePress 构建