指针运算与下标访问

在《容器与数组简介》中,我们指出数组元素在内存中连续存放。本章将深入探讨数组下标的数学原理。

尽管后续章节不会再直接使用这些下标计算,但本节内容有助于理解范围 for 循环的实现机制,并在后续学习迭代器时再次派上用场。

何谓指针运算

指针运算允许我们对某类型的指针施加特定的整数算术运算(加、减、自增或自减),以产生新的内存地址。

给定指针 ptr,表达式 ptr + 1 返回指向内存中下一个对象的地址(步长取决于指针所指向类型)。例如,若 ptrint*int 占 4 字节,则 ptr + 1ptr 后 4 字节处的地址,ptr + 2 为后 8 字节处的地址。

#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x }; // 假设 int 占 4 字节

    std::cout << ptr << ' ' << (ptr + 1) << ' ' << (ptr + 2) << '\n';

    return 0;
}

在作者机器上输出:

00AFFD80 00AFFD84 00AFFD88

可见相邻地址相差 4 字节。

指针运算亦支持减法。ptr - 1 返回前一个对象的地址,步长同理。

#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x }; // 假设 int 占 4 字节

    std::cout << ptr << ' ' << (ptr - 1) << ' ' << (ptr - 2) << '\n';

    return 0;
}

输出:

00AFFD80 00AFFD7C 00AFFD78

每步递减 4 字节。

关键洞察
指针运算返回的是“下一个/上一个对象”的地址,而非简单的“下一个/上一个字节地址”。

若对指针使用前置自增 ++ 或自减 --,其效果与指针加减 1 相同,但会修改指针自身存储的地址。

类似于整型变量:

int x;
++x;  // 等价于 x = x + 1

对于指针:

int* ptr;
++ptr; // 等价于 ptr = ptr + 1
#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x };

    std::cout << ptr << '\n';
    ++ptr;
    std::cout << ptr << '\n';
    --ptr;
    std::cout << ptr << '\n';

    return 0;
}

输出:

00AFFD80  
00AFFD84  
00AFFD80

警告
严格来说,上述示例属于未定义行为。C++ 标准规定,指针运算只有当指针本身及其结果均位于同一数组(或指向数组尾后一位)时才为良定义。不过,现代实现通常不会强制检查,允许在数组外进行运算。

下标运算通过指针运算实现

上一章《C 风格数组退化》提到可对指针使用 operator[]

#include <iostream>

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };

    const int* ptr{ arr }; // 指向元素 0 的普通指针
    std::cout << ptr[2];   // 输出 5

    return 0;
}

深入解析:
下标表达式 ptr[n]*((ptr) + (n)) 的简写形式。编译器先做指针运算,再隐式解引用。

  • 初始化 ptr 时,arr 退化为首元素地址。
  • ptr[2] 等价于 *(ptr + 2)
    ptr + 2 得到首元素后 2 个对象的地址;
    – 解引用后取得数组下标 2 的元素。

再举一例:

#include <iostream>

int main()
{
    const int arr[] { 3, 2, 1 };

    std::cout << &arr[0] << ' ' << &arr[1] << ' ' << &arr[2] << '\n';
    std::cout << arr[0] << ' ' << arr[1] << ' ' << arr[2] << '\n';

    std::cout << arr << ' ' << (arr + 1) << ' ' << (arr + 2) << '\n';
    std::cout << *arr << ' ' << *(arr + 1) << ' ' << *(arr + 2) << '\n';

    return 0;
}

输出:

00AFFD80 00AFFD84 00AFFD88
3 2 1
00AFFD80 00AFFD84 00AFFD88
3 2 1

由于数组元素在内存中顺序存放,若 arr 指向元素 0,则 *(arr + n) 即为数组第 n 个元素。

这也是数组采用 0 基下标而非 1 基下标的主因:运算更高效,无需每次下标时额外减 1。

附注
由于 ptr[n] 被翻译为 *((ptr) + (n)),因此甚至可以写成 n[ptr]!编译器会将其变为 *((n) + (ptr)),与前者行为一致,但请勿如此书写,因其可读性极差。

指针运算与下标均表示相对位置

初学者常误以为下标表示数组中的固定元素:0 永远是首元素,1 永远是次元素……
这是一种错觉。下标实则表示相对位置。它们看似固定,是因为我们通常从元素 0 开始索引。

给定指针 ptr*(ptr + 1)ptr[1] 均返回“下一个对象”。若 ptr 指向元素 0,则二者都指向元素 1;若 ptr 指向元素 3,则二者指向元素 4。

#include <array>
#include <iostream>

int main()
{
    const int arr[] { 9, 8, 7, 6, 5 };
    const int *ptr { arr }; // 指向元素 0

    std::cout << *ptr << ptr[0] << '\n';      // 输出 99
    std::cout << *(ptr+1) << ptr[1] << '\n';  // 输出 88

    ptr = &arr[3];                            // 指向元素 3
    std::cout << *ptr << ptr[0] << '\n';      // 输出 66
    std::cout << *(ptr+1) << ptr[1] << '\n';  // 输出 55

    return 0;
}

为避免混淆,建议仅在从数组首元素(元素 0)开始索引时使用下标;若需相对定位,则使用指针运算。

最佳实践

  • 以元素 0 为基准时,优先使用下标,使下标与元素序号一致。
  • 需相对定位时,使用指针运算。

负下标

上一章曾提及,与标准库容器不同,C 风格数组的下标可为有符号或无符号整数。这不仅出于便利,更因为负下标的确可行。

已知 *(ptr + 1) 指向下一对象,那么上一对象对应的下标形式为何?正是 ptr[-1]

#include <array>
#include <iostream>

int main()
{
    const int arr[] { 9, 8, 7, 6, 5 };

    const int* ptr { &arr[3] }; // 指向元素 3

    std::cout << *ptr << ptr[0] << '\n';  // 输出 66
    std::cout << *(ptr-1) << ptr[-1] << '\n'; // 输出 77

    return 0;
}

使用指针运算遍历数组

指针运算最常见的用途之一是无显式索引地遍历 C 风格数组:

#include <iostream>

int main()
{
    constexpr int arr[]{ 9, 7, 5, 3, 1 };

    const int* begin{ arr };                // begin 指向首元素
    const int* end{ arr + std::size(arr) }; // end 指向尾后一位

    for (; begin != end; ++begin)           // 从 begin 到 end(不含)遍历
    {
        std::cout << *begin << ' ';         // 解引用获取当前元素
    }

    return 0;
}

输出:

9 7 5 3 1
  • end 设为“尾后一位”地址,虽不可解引用,但便于边界判断。
  • 只要指针运算结果位于数组元素或尾后一位内,即为良定义;否则导致未定义行为。

在上一章中,我们指出数组退化使函数重构困难,因为某些操作对非退化数组有效,对退化数组则不然。然而,上述遍历方式可原封不动地提取为独立函数,且仍能正常工作:

#include <iostream>

void printArray(const int* begin, const int* end)
{
    for (; begin != end; ++begin)
    {
        std::cout << *begin << ' ';
    }
    std::cout << '\n';
}

int main()
{
    constexpr int arr[]{ 9, 7, 5, 3, 1 };

    const int* begin{ arr };
    const int* end{ arr + std::size(arr) };

    printArray(begin, end);
    return 0;
}

该程序正确编译并输出,且我们并未显式把数组传给函数,而是通过 beginend 提供了遍历所需的全部信息。后续学习迭代器与算法时,将看到标准库大量采用“begin/end”对来界定操作范围。

范围 for 循环基于指针运算实现

考虑以下范围 for 循环:

#include <iostream>

int main()
{
    constexpr int arr[]{ 9, 7, 5, 3, 1 };

    for (auto e : arr)
    {
        std::cout << e << ' ';
    }
    return 0;
}

根据文档,范围 for 循环大致展开如下:

{
    auto __begin = begin-expr;
    auto __end = end-expr;

    for ( ; __begin != __end; ++__begin)
    {
        range-declaration = *__begin;
        loop-statement;
    }
}

将示例手动展开:

#include <iostream>

int main()
{
    constexpr int arr[]{ 9, 7, 5, 3, 1 };

    auto __begin = arr;                    // begin-expr 为 arr
    auto __end = arr + std::size(arr);     // end-expr 为 arr + size

    for ( ; __begin != __end; ++__begin)
    {
        auto e = *__begin;     // range-declaration
        std::cout << e << ' '; // loop-statement
    }

    return 0;
}

与前例几乎一致,区别仅在于将 *__begin 赋给 e 后再使用。


测验

问题 1
a) 为何 arr[0]*arr 等价?
[显示答案]

相关内容
下一章《C 风格字符串》还将包含指针运算的更多测验题。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"