6.6. 特殊场景下的智能指针

标准智能指针如unique_ptr<T>(独占所有权)和shared_ptr<T>(共享所有权)已为我们所熟知,而面对特殊场景时也能自定义实现(如前文探讨的dup_ptr<T>,它具备独占所有权特性,但在指针复制时会同步复制所指对象)。那么,还有哪些常见语义值得我们通过类型系统来强化呢?至少有两个显而易见的选项:实现“永不为空”的语义约束,以及实现“仅观察”的语义约束。

6.6.1 非空指针类型(non_null_ptr)

回顾之前的一个示例代码:

// ...
// 前提条件:p 不为空指针(为了简化逻辑)
X* duplicate(X *p) {
  return new X{ *p }; // 没问题
}
// ...

请注意代码中的注释 —— 将“不传入空指针”的责任完全交给了使用者。可以通过多种方式强化这个约束:

关键在于:如果确实需要确保指针非空,却只在运行时通过if(!p)进行检查,这很可能(或者说应该?)是设计上的失误 —— “只接受非空指针”这个约束本应通过类型系统来保证,代码本身的约束力永远强于注释说明。

这种设计理念已应用于某些商业库(比如,主流编译器厂商提供的指南支持库中的gsl::non_null<T>)。只要能明确定义错误处理方式,实现这种类型并不困难。为便于演示,将采用抛出异常作为错误处理机制:

class invalid_pointer {};

template <class T>
class non_null_ptr {
  T *p;
public:
  non_null_ptr(T *p) : p{ p } {
    if (!p) throw invalid_pointer{};
  }

  T* get() const { return p; }

  constexpr operator bool() const noexcept {
    return true;
  }
// ...

通过使用non_null_ptr<T>类型,接受该类型参数的函数都能确保其中的T*指针非空,从而免除了使用端代码的校验负担。这使得non_null_ptr<T>成为需要非空指针参数函数接口的理想选择。

该类的其余部分实现起来大多较为简单。关键特性在于:non_null_ptr<T>不提供默认构造函数,该构造函数需要将p数据成员初始化为某个默认值(很可能是nullptr),但这与“非空指针”的类型语义矛盾。

使用示例如下所示:

struct X { int n; };

class invalid {};

int extract_value(const X *p) {
  if(!p) throw invalid{};
  return p->n;
}

#include <iostream>

int main() try {
  X x{ 3 };
  std::cout << extract_value(&x) << '\n'
            << extract_value(nullptr) << '\n';
} catch(invalid) {
  std::cerr << "oops\n";
}

现在将其与以下代码对比(假设non_null_ptr<T>在构造时传入空指针会抛出异常):

// non_null_ptr 类型的定义(此处省略)
struct X { int n; };

int extract_value(const non_null_ptr<X> &p) {
  return p->n; // 定义一个简单结构体 X,包含一个整型成员 n
}

#include <iostream>

int main() try {
  X x{ 3 };
  std::cout << extract_value(&x) << '\n'
            << extract_value(nullptr) << '\n';
} catch(...) {
  std::cerr << "oops\n";
}

相比直接使用T*,non_null_ptr<T>在此场景下的两大优势在于:

若被调用函数确实需要T*(需要调用C函数时),可通过non_null_ptr<T>的get()成员函数获取。毕竟C++向来注重实用性。

6.6.2 观察指针类型(observer_ptr)

实现一个极简的智能指针observer_ptr<T>,其核心语义在于表明这个“智能”指针实际上不具备指针特性 —— 即限制原生指针支持的部分操作。典型场景是:对T*执行delete是合法操作,但对observer_ptr<T>执行delete则应禁止,因为observer_ptr<T>本质上是…非指针类型。请看以下示例:

class X { /* ... */ };

void f(X *p) {
  // 使用 *p
  // 我们传给 f() 的是一个裸指针,因此 f() 应该只是观察(使用)该指针,
  // 而不应该拥有它(即不应该负责释放资源)
  delete p; // 等等!你不应该这么做!
}

正如注释所言,而有读者可能会说:“但这个函数不该这么做!它并不拥有*p的所有权!”。然而,错误和误解总是难以避免。在这种情况下,由于参数类型本身并未表明“对p执行delete操作是非法的”,使得误解带来的后果更加严重。

现在让我们稍作修改:

class X { /* ... */ };

void f(observer_ptr<X> p) {
  // 使用 *p
  // delete p; // 不行,这段代码无法通过编译
}

两个版本中“使用*p”的注释保持相同。observer_ptr<T>类型提供了所有合理操作符和成员函数(get()、operator*()、operator→()、empty()等)的基础实现,因此在用户代码中使用T*和observer_ptr<T>应该基本等效 —— 唯一的区别在于禁止使用错误用法(如执行delete或指针运算)。

有时,仅仅在函数接口中明确意图就能显著提升代码质量。这种类型的主要价值在于: