在《容器与数组简介》中,我们指出数组元素在内存中连续存放。本章将深入探讨数组下标的数学原理。
尽管后续章节不会再直接使用这些下标计算,但本节内容有助于理解范围 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 风格字符串》还将包含指针运算的更多测验题。