第1章从概念层面探讨了C++指针的本质与含义,解释了指针算术运算的原理及其功能。现在来深入分析指针算术的实际应用场景,包括底层工具(有时极其重要)的正确与错误用法。
指针算术是把锋利双刃剑,实用却常误用。对于原生数组,下面标记为A和B的两个循环完全等效:
void f(int);
int main() {
int vals[]{ 2,3,5,7,11 };
enum { N = sizeof vals / sizeof vals[0] };
for(int i = 0; i != N; ++i) // A
f(vals[i]);
for(int *p = vals; p != vals + N; ++p) // B
f(*p);
}
循环B中的vals + N表达式看似危险,但实为合法且符合C++的惯用法。标准允许获取数组尾后指针(尽管禁止解引用),但要注意:仅此特定位置获得保证,若越界访问则立即成为未定义行为(UB) —— 可能触发进程地址空间外的非法内存访问。
int arr[10]{ }; // 所有元素初始化为0
int *p = &arr[3];
p += 4; assert(p == &arr[7]);
--p; assert(p == &arr[6]);
p += 4; // 仍然可以,只要不访问*p
++p; // UB, 不保证有效
C++标准定义了对象之间的指针可互换性(pointer-interconvertibility)。当两个对象满足指针可互换时,可以通过reinterpret_cast(第3章将详细讨论)将一个对象的指针当作另一个对象的指针使用(地址相同)。
指针可互换性的核心规则包括:
对象可与自身指针互换
联合体(union)可与其数据成员(若为复合类型,则包含其首个数据成员)指针互换
特定限制下,若对象x的首个非静态数据成员与对象y类型相同,则x与y指针可互换
示例如下:
struct X { int n; };
struct Y : X {};
union U { X x; short s; };
int main() {
X x;
Y y;
U u;
// x 与 x 是指针可互换的
// u 与 u.x 是指针可互换的
// u 与 u.s 是指针可互换的
// y 与 y.x 是指针可互换的
}
若以违反指针可互换性规则的方式使用reinterpret_cast,代码就是错的,且无法保证实际运行效果,请务必避免。
我们将在代码示例中(包括下一节)适时的使用指针互换特性。
C++允许在单个对象内部进行指针算术运算,但需注意:使用正确的类型转换(第3章将详细探讨);确保算术运算的合理性。
以下代码虽然合法,但并非推荐实践(其逻辑毫无意义且实现方式过于复杂,但确实无害):
struct A {
int a;
short s;
};
short * f(A &a) {
// 指针互转换!
int *p = reinterpret_cast<int*>(&a);
p++;
return reinterpret_cast<short*>(p); // 同一个对象,没有问题
}
int main() {
A a;
short *p = f(a);
*p = 3; // 从技术上讲没问题
}
本书不会滥用这一语言特性,但理解这些底层机制对编写正确的底层代码至关重要。需要特别注意:
必须使用offsetof等标准工具计算成员偏移量
通过char*进行地址运算可确保字节级精度
最终仍需转换回正确类型才能访问成员
为了加强硬件和软件安全性,目前正在开发支持“指针标记”(pointer tagging)的硬件架构,这种技术使硬件能够追踪指针来源(provenance)等元数据。两个著名的案例:
CHERI架构(https://packt.link/cJeLo)
内存标记扩展(MTE):
Linux: https://packt.link/KXeRn
Windows: https://packt.link/DgSaH
为了充分利用这类硬件,编程语言需要区分底层的地址(address)概念和高层的指针(pointer)概念,后者需要考虑指针不仅是内存位置这一情况。如果代码确实需要比较不相关指针的排序关系,可以将指针转换为std::intptr_t或std::uintptr_t,然后比较(数值)结果,而不是直接比较指针本身。需要注意的是,编译器对这两种类型为可选性支持,不过所有主流编译器都提供了支持。
空指针的概念最早可追溯至C.A.R. Hoare(https://packt.link/ByfeX),其核心思想是通过特定值表示无效指针。在C语言中,NULL宏最初定义为值为0的char类型,后改为值为0的void类型,而在C++中则简化为字面量0 —— 类似int *p = NULL;这样带类型的NULL在C中合法,但在C++的类型系统(更为严格)中不合法。需注意,值为0的指针并不等同于“指向地址0”,零地址本身在许多平台上是有效的内存位置。
C++中表达空指针的首选方式是nullptr。这个std::nullptr_t类型的对象能隐式转换为任意指针类型,且行为符合预期。它解决了C++中长期存在的字面量0问题,例如:
int f(int); //#0
int f(char*); // #1
int main() {
int n = 3;
char c;
f(n); // 调用 #0
f(&c); // 调用 #1
f(0); // C++11之前有歧义,C++11之后调用#0
f(nullptr); // C++11之后调用#1
}
需要特别说明:
nullptr不是指针,是可转换为指针的对象(std::is_pointer_v
C++提供专门的std::is_null_pointer
解引用空指针与解引用未初始化指针,同样属于未定义行为(UB)。使用nullptr的价值在于使无效状态可识别 —— 可明确区分的特殊值,而未初始化指针可能包含任意位模式。
与C不同,C++中对空指针进行算术运算在特定情况下有明确定义:仅允许给空指针加零。标准文档(wg21.link/c++draft/expr.add#4.1)对此有专门规定。在空数组场景中,begin()返回nullptr且size()为0时,end()计算nullptr+0的行为完全合法:
template <class T> class Array {
T *elems = nullptr; // 指向起点的指针
std::size_t nelems = 0; // 元素数量
public:
Array() = default; // 等于空数组
// ...
auto size() const noexcept { return nelems; }
// 注意:可能返回nullptr
auto begin() noexcept { return elems; }
auto end() noexcept { return begin() + size(); }
}
我们将在第12、13、14章详细讨论这个数组示例,用于解析高效内存管理多个关键技术。接下来关注另一个高风险的编程操作。