计数对象技术
Kevlin Henney
([email protected])
引用计数技术?你可能会认为这没什么新鲜的。任何一本将你带到中级或高级水平的优秀的 C++ 教材都会介绍这个概念。过去对它进行了非常彻底的探讨,以至于你可能会被原谅,认为所有能说的话都已经说过了。好吧,让我们从第一原理出发,看看是否能发现一些新的东西……
然后一个也不剩了……
引用计数背后的原理是跟踪对象的正在使用的计数,以便当它降至零时,我们知道该对象未被使用。这通常用于简化动态分配对象的内存管理:跟踪对该对象的引用数量,并在数量为零时删除该对象。
如何跟踪对象的使用者数量?好吧,普通的指针非常笨拙,因此需要额外的间接层来管理计数。这本质上是设计模式[Gamma、Helm、Johnson & Vlissides,Addison-Wesley,ISBN 0-201-63361-2]中描述的代理模式。其意图如下
为另一个对象提供一个代理或占位符来控制对它的访问。
Coplien[高级 C++ 编程风格与惯用法,Addison-Wesley,ISBN 0-201-56365-7]定义了一组与句柄和主体部分的这种基本分离相关的惯用法。Taligent 设计程序指南[Addison-Wesley,ISBN 0-201-40888-0]确定了代理(又名替身)的若干特定类别。从广义上讲,它们分为两大类
- 隐藏:句柄是感兴趣的对象,隐藏了主体本身。句柄的功能是通过委托给主体来获得的,句柄的使用者不知道主体。引用计数字符串提供了一种透明的优化。主体在字符串副本之间共享,直到需要更改时,此时才会创建副本。这种写时复制模式(延迟评估的一种专门化)需要使用隐藏的引用计数主体。
- 显式:这里主体是感兴趣的,句柄仅仅为其访问和维护提供智能。在 C++ 中,这通常实现为智能指针惯用法。一个这样的应用是引用计数智能指针,它们协作跟踪对象的计数,并在计数降至零时删除它。
附加与分离
对于引用计数智能指针,计数可以存在两个位置,从而产生两种不同的模式,软件模式[Coplien,SIGS,ISBN 0-884842-50-X]中概述了这两种模式
- 计数对象或附加计数句柄/主体将计数放在被计数的对象中。好处是可计数性是被计数对象的一部分,并且引用计数不需要额外的对象。缺点是显然这具有侵入性,并且当对象不是基于堆时,引用计数的空间会被浪费。因此,引用计数将你绑定到特定的实现和使用方式。
- 分离计数句柄/主体将计数放在被计数的对象之外,以便它们一起处理。这样做的好处是这种技术完全没有侵入性,所有智能和支持装置都在智能指针中,因此可以用于独立于引用计数指针创建的类。主要缺点是频繁使用此方法会导致大量小的对象(即计数器)在堆上创建。
即使进行这种简单的分析,似乎分离计数句柄/主体方法也更胜一筹。事实上,随着模板使用的增加,这通常是首选方法,并且是常见(但不是标准)counted_ptr
背后的原理。[Boost 中的名字是 shared_ptr
而不是 counted_ptr
。]
计数对象的常见实现是在基类中提供计数机制,被计数类型从中派生。或者,为每个需要它的类重新提供引用计数机制。这两种方法都不令人满意,因为它们非常封闭,将一个类耦合到特定的框架中。再加上计数在非计数对象中处于休眠状态的不内聚性,你会感觉到,除了在 COM 和 CORBA 等广泛的对象模型中使用之外,计数对象方法可能仅在特殊情况下有用。
基于需求的方法
正是开放性的问题让我重新审视了计数对象惯用法的难题。是的,使用这种惯用法时需要一定程度的侵入性,但有没有办法最大程度地减少这种侵入性,并将计数机制的选择与使用的智能指针类型解耦?
近年来,构建开放式通用组件最有指导意义的代码和规范是 Stepanov 和 Lee 的 STL(标准模板库),现在它是 C++ 标准库的一部分。STL 方法广泛使用基于类型明确定义的操作需求的编译时多态性。例如,每个容器、被包含类型和迭代器类型都由可以在该类型的对象上执行的操作定义,通常还带有描述其他约束的注释。顾名思义,编译时多态性根据函数名称和参数使用(即重载)在编译时解析函数。这侵入性较小,但如果错误则难以诊断,而不是基于类型、名称和函数签名的运行时多态性。
这种基于需求的方法可以应用于引用计数。我们需要使一个类型成为可计数的操作大致如下
- 一个
acquire
操作,注册对可计数对象的兴趣。 - 一个
release
操作,注销对可计数对象的兴趣。 - 一个
acquired
查询,返回可计数对象当前是否被获取。 - 一个
dispose
操作,负责处理不再被获取的对象。
请注意,计数被推断为该类型的抽象状态的一部分,并且没有以任何其他方式提及或定义。这种方法的开放性部分源于全局函数的使用,这意味着没有暗示任何特定的成员函数;一种完美的方式来封装现有的计数对象类,而无需修改类本身。开放性的另一个方面来自对操作的更精确的规范。
要使一个类型成为可计数的,它必须满足以下要求,其中ptr
是指向该类型单个对象(即不是数组)的非空指针,而#function
表示对function(ptr)
的调用次数
表达式 | 返回类型 | 语义和注释 |
acquire(ptr) |
无要求 | 后置条件:acquired(ptr) |
release(ptr) |
无要求 | 前置条件:acquired(ptr) 后置条件:acquired(ptr) == #acquire - #release |
acquired(ptr) |
可转换为bool |
返回:#acquire > #release |
dispose(ptr, ptr) |
无要求 | 前置条件:!acquired(ptr) 后置条件:*ptr 不再可用 |
请注意,dispose
的两个参数是为了支持选择要调用的相应类型安全版本的函数。在一般情况下,意图是第一个参数确定要删除的类型,并且通常是模板化的,而第二个参数选择要使用的哪个模板,例如,通过符合特定的基类。
此外,还必须满足以下要求,其中null
是指向可计数类型的空指针
表达式 | 返回类型 | 语义和注释 |
acquire(null) |
无要求 | 动作:无 |
release(null) |
无要求 | 动作:无 |
acquired(null) |
可转换为bool |
返回:false |
dispose(null, null) |
无要求 | 动作:无 |
请注意,对于这些函数在抛出或不抛出异常方面没有任何要求,除非如果抛出异常,函数本身应该是异常安全的。
变得智能
给定类型的可计数要求,可以定义一个通用智能指针类型,将其用于引用计数
template<typename countable_type> class countable_ptr { public: // construction and destruction explicit countable_ptr(countable_type *); countable_ptr(const countable_ptr &); ~countable_ptr(); public: // access countable_type *operator->() const; countable_type &operator*() const; countable_type *get() const; public: // modification countable_ptr &clear(); countable_ptr &assign(countable_type *); countable_ptr &assign(const countable_ptr &); countable_ptr &operator=(const countable_ptr &); private: // representation countable_type *body; };
此类的接口有意保持简单,例如,为了说明,省略了成员模板和throw
规范。大多数函数的实现非常简单,在很大程度上依赖于assign
成员作为关键函数
template<typename countable_type> countable_ptr<countable_type>::countable_ptr(countable_type *initial) : body(initial) { acquire(body); } template<typename countable_type> countable_ptr<countable_type>::countable_ptr(const countable_ptr &other) : body(other.body) { acquire(body); } template<typename countable_type> countable_ptr<countable_type>::~countable_ptr() { clear(); } template<typename countable_type> countable_type *countable_ptr<countable_type>::operator->() const { return body; } template<typename countable_type> countable_type &countable_ptr<countable_type>::operator*() const { return *body; } template<typename countable_type> countable_type *countable_ptr<countable_type>::get() const { return body; } template<typename countable_type> countable_ptr<countable_type> &countable_ptr<countable_type>::clear() { return assign(0); } template<typename countable_type> countable_ptr<countable_type> &countable_ptr<countable_type>::assign(countable_type *rhs) { // set to rhs (uses Copy Before Release idiom which is self assignment safe) acquire(rhs); countable_type *old_body = body; body = rhs; // tidy up release(old_body); if(!acquired(old_body)) { dispose(old_body, old_body); } return *this; } template<typename countable_type> countable_ptr<countable_type> &countable_ptr<countable_type>::assign(const countable_ptr &rhs) { return assign(rhs.body); } template<typename countable_type> countable_ptr<countable_type> &countable_ptr<countable_type>::operator=(const countable_ptr &rhs) { return assign(rhs); }
公共问责制
符合要求意味着一个类型可以与countable_ptr
一起使用。这是一个实现混合类(mix-imp),它通过成员函数为其派生类赋予可计数性。此类可用作类适配器
class countability { public: // manipulation void acquire() const; void release() const; size_t acquired() const; protected: // construction and destruction countability(); ~countability(); private: // representation mutable size_t count; private: // prevention countability(const countability &); countability &operator=(const countability &); };
请注意,操作函数是const
,而count
成员本身是mutable
。这是因为可计数性不是对象抽象状态的一部分:内存管理不依赖于对象的const
性或其他特性。我不会在这里包含成员函数的定义,因为你可能已经猜到了:分别针对操作函数递增、递减和返回当前计数。在多线程环境中,应确保此类读写操作是原子的。
那么我们如何使这个类成为可计数的?一组简单的转发函数可以完成这项工作
void acquire(const countability *ptr) { if(ptr) { ptr->acquire(); } } void release(const countability *ptr) { if(ptr) { ptr->release(); } } size_t acquired(const countability *ptr) { return ptr ? ptr->acquired() : 0; } template<class countability_derived> void dispose(const countability_derived *ptr, const countability *) { delete ptr; }
现在从countability
派生的任何类型现在都可以与countable_ptr
一起使用
class example : public countability { ... }; void simple() { countable_ptr<example> ptr(new example); countable_ptr<example> qtr(ptr); ptr.clear(); // set ptr to point to null } // allocated object deleted when qtr destructs
运行时混合
挑战在于以非侵入的方式应用计数对象,以便在对象未被计数时没有开销。我们想要做的是在每个对象而不是每个类的基础上赋予这种能力。有效地,我们是在任何对象上追求可计数性,即任何由void *
指向的对象!不用说,void
可能是任何类型中最不承诺的。
解决这个问题的力量非常有趣,至少可以说。有趣,但并非不可克服。鉴于运行时对象的类不能以任何明确定义的方式动态更改,并且对象的布局必须固定,因此我们必须找到一个新的位置和时间来添加计数状态。必须仅在堆创建时添加这一事实表明以下解决方案
struct countable_new; extern const countable_new countable; void *operator new(size_t, const countable_new &); void operator delete(void *, const countable_new &);
我们用一个虚拟参数重载了operator new
,以将其与常规的全局operator new
区分开来。这类似于在标准库中使用std::nothrow_t
类型和std::nothrow
对象。放置operator delete
用于在构造失败的情况下执行任何清理工作。请注意,这在目前还不太多的编译器上受支持。
使用countable
的new
表达式的结果是在堆上分配的对象,该对象具有保存计数的头部块,即我们通过在其前面添加前缀来扩展了对象。我们可以在实现文件中的匿名命名空间(未显示)中提供几个功能,以支持计数及其从原始指针的访问
struct count { size_t value; }; count *header(const void *ptr) { return const_cast<count *>(static_cast<const count *>(ptr) - 1); }
这里需要注意的一个重要限制是,count
的对齐方式应适合任何类型。对于所示的定义,这在几乎所有平台上都是适用的。但是,您可能需要为那些不适用的平台添加填充成员,例如,使用匿名 union
来使 count
与对齐方式最高的类型对齐。不幸的是,没有可移植的方式来指定这一点,同时也能保证满足最小对齐方式 - 当您指定不直接使用 new
或 malloc
结果的自定义分配器时,这是一个常见问题。
再次注意,计数不被视为对象逻辑状态的一部分,因此从 const
到非 const
的转换 - count
实际上是一种 mutable
类型。
分配器函数本身相当简单。
void *operator new(size_t size, const countable_new &) { count *allocated = static_cast<count *>(::operator new(sizeof(count) + size)); *allocated = count(); // initialise the header return allocated + 1; // adjust result to point to the body } void operator delete(void *ptr, const countable_new &) { ::operator delete(header(ptr)); }
给定一个正确分配的头部,我们现在需要 Countable 函数在 const void *
上操作以完成整个过程。
void acquire(const void *ptr) { if(ptr) { ++header(ptr)->value; } } void release(const void *ptr) { if(ptr) { --header(ptr)->value; } } size_t acquired(const void *ptr) { return ptr ? header(ptr)->value : 0; } template<typename countable_type> void dispose(const countable_type *ptr, const void *) { ptr->~countable_type(); operator delete(const_cast<countable_type *>(ptr), countable); }
其中最复杂的是 dispose
函数,它必须确保正确地销毁类型并从正确的偏移量收集内存。它使用第一个参数的值和类型来正确执行此操作,第二个参数仅充当策略选择器,即 const void *
的使用将其与之前显示的 const countability *
的 dispose 区分开来。
变得更智能
现在我们有了一种在创建任何类型对象的计数能力的方法,还需要什么才能使它与我们之前定义的 countable_ptr
一起工作?好消息:不需要任何额外操作!
class example { ... }; void simple() { countable_ptr<example> ptr(new(countable) example); countable_ptr<example> qtr(ptr); ptr.clear(); // set ptr to point to null } // allocated object deleted when qtr destructs
new(countable)
表达式定义了不同的分配和释放策略,并且与其他分配器一样,任何尝试混合您的分配策略的行为(例如,在使用 new(countable)
分配的对象上调用 delete
)都会导致未定义的行为。这类似于混合使用 new[]
和 delete
或 malloc
和 delete
时发生的情况。Countable 符合性的全部意义在于 Countable 对象与 countable_ptr
一起使用,这确保了正确的使用。
但是,意外情况会发生,您不可避免地可能会忘记使用 new(countable)
分配,而是使用 new
。在大多数情况下,可以通过扩展此处显示的代码以向 count
添加一个检查成员来检测此错误和其他错误,并在每次访问时验证检查。确保头部和实现源文件之间清晰分离的一个好处是,您可以引入此分配器的检查版本,而无需重新编译代码。
结论
本文介绍了两个关键概念。
- 使用基于通用需求的方法来简化和调整 COUNTED BODY 模式的使用。
- 通过控制分配,能够使用 RUNTIME MIXIN 模式动态地、非侵入地向固定类型添加功能。
将两者结合起来,产生了一种基本 COUNTED BODY 模式的新的变体,即 UNINTRUSIVE COUNTED BODY。您可以将此主题进一步扩展,并为 C++ 设计一个简单的垃圾回收系统。
countable_ptr
、countability
和 countable new
的完整代码也可获得。