在《容器与数组简介》中,我们指出数组元素在内存中连续存放。本章将深入探讨数组下标的数学原理。
尽管后续章节不会再直接使用这些下标计算,但本节内容有助于理解范围 for 循环的实现机制,并在后续学习迭代器时再次派上用场。
何谓指针运算
指针运算允许我们对某类型的指针施加特定的整数算术运算(加、减、自增或自减),以产生新的内存地址。
给定指针 ptr,表达式 ptr + 1 返回指向内存中下一个对象的地址(步长取决于指针所指向类型)。例如,若 ptr 为 int* 且 int 占 4 字节,则 ptr + 1 为 ptr 后 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;
}
该程序正确编译并输出,且我们并未显式把数组传给函数,而是通过 begin 与 end 提供了遍历所需的全部信息。后续学习迭代器与算法时,将看到标准库大量采用“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 风格字符串》还将包含指针运算的更多测验题。
