C++ 语义化指针: NotNull 和 Nullable
C++ 的智能指针不是万能的,什么地方都使用智能指针属于一种滥用。其实在程序的局部视角中,有很多变量的生命周期都是程序员可以预见的,这些地方大可放心使用裸指针。但是在 C++ 中使用裸指针又容易引起 “技术洁癖” 那种不适感,所以我设计了两个裸指针的 wrapper,将指针的生命周期语义化,减少代码编写时的心智负担。本文编写过程中没有 AI 润色,可放心阅读。
不要滥用智能指针
C++11 标准确定之后,最常用的两类智能指针可以按照所有权来分类,它们是 唯一所有权 和 共享所有权 两类。标准库中分别对应的类型是 std::unique_ptr 和 std::shared_ptr/std::weak_ptr。共享所有权这一类包含两个智能指针类型,是因为 weak_ptr 不能单独使用。而 shared_ptr 虽然可以单独使用,但是也得和 weak_ptr 一起用才能用的好。
所谓指针的 所有权,我认为可以简单理解为 “释放资源” 的责任。谁持有这个资源,谁就有这个责任正确、安全地释放它。那么唯一所有权指的就是负责释放资源的人是唯一的。我认为这是最普遍的一种情况,从 C 语言时代就有这种思想:“谁创建、谁释放”。而共享所有权就意味着负责释放资源的人是不唯一的,持有这种智能指针的所有对象都有可能负责释放这份资源,使用它的理由通常是资源的生命周期不可控,无法预期。但是资源只能被释放一次,多次释放资源在 C/C++ 中属于未定义行为。为了保证资源只被释放一次,shared_ptr 除了储存资源的指针,还维护着一个 “控制块”。这引出了不要滥用智能指针的第一个理由 —— 一切都是有成本的。
shared_ptr 是有成本的
shared_ptr 是最容易被滥用的智能指针。因为 unique_ptr 使用起来并不那么容易,如果你的结构体或者类中包含了 unique_ptr 那么你的结构体和类将不能复制。在初学 C++ 的那段时间,还难以理解 C++ 的一些理念。所以这时候最简单的解决方法就是将 unique_ptr 改为 shared_ptr,这样只需要改个类型名字就消除了所有编译错误。但是前面我已经提到 shared_ptr 为了保证正确释放资源,会引入一个控制块,这会增加额外地成本。本文不打算详细拆解 shared_ptr 的实现细节以及成本,但是它们通常是因为:控制块本身、控制块和指针的内存可能不连续、控制块的多线程安全。
shared_ptr 解决了内存泄漏,又没解决
智能指针的初衷就是解决手动管理内存生命周期而导致的内存泄漏。这里的 “内存泄露” 前面有一个很长的定语,是因为我想区分不同场景的内存泄漏。shared_ptr 确实解决了初衷的内存泄漏场景,但是却引出了另一种内存泄漏场景。循环引用是一个很经典的 shared_ptr 滥用场景:两个使用 shared_ptr 管理的实例通过 shared_ptr 互相持有对方的指针,这样就造成了内存泄漏。
1 | { |
注意这 4 行代码运行的结果是使得 a 和 b 的引用计数都是 2。当作用域结束时 a 和 b 均让自己的引用计数减少 1,但是因为都达不到 0,所以谁也不会释放资源。循环引用的解决方法是使用 weak_ptr 打破循环,这个内存泄露以及解决方法中的具体细节有很多现成的文章和视频可以参考,本文不再展开。
使用裸指针吧!
智能指针拥有资源的所有权,这是一种持有关系,而 weak_ptr 的使用场景则揭示了资源的另一种关系 —— “借用关系” 或者说 “引用关系”。对象不需要对所引用资源的生命周期负责,只管使用。至于可能出现的悬挂引用,则是程序员的责任。
通常来说,这种关系可以用 C++ 的引用 (int&) 或者裸指针 (int*) 描述。就我的个人习惯,如果一个函数仅仅需要访问资源,不需要拿到资源的所有权,那么我不会使用 const std::unique_ptr<T>&,而是直接使用 T& 或者 T*。类成员变量也是类似的用法,如果一个类引用了另一个类的实例,那么我会用指针(T*)描述这个引用关系。但是我不会使用引用(T&),因为 C++ 引用是不可以为空的,这个特性会让构造函数很难写。
我的习惯其实是做了一种约定:用什么指针只看是持有关系还是引用关系,需要所有权转移的时候才传递智能指针,仅仅使用资源的时候就传递裸指针。
语义化指针
NotNull
但是大量的裸指针也会带来问题,那就是使用者必须对它判空。空指针问题和资源的持有/引用关系问题是两个不同的问题。如果一个函数不接受空指针,那么一个好的习惯是在调用函数前就对传进去的指针判空,如果是空指针当然不能调用这个函数了。对于函数的实现者来说,对空指针解引用是未定义行为,所以使用前必须判定其是否空指针。这里就引起了一个问题:为了一次函数调用,要进行两次判空检查。最终程序中会存在大量判空代码。
我不担心它会引起什么性能问题,主要是那么多判空的代码写起来是很麻烦的,而且难免会有一两次会漏掉判空检查。
为了减少判空代码,我决定尝试在代码中引入两样东西。首先是实现一个 NullPointerException,我决定用这个异常来统一空指针的处理。另外就是 NotNull<T> 模板类。由于用到了异常,所以这个代码很明显不适用于算法类的热点代码。
1 | template <typename T> |
上面的代码是一个简化的实现,已经足够勾勒出 NotNull 的轮廓。有了 NotNull 之后,构造函数、getter/setter 函数的写法会有一点变化。
1 | class DeserializeContext |
注意上面各种示例函数我都标记为了 noexcept,因为它们不需要分心去处理空指针输入了。由于有了 NotNull,如果程序出现逻辑错误或者程序员在构造 NotNull 时没有判空,那么异常早早就在 NotNull 的构造函数抛出来了。
使用 NotNull 至少提供了这些便利:
- 空指针会在上一层就抛出异常,使用指针时不再担心遇到空指针,减少心智负担;
- 由于保证不是空指针,因此不再需要写判空代码;
- 看不见
*号,代码洁癖不会发作;
但是 NotNull 的能力其实也很有限,比如它不保证解决生命周期问题,所以如果出现悬挂指针,代码还是很危险的。但是我认为也不该用智能指针来解决这个问题,如果一定会出现生命周期的不确定性,那还是得用 shared_ptr + weak_ptr 来确保没有悬挂引用。或者参考 QPointer 的做法,当资源被释放时,通知所有 QPointer 将自己设置为空指针。
其实对裸指针包装一层的做法并不新鲜,比如微软就做了 gsl::not_null,不过我没有仔细研究过它的具体实现,也没有比较过和我的实现有什么区别。
Nullable
如果在 NotNull 的基础上再引入 Nullable 会有更多收益。和 NotNull 相反,Nullable 储存一个可能为空的指针类型,行为和语义都和普通裸指针一样,但如果新增一些成员变量,确实可以让代码风格更一致。
1 | template <typename T> |
Nullable 的实现没有什么特别的,但是我想介绍 Nullable 的 Required 成员函数,它用来确认指针是否为空,如果不为空那么返回一个 NotNull。
1 | template <typename T> |
这就直接将 Nullable 和 NotNull 联系起来了。在业务代码中,如果不想处理空指针(更多是不方便,比如为了处理空指针会改变函数的返回类型),那么就直接抛出空指针异常交给调用方处理。
另外还需要考虑指针的类型转换,比如 dynamic_cast、static_cast 以及 const_cast。对于 NotNull,为了实现 dynamic_cast,引入 Nullable 是必须的,因为 dynamic_cast 可能会失败。
1 | template <typename T> |
最后展示下引入 NotNull/Nullable 前后的代码对比。
裸指针版本:1
2
3IShape* shape = m_pPatch->GetShape();
ICurveShape* curveShape = dynamic_cast<ICurveShape>(shape);
if(!curveShape) throw NullPointerException{};
语义化指针版本:1
2
3
4// note: NotNull<Patch> m_pPatch;
auto curveShape = m_pPatch->GetShape() // NotNull<IShape>
.DCast<ICurveShape>() // Nullable<ICurveShape>
.Required(); // NotNull<ICurveShape>
这两个版本应该说基本上是等价的。
本文简单介绍了 NotNull/Nullable 语义化指针的思路,完整的代码实现会比本文介绍的细节要多,比如构造函数需要支持类型转换,重载 std::hash 等。
C++ 语义化指针: NotNull 和 Nullable