17-9 指针运算与下标操作

在第16.1节——容器与数组简介中,我们提到数组在内存中按顺序存储。本节课我们将深入探讨数组索引运算的原理。

尽管后续课程不会直接使用索引运算,但本节内容将帮助你理解基于范围的for循环的实际运作机制,并在后续讲解迭代器时再次派上用场。


什么是指针运算?

指针运算Pointer arithmetic是一种特性,它允许我们对指针应用特定的整数运算符(加法、减法、递增或递减),从而生成新的内存地址。

给定某个指针 ptr,ptr + 1 将返回内存中下一个对象的地址(取决于被指向的数据类型)。因此若 ptr 是 int* 类型,且 int 占用 4 字节,则 ptr + 1 将返回 ptr 之后 4 字节处的内存地址,而 ptr + 2 将返回 ptr 之后 8 字节处的内存地址。

#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x }; // assume 4 byte ints

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

    return 0;
}

在作者的机器上,这段代码输出如下:

image

请注意,每个内存地址都比前一个地址大4字节。

虽然较少使用,但指针运算同样支持减法。对于某个指针ptr,ptr - 1将返回内存中前一个对象的地址(基于被指向的类型)。

#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x }; // assume 4 byte ints

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

    return 0;
}

在作者的机器上,这段代码输出如下:

image

在此情况下,每个内存地址都比前一个少4字节。

关键要点:
指针运算返回的是下一个/上一个对象的地址(取决于被指向的类型),而非下一个/上一个地址本身。

对指针应用自增(++)和自减(--)运算符,分别相当于指针加法和指针减法,但实际上会修改指针所指向的地址。

对于某个整型值 x,++x 是 x = x + 1 的简写形式。同理,对于某个指针 ptr,++ptr 是 ptr = ptr + 1 的简写形式,该操作执行指针运算并将结果赋值回 ptr。

#include <iostream>

int main()
{
    int x {};
    const int* ptr{ &x }; // assume 4 byte ints

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

    ++ptr; // ptr = ptr + 1
    std::cout << ptr << '\n';

    --ptr; // ptr = ptr - 1
    std::cout << ptr << '\n';

    return 0;
}

在作者的机器上,这段代码输出如下:

image

警告:
严格来说,上述操作属于未定义行为。根据C++标准,只有当指针与运算结果位于同一数组内(或数组末尾下一个位置)时,指针运算才属于定义行为。然而,现代C++实现通常不会强制执行此规则,通常不会禁止你在数组外部使用指针运算。


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

在前一节(17.8 -- C 风格数组衰变)中,我们提到可以将[]运算符应用于指针:

#include <iostream>

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

    const int* ptr{ arr }; // a normal pointer holding the address of element 0
    std::cout << ptr[2];   // subscript ptr to get element 2, prints 5

    return 0;
}

image

让我们深入剖析这里发生的情况。

事实证明,下标运算符 ptr[n] 是一种简洁的语法,等同于更冗长的表达式 *((ptr) + (n))。你会注意到这本质上是指针运算,通过添加额外的括号确保按正确顺序求值,并隐式解引用以获取该地址处的对象。

首先,我们用 arr 初始化 ptr。当 arr 用作初始化器时,它会衰变为指向索引为 0 的元素地址的指针。因此 ptr 现在持有元素 0 的地址。

接着输出 ptr[2]。该操作等同于 *((ptr) + (2)),即 *(ptr + 2)。ptr + 2 返回 ptr 之后两个对象的位置地址,即索引为 2 的元素。该地址处的对象随后被返回给调用方。

让我们再看一个示例:

#include <iostream>

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

    // First, let's use subscripting to get the address and values of our array elements
    std::cout << &arr[0] << ' ' << &arr[1] << ' ' << &arr[2] << '\n';
    std::cout << arr[0] << ' ' << arr[1] << ' ' << arr[2] << '\n';

    // Now let's do the equivalent using pointer arithmetic
    std::cout << arr<< ' ' << (arr+ 1) << ' ' << (arr+ 2) << '\n';
    std::cout << *arr<< ' ' << *(arr+ 1) << ' ' << *(arr+ 2) << '\n';

    return 0;
}

在作者的机器上,这段代码输出如下:

image

你会注意到,数组 arr 当前指向地址 0x7ba9ad5000e0,(arr + 1) 指向后 4 字节处的地址,而 (arr + 2) 指向后 8 字节处的地址。我们可以解引用这些地址来获取对应位置的数组元素。

由于数组元素在内存中总是连续存储,若 arr 是指向数组第 0 个元素的指针,则 *(arr + n) 将返回数组中的第 n 个元素。

这正是数组采用 0 基索引而非 1 基索引的主要原因——它能提升运算效率(因为编译器在索引运算时无需每次都减去 1)!

顺带一提...:
有趣的是,由于编译器在对指针进行下标操作时会将 ptr[n] 转换为 *((ptr) + (n)),这意味着我们也可以用 n[ptr] 对指针进行下标操作!编译器会将其转换为 *((n) + (ptr)),其行为与 *((ptr) + (n)) 完全相同。不过实际编程中请避免这样写,因为会造成混淆。


指针运算与下标操作是相对地址

初学数组下标时,人们常误以为索引代表数组中固定的元素位置:索引0永远是第一个元素,索引1永远是第二个元素,以此类推……

这实为错觉。数组索引本质上是相对位置。之所以看似固定,仅仅因为我们几乎总是从数组开头(元素0)开始索引!

请记住:对于某个指针 ptr,(ptr + 1) 和 ptr[1] 都会返回内存中下一个对象(取决于被指向的数据类型)。“下一个”是相对概念而非绝对概念。因此若ptr指向元素0,则(ptr + 1)和ptr[1]均返回元素1;但若ptr指向元素3,则两者都将返回元素4!

以下示例说明此特性:

#include <array>
#include <iostream>

int main()
{
    const int arr[] { 9, 8, 7, 6, 5 };
    const int *ptr { arr }; // arr decays into a pointer to element 0

    // Prove that we're pointing at element 0
    std::cout << *ptr << ptr[0] << '\n'; // prints 99
    // Prove that ptr[1] is element 1
    std::cout << *(ptr+1) << ptr[1] << '\n'; // prints 88

    // Now set ptr to point at element 3
    ptr = &arr[3];

    // Prove that we're pointing at element 3
    std::cout << *ptr << ptr[0] << '\n'; // prints 66
    // Prove that ptr[1] is element 4!
    std::cout << *(ptr+1) << ptr[1] << '\n'; // prints 55

    return 0;
}

image

然而,您也会注意到,如果不能假设 ptr[1] 始终是索引为 1 的元素,我们的程序就会变得非常混乱。因此,我们建议仅在从数组开头(元素 0)进行索引时使用下标操作,仅在进行相对定位时使用指针运算。

最佳实践:
从数组开头(元素0)开始索引时,优先使用下标操作,使数组索引与元素位置对齐。

从特定元素进行相对定位时,优先使用指针运算。


负索引

上一课我们提到,与标准库容器类不同,C 风格数组的索引既可以是无符号整数,也可以是有符号整数。这并非仅出于便利性考虑——实际上,C 风格数组确实支持使用负下标进行索引。听起来有些奇怪,但确实合理。

我们刚讲过 *(ptr+1) 返回内存中下一个对象。而 ptr[1] 只是实现相同功能的便捷语法。

本节开头提到 *(ptr-1) 返回内存中上一个对象。猜猜对应的下标形式是什么?没错,正是 ptr[-1]。

#include <array>
#include <iostream>

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

    // Set ptr to point at element 3
    const int* ptr { &arr[3] };

    // Prove that we're pointing at element 3
    std::cout << *ptr << ptr[0] << '\n'; // prints 66
    // Prove that ptr[-1] is element 2!
    std::cout << *(ptr-1) << ptr[-1] << '\n'; // prints 77

    return 0;
}

image


指针运算可用于遍历数组

指针运算最常见的用途之一,是在不显式指定索引的情况下遍历C风格数组。以下示例展示了具体实现方式:

#include <iostream>

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

	const int* begin{ arr };                // begin points to start element
	const int* end{ arr + std::size(arr) }; // end points to one-past-the-end element

	for (; begin != end; ++begin)           // iterate from begin up to (but excluding) end
	{
		std::cout << *begin << ' ';     // dereference our loop variable to get the current element
	}

	return 0;
}

在上例中,我们从 begin 指向的元素开始遍历(此处即数组的第 0 个元素)。由于 begin != end,循环体得以执行。在循环内部,我们通过*begin访问当前元素,这本质上是解引用操作。循环体执行完毕后,++begin通过指针运算将begin指向下一个元素。由于begin不等于end,循环体再次执行。此过程持续进行,直至begin不等于end的条件不成立——即begin等于end时。

因此上述代码将输出:

image

请注意,end被设置为数组末尾的下一个地址。让end持有该地址是可行的(只要我们不解引用end,因为该地址不存在有效元素)。这样做是为了使数学运算和比较尽可能简单(无需在任何地方加减1)。

提示:
对于指向C风格数组元素的指针,只要运算结果指向有效数组元素或末元素之后的地址,指针运算即为合法。若运算结果超出此范围,则属于未定义行为(即使未进行解引用)。

在之前的第17.8节——C风格数组衰变中,我们提到数组衰变会使函数重构困难,因为某些操作在非衰变数组上可行而在衰变数组上不可行(如std::size)。这种遍历数组方式的巧妙之处在于:我们可以将上述示例中的循环部分原样重构为独立函数,且仍能正常运行:

#include <iostream>

void printArray(const int* begin, const int* end)
{
	for (; begin != end; ++begin)   // iterate from begin up to (but excluding) end
	{
		std::cout << *begin << ' '; // dereference our loop variable to get the current element
	}

	std::cout << '\n';
}

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

	const int* begin{ arr };                // begin points to start element
	const int* end{ arr + std::size(arr) }; // end points to one-past-the-end element

	printArray(begin, end);

	return 0;
}

image

请注意,尽管我们从未显式将数组传递给函数,该程序仍能编译并产生正确结果!由于未传递 arr,我们无需在 printArray() 中处理衰变的 arr。相反,begin 和 end 包含遍历数组所需的所有信息。

在后续课程(涉及迭代器和算法时)我们将看到,标准库中大量函数都采用 begin 和 end 元素对来定义容器中需要操作的元素范围。


基于范围的 for 循环遍历 C 风格数组时,通过指针运算实现

考虑以下基于范围的 for 循环:

#include <iostream>

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

	for (auto e : arr)         // iterate from `begin` up to (but excluding) `end`
	{
		std::cout << e << ' '; // dereference our loop variable to get the current element
	}

	return 0;
}

image

如果你查看基于范围的 for 循环的文档,你会发现它们通常以类似以下方式实现:

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

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

让我们用以下实现替换前例中的基于范围的 for 循环:

#include <iostream>

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

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

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

	return 0;
}

image

注意这与上一节编写的示例多么相似!唯一的区别在于我们将 *__begin 赋值给 e,并使用 e 而不是直接使用 *__begin!


测验时间

问题 #1

a) 为何 arr[0] 与 *arr 相等?

显示解答

arr[0] 是 *((arr) + (0)) 的简写形式,等同于 *(arr + 0),而这又等于 *arr。

相关内容:
下一课(17.10 -- C 风格字符串)中将提供更多关于指针运算的测验题。

posted @ 2026-01-12 19:51  游翔  阅读(11)  评论(0)    收藏  举报