2.2. 指针

第1章从概念层面探讨了C++指针的本质与含义,解释了指针算术运算的原理及其功能。现在来深入分析指针算术的实际应用场景,包括底层工具(有时极其重要)的正确与错误用法。

2.2.1 数组内的指针算术运算

指针算术是把锋利双刃剑,实用却常误用。对于原生数组,下面标记为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, 不保证有效

2.2.2 指针的可互换性

C++标准定义了对象之间的指针可互换性(pointer-interconvertibility)。当两个对象满足指针可互换时,可以通过reinterpret_cast(第3章将详细讨论)将一个对象的指针当作另一个对象的指针使用(地址相同)。

指针可互换性的核心规则包括:

示例如下:

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,代码就是错的,且无法保证实际运行效果,请务必避免。

我们将在代码示例中(包括下一节)适时的使用指针互换特性。

2.2.3 对象内的指针算术运算

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; // 从技术上讲没问题
}

本书不会滥用这一语言特性,但理解这些底层机制对编写正确的底层代码至关重要。需要特别注意:

指针与地址的本质区别

为了加强硬件和软件安全性,目前正在开发支持“指针标记”(pointer tagging)的硬件架构,这种技术使硬件能够追踪指针来源(provenance)等元数据。两个著名的案例:

为了充分利用这类硬件,编程语言需要区分底层的地址(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
}

需要特别说明:

解引用空指针与解引用未初始化指针,同样属于未定义行为(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章详细讨论这个数组示例,用于解析高效内存管理多个关键技术。接下来关注另一个高风险的编程操作。