XONOR 指针:排他性拥有和非拥有引用指针






4.96/5 (43投票s)
一个用于C++安全应用程序开发的智能指针系统。
本文已被
请使用新文章中提供的更成熟的版本
引言
这是一个适用于C++应用程序开发的智能指针系统,其中:
- 您编写的对象和引用管理代码比Java或C#少。
- 您不会失去C++提供的任何精确和确定的对象生命周期控制。
- 您不会遭受内存泄漏或悬空指针的困扰。
- 您不会遭受意外未能释放引用的困扰。
- 尽管存在少量的内存和执行开销,但其操作是直接的,没有迭代。
它遵循这样的原则:对象生命周期和内存使用应由作用域和程序员的显式设计确定性控制,而不应因未释放的次要引用而妥协,就像使用boost::shared_ptr
作为“通用解决方案”的做法一样。
它附带了一个演示和测试了智能指针系统许多方面的示例项目。这是一个使用ATL/WTL的Microsoft Visual Studio 2005项目。智能指针系统本身是纯C++,但这个实现使用了ATL集合类来提供数组和列表。
更新
本文及相关源代码已于2009年2月4日更新,以支持多线程。
文章文本已添加以下部分:
- 多线程引入的复杂性
- 影响多线程的设计决策
- 多线程支持如何工作
- 多线程支持的局限性
更改的更完整描述在修订历史中给出。
背景
多年来,存在一种成熟的C++编程模型,其中使用new
运算符动态创建的所有对象都被分配给一个智能指针,该指针可确保对象在适当的时间被删除——即智能指针被重置或超出作用域时。这种模型有利于消除内存泄漏,并且无需编写调用delete运算符的代码。这些智能指针可以被描述为独占所有权指针。示例包括:
std::auto_pointer
boost::scoped_ptr
这工作得很好,没有引入任何复杂性,是一种非常推荐的做法,但它仍然留下一个未解决的重大问题,这让许多开发人员——更重要的是他们的老板——感到脊背发凉——那就是“悬空指针”。
我编写的所有程序也要求我使用不拥有它们所指向对象的指针,它们只是作为程序操作的一部分来引用它们,例如,指向当前焦点的指针。对于这些,我一直使用原始指针,因为只有它们可用。问题在于,这些次要的非拥有引用很容易成为悬空指针。由于它们只是普通的原始指针,所以它们不知道所指向的对象何时已被删除。必须编写代码来确保在删除对象时,所有引用它的指针都首先被设置为
NULL
。这些代码可能很复杂、重复、丑陋、编写起来很乏味,因此很容易出错。
毫无疑问,花费在确保指针不悬空上的高技能(因此昂贵)的人力小时,甚至更糟糕的是试图找出应用程序为何会莫名其妙崩溃的尝试,导致了像Java和C#这样的依赖垃圾回收来管理对象生命周期的语言的流行。
我的感觉是,如果程序员选择Java或C#而不是C++是因为它们在某些任务上更方便,那么这很好,但如果他们害怕接触C++是因为担心悬空指针,那么让我们在C++中解决这个问题。
C++有垃圾回收器,但实际上,它们会将其变成另一种语言。首先,它们会严重影响许多封装了在析构函数中释放资源的类库。
似乎正在被接受的C++解决方案是将所有指针包装为强指针或共享指针,以便对象仅在最后一个指向它们的指针被重置或超出作用域时才被删除。这提供了与某物指向它们一样长的对象存活的保证,但不是等待垃圾回收器,而是在没有指针指向它们时立即删除它们。这是C++标准库TR1内存处理扩展所推荐的解决方案,该扩展采用了boost::shared_ptr
。这让我感到担忧,因为它有两个不利之处,不利于C++开发的健康。
- 一个是被广泛承认的——循环引用的问题。如果你使用
shared_ptr
作为所有次要引用,那么当两个对象直接或间接相互引用时,它们将永远不会被释放——每一个都会让另一个永远存活。这是由使用shared_ptr
引起的经典永久内存泄漏。推荐的解决方案是识别循环引用情况并用weak_ptr
打破循环。需要弱指针来打破强引用循环这一事实,仅仅说明了在最初使用强指针承担这些角色是多么不合适。循环引用并不总是容易识别,因此这代表了shared_ptr
引入的相当大的编程危险。 - 另一个问题几乎从未被提及,尽管它在垃圾回收世界中是等效的,现在已经成为一个问题,并有一个名称——“未能释放次要引用”。使用
shared_ptr
来包装所有指针确实消除了指针悬空的可能性,但这是通过延长它们所指向对象的生命周期来实现的。如果你想要这样,那就没问题,但如果只是忽略了将次要引用设为null
,那么你将毫无理由地保留它指向的对象在内存中——可能与你的设计意图相冲突。
在这两种问题中,编译器和运行时系统都无法知道你犯了错误,所以你甚至不会得到警告——直到稍后出现严重的内存问题,你才不会知道你的设计意图已被妥协。
这里遗漏的是,C++中的问题从来不是对象过早删除,而是次要指针因程序员的疏忽而继续指向不存在的对象。那么,为什么我们要通过延长对象寿命来掩盖程序员的错误呢?
简单来说:
- 为了迎合一个未置
null
的次要指针(这是一个疏忽),而延长一个(可能设计正确)对象的生命周期是错误的。 - 不要延长对象的寿命来迎合错误的指针——告诉错误的指针对象已被删除。
我担心的是,将所有指针设为shared_ptr
的做法本身就会导致粗心大意的编程,而这种模式专门隐藏了这种粗心大意的后果,这将降低C++开发的质量。
在考虑这一切的时候,我发现自己在想:“我想要的只是让我的次要引用在它们指向的对象被删除时自动测试为null
。”
这里提出的系统是仅仅做了一件事的结果:拥有一个用于非拥有引用指针的智能指针,其中包括一个机制,可以在它指向的对象被销毁时自动将其设置为NULL
。该机制必然需要对象的拥有者参与,因此需要一种新的拥有者指针类型来与之配合。为此,我称之为
XONOR指针 = eXclusive Ownership & Non Owning Reference 指针。
XONOR 指针
这是一个智能指针系统,具有一种消除内存泄漏和悬空指针的机制,该机制尊重独占所有权指针和非拥有引用指针之间的区别。智能指针系统为这两个指针提供了急需的声明性区分——在这里:
owner_ptr<T>
是类型为T
的对象的独占所有权指针。
它是动态分配对象的首要引用,也是其生命周期的控制器。实际上,您可以将其视为对象本身(这是您能接近它的程度!)。
ref_ptr<T>
是类型为T
的任何对象的非拥有引用,其生命周期已由owner_ptr
控制。
这是一个弱指针,但与第二类
weak_prt<T>
(TR1)不同,它是一个一流的指针,可以解引用并以与原始指针相同的方式使用。您可以在任何想要持有对已存在事物引用的地方使用它。它被简单地命名为ref_ptr
,以表示它只是一个观察引用,并且名称简短,以鼓励其频繁和几乎随意的使用。通过这种区分,可以构建一个正确处理内存的系统,没有异常,也没有新的编程危险。
此外,还提供了一个共享所有权指针,用于真正的共享所有权情况(而不是用于持有次要引用!)。
strong_ptr<T>
是共享所有权指针。
有时,您确实需要一个共享所有权指针;例如,用于控制具有广泛分布使用的资源或服务的生命周期。为此提供了
strong_ptr
。此外,
strong_ptr
可以应对STL集合的存储机制,这些机制通过创建临时副本读取元素。使用owner_ptr<T>
(或任何独占所有权指针),这个“复制出去”的过程会导致被指向的对象被删除。
构建一个完整的集成解决方案
本系统的目标是让C++程序员及其经理对他们的代码不会产生内存泄漏或遭受悬空指针的困扰有合理的信心。因此,它必须尽可能地涵盖他们可能做的事情。理想情况下,应该可以在不声明或使用任何原始指针的情况下进行编码。这个想法无法完全实现,因为大多数API都使用原始指针,但可以实现的是,原始指针的使用大大减少并且显式化,以便可以快速找到它们引起的问题。为此,已添加以下内容:
new_ptr<T>
有时,在将新对象分配给永久拥有者之前,需要对其进行一些操作——这甚至可能包括决定永久拥有者是谁:
new_ptr<T>
可以临时持有使用new
运算符创建的对象,然后再将其分配给owner_ptr
或strong_ptr
。这对于在将新对象添加到集合之前对其进行初始化非常有用。如果新对象未分配给拥有者,
new_ptr
将在其超出作用域时将其删除。此外,
new_ptr
可以用作对象创建函数的返回值。它的优点是,如果返回值未分配给拥有者,则对象将在函数返回时自动删除。一旦
new_ptr
已分配给拥有者,就可以从中获取一个ref_ptr
,它将指向新的拥有者。在
new_ptr
分配给拥有者之前,您无法从中获取次要引用(ref_ptr
)。如果尝试这样做,即使new_ptr
有值,次要引用(ref_ptr
)将仅持有NULL
。使用
owner_ptr
或strong_ptr
可以完成相同的初始化工作,因为两者都可以从函数返回;但是,new_ptr<T>
有三个优点:
- 它在函数或代码块中提供了一个声明性的指示,表明其目的是在将新对象分配给拥有者之前对其进行初始化。
- 它允许创建适用于
owner_ptr
和strong_ptr
的对象创建函数(不允许在owner_ptr
和strong_ptr
之间进行转换)。- 在许多情况下,它允许更简洁、更整洁的初始化序列,如下面的代码示例所示:
使用
owner_ptr<T>
//Adding a CRectangleBox to an array of VisualObjects (Common base class) //and returning the object created. //Using owner_ptr<CRectangleBox> to hold created object ref_ptr<CRectangleBox> AddRectangleBox(CPoint point, CSize size) { owner_ptr<CRectangleBox> newRectangleBox(new CRectangleBox); //object created and assigned to owner_ptr //take reference BEFORE assigning to owner ref_ptr<CRectangleBox> rBox=newRectangleBox; //we have to declare a ref_ptr to hold a reference the new object because //because once newRectangleBox has been assigned to the array, it will loose //ownership and be set to NULL m_VisualObjects.Add(newRectangleBox);//assign to owner //newRectangleBox is now EMPTY so we must use the reference rBox //to complete the initialisation and as the return value. rBox->m_Point=point; rBox->m_Size=size; return rBox; }使用
new_ptr<T>
//Adding a CRectangleBox to an array of VisualObjects (Common base class) //and returning the object created. //Using new_ptr<CRectangleBox> to hold created object ref_ptr<CRectangleBox> AddRectangleBox(CPoint point, CSize size) { new_ptr<CRectangleBox> newRectangleBox(new CRectangleBox); //object created and assigned to new_ptr //with new_ptr you CANNOT take a reference //until it has been assigned to an owner m_VisualObjects.Add(newRectangleBox);//assign to owner //we can continue to use newRectangleBox (the new_ptr) to complete //the initialisation and as the return value. newRectangleBox->m_Point=point; newRectangleBox->m_Size=size; return newRectangleBox; }区别在于,使用
owner_ptr
(与std::auto_ptr
一样),赋值是破坏性的。这意味着接收者获得所有权,而发送者被设置为NULL
——因为两个指针不能同时是独占所有者,所以必须如此。new_ptr
的行为不同;当它被赋值给拥有者时,它不会设置为NULL
,而是变成一个被动的非拥有观察者(在其短暂的生命周期内)。然后可以从中获取指向新拥有者的引用。使用
new_ptr<T>
不是必需的,但建议使用。
fast_ptr<T>
这是
ref_ptr<T>
的一个变体。每次解引用ref_ptr
时,它都会产生一些执行开销——它首先检查对象是否仍然存在(这就是它的目的)。如果你有一个代码块大量使用ref_ptr
,反复解引用它,那么每次都检查对象是否存在会有些过度和浪费执行时间。fast_ptr
的行为略有不同——它在解引用时根本不检查对象是否存在;相反,它在fast_ptr
初始化时将对象锁定在存在状态,并在其超出作用域时解锁。这意味着它不会保持对象存活,而是意味着在fast_ptr
使用对象期间尝试删除该对象将导致抛出异常。仅在您可以合理确信没有任何内容会尝试删除它所引用的对象的情况下,才在代码块中使用
fast_ptr
。fast_ptr
永远不能为null
。它必须在构造时使用有效的非空引用进行初始化,并且不能被重置。唯一的使用方法如下:if(rObject)//Test that rObject is valid { //Construct the fast_ptr from rObject fast_ptr<cobject> frObject=rObject; frObject->DoThis();//Use it..... frObject->DoThat(); //etc..... }使用
fast_ptr<T>
不是必需的,但它可以加快关键代码块的执行速度。
enable_ref_ptr_to_this<T>
类及其ref_ptr_to_this()
方法
有时有必要从类内部获取对
this
指针的引用。enable_ref_to_this<T>
是一个附加基类,您可以将其添加到您正在处理的类的继承列表中。它提供了ref_to_this()
方法,该方法返回一个包装this
指针的ref_ptr
。
enable_self_delete<T>
类及其self_delete()
方法
有些对象会自我删除:例如,无模式对话框。
enable_self_delete<T>
是一个附加基类,您可以将其添加到您正在处理的类的继承列表中。它必须由一个owner_ptr
初始化,要么在构造时,要么通过调用其set_owner()
方法。对象应通过调用self_delete()
方法来删除自身。self_delete
方法确保在删除对象时将owner_ptr
置为null
。在这种情况下,owner_ptr
不得存储在动态数组或任何移动其元素位置的容器中。
referencable(Type, value variable name)
是一个宏,用于按值声明对象,以便可以从中获取ref_ptr
。有时,我们声明一个按值成员变量,因为没有理由声明一个指针并使用new
运算符创建对象,但我们仍然希望能够从中获取安全的ref_ptr
引用。
ref_ptr_to(value variable name)
是一个宏,用于从使用referencable
宏按值声明的对象中获取ref_ptr
。
Xonor 指针的集合
在集合中存储owner_ptr<T>
时需要注意。似乎所有集合在添加元素时都会创建临时副本,而owner_ptr<T>
对此没问题(由于其破坏性复制构造函数)。这意味着您可以在ATL集合(如CAtlArray
和CAtlList
)中使用owner_ptr<T>
。
对于ATL,可选的XonorAtlColls.h文件定义了
owner_ptr_array<T>
和owner_ptr_list<T>
,如下所示:template <class T> class owner_ptr_array : public CAtlArray<owner_ptr<T> > { }; template <class T> class owner_ptr_list : public CAtlList<owner_ptr<T> > { };
某些集合(尤其是STL(标准模板库)集合)在读取元素时以及在执行各种内部操作时也会创建临时副本。owner_ptr<T>
对此不适用——这会导致涉及的元素删除其对象。您不能直接在STL集合中使用任何类型的独占所有权指针——它们可能会被意外删除。对于STL集合,您必须为拥有其对象的元素使用strong_ptr<T>
。strong_ptr<T>
,顾名思义,非常健壮,几乎可以承受任何东西。当然,如果您这样做,您将失去独占所有权的声明性保证,但如果您小心不与其他strong_ptr<T>
共享这些元素,它们仍然会表现得像独占所有者。
对于STL,可选的XonorSTLColls.h文件定义了
owner_ptr_array<T>
和owner_ptr_list<T>
,如下所示:template <class T> class owner_ptr_array : public vector<strong_ptr<T> > { }; template <class T> class owner_ptr_list : public list<strong_ptr<T> > { };
如果您确实希望拥有一个包含owner_ptr<T>
的STL集合(因为您希望声明性地保证它们不会被共享),您可以使用owner_ptr<T, false>
(当它超出作用域时不会删除其被指向对象)作为集合元素,并覆盖集合类以处理涉及从集合中删除元素的每个事件,以便调用reset()
到owner_ptr
。这需要一些工作,但会得到一个工程精良的通用集合,能够精确地完成您想要的工作。
将ref_ptr
存储在任何类型的集合中都没有问题,而fast_ptr
根本不属于集合。
使用规则
在此系统中的所有智能指针的操作都相同,只是fast_ptr
不能初始化为NULL
。
解引用运算符-> |
opObject->AMethod() |
所有指针 |
初始化为NULL |
owner_ptr<CObject> opObject; |
除fast_ptr 外的所有指针 |
测试非NULL |
if(opObject) |
除fast_ptr 外的所有指针 |
测试NULL |
if(!opObject) |
除fast_ptr 外的所有指针 |
比较测试两个指针是否指向同一对象。在比较两个owner_ptr
或一个owner_ptr
与一个strong_ptr
时,它将始终返回false
。两个owner_ptr
以及一个owner_ptr
和一个strong_ptr
不能指向(并因此拥有)同一个对象;因此,根据定义,比较必须返回false
。new_ptr
不支持比较。
比较 | if(opObject==rCurrentSelection) |
ref_ptr 与ref_ptr |
测试两个指针是否指向同一对象 |
ref_ptr 与fast_ptr |
|||
ref_ptr 与owner_ptr |
|||
ref_ptr 与strong_ptr |
|||
fast_ptr 与strong_ptr |
|||
fast_ptr 与owner_ptr |
|||
strong_ptr 与strong_ptr |
|||
strong_ptr 与owner_ptr |
始终返回false |
||
owner_ptr 与owner_ptr |
|||
new_ptr |
不支持比较 |
赋值和构造规则真正决定了这个系统的形态,因为它们决定了每种指针如何获得非空引用。
赋值和构造 | new_ptr |
由new 运算符返回的未强制转换的原始指针 |
owner_ptr |
由new 运算符返回的未强制转换的原始指针 |
|
一个new_ptr ,然后它变成一个被动的观察者,可以从中获取引用 |
||
另一个owner_ptr ,它将失去所有权并被重置为NULL |
||
(仅限 |
一个已分配给拥有者的new_ptr |
|
一个owner_ptr |
||
一个ref_ptr |
||
一个fast_ptr |
||
一个strong_ptr |
||
strong_ptr |
由new 运算符返回的未强制转换的原始指针 |
|
一个new_ptr ,然后它变成一个被动的观察者,可以从中获取引用 |
||
另一个strong_ptr |
||
一个ref_ptr ;如果不是对另一个strong_ptr 的引用,则赋NULL 。 |
owner_ptr
应用于另一个owner_ptr
的=
运算符执行破坏性赋值,与std:auto_ptr
相同。它转移所有权并将源指针留空。此操作不用于应用程序代码,但必须存在,以便owner_ptr
可以从函数返回。破坏性赋值是反直觉的(您会期望=
创建副本并保持源不变),因此它对出现在应用程序代码中没有帮助。相反,请使用两个点方法之一,以明确您正在做什么:
.make_copy(owner_ptr<T>)
创建另一个owner_ptr
所拥有的对象的副本。.steal_object(owner_ptr<T>)
获取另一个owner_ptr
对象并使其成为自己的。另一个owner_ptr
被保留为NULL
。这与=
运算符的效果相同。
同样,owner_ptr
有一个public
破坏性复制构造函数,这是必需的,以便它可以在集合中使用。尽管它是public
的,但不应在应用程序代码中使用它——在这种情况下,很难看出您为什么要这样做!
多态
多态性完全支持。所有赋值都提供了从派生类到基类的自动隐式下转换。
在多态系统中,基类析构函数声明为virtual
通常很重要——这确保delete
运算符始终删除使用new
运算符创建的派生类对象——否则,将只删除基类部分,留下内存泄漏(类切片)。这很容易忘记!使用此智能指针系统,则无需这样做——智能指针系统知道使用哪个派生类创建了对象,并确保删除同一类的对象。切勿在将其分配给拥有者之前或期间显式转换对象。
提供了一个显式的up_cast<T, U>()
函数,用于将所有类型从基类转换为派生类,当需要时。否则,不应进行任何显式转换。
仍然可能遇到麻烦的地方
此系统的易受攻击点是其与原始指针的接口。
owner_ptr
、strong_ptr
和new_ptr
由new
运算符返回的原始指针初始化。应立即将new
运算符返回的指针分配给这些智能指针之一,而不进行任何更改,然后永远不要再次使用它。例如:
owner_ptr<U> op=new T; //Correct and safe
不难看出以下操作会破坏系统:
-
T* pT=new T; //First bad move - // Don't even give a variable name to the pointer returned // by the new operator. owner_ptr<U> op1= pT; //Now we have two owner_ptrs thinking that they own //the same object - disastrous owner_ptr<U> op2= pT;
但这个不那么明显:
//op will eventually delete an object of type U even though //the object created was type T owner_ptr<U> op=(U*)new T;
它可能看起来像转换
(U*)
是无害的,甚至是有益的,但它隐藏了原始类型T
对owner_ptr
初始化的影响。此行代码将阻止反类切片机制工作,导致内存泄漏。owner_ptr
有自己的隐式转换运算符,但只有在用new
运算符返回的原始未转换指针进行赋值时,它才会正确作用。
为避免此类问题:
- 永远不要为
new
运算符返回的指针命名变量——始终直接将其分配给XONOR指针。 - 永远不要转换
new
运算符返回的指针——即使在分配给XONOR指针期间也不要。
如果您需要在将新对象提供给拥有者之前对其进行初始化,则应将
new
运算符返回的指针直接分配给new_ptr
,该指针可以临时持有它,然后再将其分配给拥有者。-
- 许多API接受原始指针作为参数。一般来说,这没问题——您可以通过使用点方法
.get_pointer()
传递任何XONOR指针的原始指针。API可以通过以下两种方式处理这些指针,这会破坏系统: - 删除您传递的指针——智能指针系统对此一无所知,稍后会再次尝试删除它。
- 存储您传递的指针——存储的指针不会知道何时删除对象,并且可能会悬空。
- 许多API的返回值是原始指针——在这里,您应该非常小心。您需要知道您收到的是次要的非拥有引用指针(在大多数情况下是这样),还是您必须拥有其所有权的指针(如果是这种情况,文档应明确说明)。
- XONOR指针可以抛出两种类型的异常:
xonor_ptr_exception
和xonor_ptr_fatal_exception
。这两种类型的异常都是由于您应纠正的编码错误而抛出的。没有任何外部因素,甚至内存或资源分配都不会抛出xonor_ptr_exceptions
。处理这两种异常并继续执行并不是一个好主意,但如果您坚持以这种方式掩盖错误,那么您可以处理xonor_ptr_exception
并继续执行,但切勿处理xonor_ptr_fatal_exception
并继续执行——这些致命异常通常在构造函数和析构函数中抛出,因此损坏已经造成。如果发生xonor_ptr_fatal_exception
,您就犯了一个严重的编码错误,必须纠正。 - 已经提到了
owner_ptr
应用于另一个owner_ptr
的破坏性复制构造函数和赋值运算符。误解这一点不会引起内存泄漏或悬空指针,但可能会让您感到意外。 enable_self_delete
附加基类严格来说不是XONOR指针系统的一部分;它是一个与之配合的实用程序,并且有能力破坏它。enable_self_delete
使用owner_ptr
的地址,因此owner_ptr
不得移动至关重要。这意味着它不得存储在动态数组中。将存储在动态数组中的owner_ptr
传递给enable_self_delete
可能会导致我们花费大量精力避免的最糟糕类型的内存损坏。到目前为止,我还没有找到一种方法来检测和防止这种情况,因此最好将enable_self_delete
视为潜在危险但有时非常有用。通常会阻止获取owner_ptr
的地址,但enable_self_delete
具有特殊权限执行此操作,但无法检测何时不适当。
大多数API不会这样做,如果它们这样做,通常会在文档中明确说明。
如果您必须拥有该对象,请直接将返回值分配给owner_ptr
、strong_ptr
或new_ptr
,就像它是new运算符的返回值一样。
如果(如大多数情况)它是一个非拥有引用指针,则使用它并将其存储为原始指针。我再说一遍——使用它并将其存储为原始指针。试图用XONOR指针持有它没有意义,因为我们对其生命周期一无所知。请小心处理此原始指针——这是您与正在调用的API之间的事。
工作原理
在大多数情况下,这些智能指针与其他智能指针类似,实现为模板类,具有大量运算符重载,并利用析构函数来删除被指向对象或减少其引用计数。不同之处在于,将几种具有不同角色的智能指针集成在一起以协同工作。
所有智能指针都持有指向类型为T
的对象和一个指向引用控制器的指针。
reference_controller
包含:
- 一个弱引用计数。
- 一个强引用计数。
- 一个指向原始对象且类型与原始对象相同的指针,并且有一个将在删除对象时调用的虚函数。
当两个或多个xonor_ptr
指向同一对象时,就会创建一个引用控制器。当存储的类型是创建类型的基类时,因为它需要反类切片机制的虚函数,所以在构造和从new
运算符赋值时也会创建它。
强引用计数表示拥有对象的xonor_ptr
的数量。这可以是:
- 1 -
owner_ptr
或单个strong_ptr
- 多 - 多个
strong_ptr
- 或 0 - 对象已被删除
强引用计数控制对象生命周期——当它降至零时,对象被删除。
弱引用计数表示引用对象的xonor_ptr
的数量。这可以是:
- 1 或多 - 仍有对该对象的引用
- 或 0 - 没有对象再引用它了
弱引用计数控制其自身的生命周期。当计数降至零时,它就被删除。
与XonorPtr
关联的引用控制器可能未专门化为与XonorPtr
相同的类型。这是因为引用控制器必须持有(并删除)与创建的原始对象相同类型的指针。XonorPtr
不知道其引用控制器的类型特化。它只知道它是一个未特化的基类——因此,当需要删除对象时,它会调用引用控制器的虚函数。
确实似乎持有指向创建对象的指针存在一些冗余。每个XonorPtr
以及引用控制器中都有一个副本。我认为冗余是真实的,如果我们有一个系统可以根据使用位置对其进行适当的类型转换,那么一个副本就足够了。我认为这可以做到,但我的努力虽然部分成功,却产生了过多的混淆,而我尤其想避免这一点。所以,目前,为了拥有可靠的、透明的正确操作保证,冗余仍然存在。
现在,让我们用最简单的方式来看“用户忘记null
引用”的场景。
owner_ptr<T> opT=new T; //line 1 - create object and assign to owner
ref_ptr<T> rT=opT; //line 2 - take a secondary reference from the owner
r->DoSomething(); //line 3 - use the secondary reference to call a method
opT=NULL; //line 4 - reset the owner and delete the object
if(rT!=NULL) //line 5 - test that the secondary reference is still valid
rT->DoSomething(); //line 6 - use the secondary reference to call a method
在第一行,opT
的内部指针由new T
赋值——没有创建引用控制器。在第二行,rT
的内部指针由opT
的内部指针赋值,并创建一个引用控制器,其强计数为1,弱计数为2。在第三行,rT
检查强计数是否非零,并调用DoSomething()
。在第四行,opT
被重置。对象被销毁,其内部指针被设置为NULL
。同时,强计数被设置为零,弱计数减少到1。在第五行,rT
被测试是否非空,并返回false
,因为强计数为零。指针现在知道对象已消失,因此将自身重置并释放引用控制器,将其引用计数减少到零,从而导致引用控制器被删除。第六行不会被调用。
如果省略了非空测试,那么解引用运算符将在强计数上进行相同的检查,并将抛出异常。
关键功能是reference_controler
可以比其关联的对象更长寿,并继续存在,以便任何剩余的次要引用都可以知道对象不再存在。
strong_ptr
具有与WeakCount
相同的交互,但此外在获取引用时会增加StrongCount
,并在其超出作用域或被重置时减少它。
总结:
- 当
StrongCount
递减到零时,指向的对象被删除。对象的生命结束。 owner_ptr
可以简单地删除对象。如果它有一个reference_controler
,其StrongCount
将被设置为零。只有当owner_ptr
是最后一个指向它的引用时,reference_controler
本身才会被删除。- 当
WeakCount
递减到零时,reference_controler
本身被删除。不再有需要知道它的次要引用。
还有两个进一步的复杂情况:
如果一个对象被strong_ptr
持有,那么它的弱计数将被保存为一个负数。这是为了能够从ref_ptr
赋值strong_ptr
,如果它引用的对象具有共享所有权strong_ptr
。这是正确且有用的。负弱计数表示它引用了一个具有共享所有权的对对象,因此可以这样做。否则,该对象是独占拥有的,这是不可能的——strong_ptr
将被简单地赋为NULL
。
fast_ptr
使用负的StrongCount
来锁定对象,防止其被销毁。如果尝试删除对象时StrongCount
为负(例如,其主要引用超出作用域),则会抛出异常。
一个副作用是,如果从strong_ptr
获取fast_ptr
,那么StrongCount
已经以正值使用,这会产生冲突。解决方案是fast_ptr
放弃锁定概念,并增加StrongCount
,从而充当另一个strong_ptr
。这实际上不是一个问题,因为对象不再直接由作用域控制,而且由于fast_ptr
通常具有较短的生命周期,因此不太可能不自然地延长对象寿命。无论如何,如果fast_ptr
是最后一个使共享对象在超出作用域时保持存活的引用,它将抛出异常。
我最担心的一件事是reference_controlers
的分配。起初,我让它们直接从堆中分配,使用new
运算符,然后我意识到两个不良后果:
- 频繁地从堆分配小对象成本很高。
- 任何由于对已删除对象的活动引用而保留在内存中的
reference_controler
都会导致碎片化,使新的内存分配更加复杂。
解决方案是创建一个池,从中分配reference_controler
。启动时创建一个包含100个对象的池,如果该池耗尽,则会额外扩展一个块,该块的大小增加10%,最多可扩展100个块。这种轻微的指数增长率不会使块分配的粒度过于剧烈,但允许最多1300万个引用控制器。
想法是这个
比这个更好
虽然第一个为引用控制器预留了100个槽,而第二个只占用了4个活动reference_controler
所需的空间,但第二种情况对未来的内存分配更具问题。
引用控制器池始终知道下一个可用槽的位置(因为它们大小相同,所以很容易实现),因此分配和取消分配非常快。
由于引用控制器池是全局的,其分配和取消分配方法受到单个临界区的保护,因此在多个线程中使用XONOR指针不会导致线程冲突。如下所示,这并不意味着这些智能指针是线程安全的——它们不是!
源代码的透明度
我已经完全修改了实现这些智能指针的源代码布局,以使其操作尽可能透明。
每个智能指针类都有少量
private
或protected
方法来执行特定操作,每个方法都有一个像name
这样的动词。它们的布局与我通常布局代码的方式相同——每件事都在新的一行,有缩进和大量的空白。不同类型的智能指针相互交互意味着存在大量的
public
方法,表示它们之间的可能转换——主要是构造函数和赋值运算符的重载。这些方法被布局为单行,构成了一系列简单的动词调用,就像private
/protected
方法一样。这使得更容易获得它们工作方式的概述,检查它们的一致性,并验证它们的正确性。
我特意这样做,希望它能鼓励程序员尝试并考虑采用它。要信任这个系统,仅仅同意它是一个好主意是不够的,您还必须能够看到它做得对!
开销和性能
是的——这个系统存在开销。
- 总的来说,XONOR指针占用的空间是使用原始指针的三倍。
- 将一个XONOR指针分配给另一个涉及执行几行代码。
- 解引用
ref_ptr
s涉及一个额外的步骤,即检查强引用计数。
减轻这种情况
- 解引用
owner_ptr
或strong_ptr
仅涉及对其内部指针的非空检查。 - 解引用
fast_ptr
是即时的——就像它是原始指针一样。 - 没有执行开销涉及任何迭代或集合遍历。
- 引用控制器池避免了引用控制器在堆上的持续构造和销毁。
多线程引入的复杂性
当对象的所有操作都在一个线程中时,我们可以进行简化。例如,我们可以允许直接解引用ref_ptr
。这是因为我们可以确定在以下代码中:
ref_ptr<T> rp;
…
….
if(rp)
rp->DoFunction();
在if(rp)
和rp->DoFunction();
之间,没有其他任何东西可以更改rp
。
如果对象也在另一个线程中被引用,那么我们就不能依赖这一点。在if(rp)
和rp->DoFunction();
之间,另一个线程可能已经使rp
无效。
此外,在单线程中,引用计数可以实现为简单的增量++
和减量--
。如果对象也在另一个线程中被引用,那么两个线程可能试图同时更改引用计数,我们需要使用InterlockedIncrement
和InterlockedDecrement
,这会带来更多的开销。
影响多线程的设计决策
- 只有具有共享所有权的对象才能在线程之间共享。
- 一个线程可以持有另一个线程中对象的
shared_ptr
或ref_ptr
。 - 指向另一个线程中对象的
ref_ptr
不能直接解引用;必须先将其转换为strong_ptr
才能使用,并且在使用前应对strong_ptr
进行测试。
我们将按相反顺序检查这些:
- #3。
ref_ptr
的理念是它不会使它指向的对象保持存活。因此,如果我们持有另一个线程中对象的ref_ptr
并且它被删除了,那么当我们去使用它时,我们首先测试它,发现它是NULL
,这完全是正确的。问题在于,如果我们测试它并发现它是有效的,然后紧接着在执行下一行代码之前,它被另一个线程删除了。确保这种情况不会发生的唯一方法是拥有该对象并在使用它时将其保持存活。这就是为什么我们必须先将其转换为strong_ptr
。 - #2。有时我们想持有另一个线程中对象的指针,并且我们希望该对象由该指针保持存活——在这种情况下,我们持有该对象在另一个线程中的
strong_ptr
。有时,我们只是想持有对象的指针,只要对象存在。在这种情况下,我们使用ref_ptr
;如果它转换成的strong_ptr
测试为NULL
,那么我们就知道对象不再存在了。如果strong_ptr
有效,那么我们就知道它仍然存活,并且我们将保持它存活,只要strong_ptr
有效。 - #1。生命周期由单次所有权控制的对象不能在线程之间共享。这是因为单个拥有者可以无条件地销毁对象,而另一个线程中的用户对此无能为力。我确实考虑过允许暂时放松单次所有权(另一个线程每次处理指针时都可以使对象短暂存活),但我决定如果你声明单次所有权,那么你就想要确定性的销毁。你希望你的析构函数被调用,并且希望它们按正确的顺序调用(通常由作用域良好控制)。问题是,如果你使一个单次拥有的对象短暂存活,它可能会延迟调用其析构函数,并导致其被错误地调用。我们不想弄乱这个!
此外,如果我们先进行测试,然后进行转换——它可能会在测试和转换之间被另一个线程删除。因此,测试和增加强引用计数必须是同一个操作:从ref_ptr
到strong_ptr
的转换已被修改以实现这一点。strong_ptr
在strong_ptr
有效期间保持对象存活。如果对象无效,那么strong_ptr
将测试为NULL
。还向ref_ptr
添加了一个Lock()
方法来实现完全相同的功能,但更明确。Lock()
返回一个strong_ptr
,您可以在测试它不为null
后使用它。
所以,我们有两种方法可以跨线程共享对象指针:
线程 1
strong_ptr<T> opT;
线程 2
strong_ptr<T> opT2= opT;
if(opT2)
opT2->UseIt();
和
线程 1
strong_ptr<T> opT;
线程 2
ref_ptr<T> rT= opT;
//code block to scope strong_ptr<T> opT2
{
strong_ptr<T> opT2= rT;
if(opT2)
opT2->UseIt();
}//On exit from this block, strong_ptr<T> opT2 is destroyed
//and ref_ptr<T> rT returns to weak behaviour
重申这些规则的否定形式很重要——您不能做什么。
- 将任何类型的
owner_ptr
指针传递给另一个线程。owner_ptr
严格为单线程。 - 对另一个线程中的对象的
ref_ptr
执行任何操作,除了将其转换为strong_ptr
或将其设置为NULL
。将ref_ptr
设置为NULL
只是告诉它不要再关注它指向的内容,这从来不是问题。
理解您不能做什么很重要,因为没有办法在不引入不可接受的开销的情况下强制执行这些规则。我认为ref_ptr
可以在单线程内直接解引用非常重要,而为了防止其在多线程中被误用而禁止它没有意义。
以下示例说明了错误用法:
线程 1
owner_ptr<T> opT;
线程 2
ref_ptr<T> rT= opT; //never share an owner_ptr between threads
和
线程 1
strong_ptr<T> opT;
线程 2
ref_ptr<T> rT= opT;
if(rT)
//Do not directly test a ref_ptr
//to an object in another thread
rT ->UseIt();
//Do not directly de-reference a ref_ptr
//to an object in another thread
多线程支持如何工作
首先,我们已经在引用控制器中携带了共享所有权指示——如果它是共享的,那么弱引用计数将保存为一个负数。由于共享所有权对象可以跨线程共享,因此对引用计数的所有更改都使用InterlockedIncrement
和InterlockedDecrement
进行,如果弱引用计数测试为负。
第二个问题是,在从ref_prt
转换为strong_ptr
期间,测试和递增强引用计数应该是同一个操作。
所有从ref_prt
到strong_ptr
的转换都调用strong_ptr
的内部方法ShareOwnership(spT)
,并且在这里我们进行必要的更改。
inline void ShareOwnership(ref_ptr<T> const& spT) { if(spT.m_pReferenceControler!=NULL)//Either initial NULL or has value forever { if(InterlockedDecrement(&(spT.m_pReferenceControler->m_WeakCount))<0) { if(InterlockedIncrement(&(spT.m_pReferenceControler->m_StrongCount))>1) { m_pReferenceControler=spT.m_pReferenceControler; m_pT=spT.m_pT; } else InterlockedDecrement(&(spT.m_pReferenceControler->m_StrongCount)); } else InterlockedIncrement(&(spT.m_pReferenceControler->m_WeakCount)); } }首先,我们测试是否有引用控制器。
m_pReferenceControler
指针要么为NULL
(我们从未指向它),要么只要有任何东西指向它,它就是有效的——它不能被另一个线程从我们这里删除——这里没有问题。接下来是第一个重要的技巧。我们想知道对象是否具有共享所有权(构建指向单次拥有对象的有效
strong_ptr
是非法的)。负弱引用计数告诉我们这一点,但我们不想在另一个线程更改其值时读取它(我们可能会读取一个中间值,这个值是胡说)。所以,我们调用InterlockedDecrement
并读取返回值(保证有意义)。如果它是负数,那么我们就拥有共享所有权,然后可以继续(记住我们已经增加了弱计数)。否则,我们拥有单次所有权,strong_ptr
应保持为null
,我们调用InterlockedIncrement
来恢复弱计数。我们现在对强引用计数做类似的操作。请记住,如果我们先测试它再递增它,我们就有可能在一次操作和另一次操作之间删除它。所以,我们只是继续调用
InterlockedIncrement
,而不去知道对象是否有效(我们总是知道引用控制器是有效的)。如果InterlockedIncrement
的返回值大于1
,那么我们就知道对象存在,然后我们继续并将其转换为有效对象。但是,如果InterlockedIncrement
的返回值仅为1
,那么我们就知道那里没有对象——它之前的强计数是0
——所以我们调用InterlockedDecrement
来恢复它的值,并且不再进行任何操作,将strong_ptr
保留为null
。
这种小心仅在从ref_ptr
到strong_ptr
的转换时需要,因为只有ref_ptr
指向的对象才可能被意外删除。
多线程支持的局限性
正确使用此多线程支持可以保证线程之间不会发生关于对象存在性和指针有效性的冲突。智能指针系统本身只能做到这一点。
它不会阻止两个线程同时修改同一个对象。任何在线程之间共享的对象都需要有自己的保护机制,以防止线程在尝试修改同一数据时发生冲突。
它不提供集成的数据共享解决方案——这是一个复杂的问题!它只是保证使用这些指针不会破坏您创建的任何解决方案。
使用源代码
将所有XonorPtrs头文件(在XonorPtrs文件夹中找到所有以'Xonor'开头的头文件)复制到include目录,并在您要构建的文件中包含以下内容:
#include <XonorPtrs.h>
#include <XonorPtrs.hpp>
using namespace XonorPtrs;
如果您编译多个*.cpp文件,那么您应该只在一个文件中包含XonorPtrs.hpp。
要使用它,只需开始将您的指针声明为XonorPtrs
,然后像往常一样编码。如果您坚持使用它,您可以享受不必跟踪和NULL
大量次要引用的便利。
注意事项
此系统旨在使普通的面向对象编程更舒适、风险更小,我试图涵盖我在编程经验中发现的各种场景。
- 如果您尝试进行奇怪且非常规的构造,它可能无法正常工作——我试图通过生成编译器错误来禁止不寻常的使用,但我怀疑这并非万无一失。
- 它不是“线程安全的”。它不提供一个机制,允许两个线程同时访问智能指针。
开发智能指针系统的一个问题是,如果它不能正常工作,它就会导致它被设计来解决的那些问题。我在这里展示的东西是有效的,并且没有失败我测试过的任何测试,但是实现它的代码足够长且复杂,以至于有可能包含未检测到的错误或疏忽。我希望通过使代码透明,我使其更容易发现和纠正任何错误,并且这降低了采用它的风险。
示例程序
示例程序是一个简单的图表构建器/编辑器。您可以创建圆角矩形和椭圆,可以以图形方式移动和调整它们的大小,可以用线条将它们连接起来,用文本填充它们,并为每个框选择单独的字体和文本颜色。
它被专门编写来演示和测试XONOR指针的使用。在大多数情况下,我试图找到XONOR指针功能的合理应用。
有两个经典的类多态子系统:
- Visual Objects,这是一个非常具体面向对象的系统。
- Mouse Modes,这是一个更抽象的多态用法。但仍然完全实用。
这些演示和测试了new_ptr
、owner_ptr
、owner_ptr_array
、ref_ptr
、fast_ptr
、enable_ref_ptr_to_this
及其ref_ptr_to_this
方法以及referencable
和ref_to
宏的使用。
有一个无模式对话框,演示和测试了以下内容的使用:
enable_self_delete
及其self_delete
方法
有一个字体池,演示和测试了以下内容的使用:
strong_ptr
这是一个WTL应用程序——您必须安装WTL库——可从SourceForge免费获取。
摘要
它似乎工作得相当可靠,最重要的是,它通过自动置null
来解决程序员忘记null
次要引用的问题,而不是隐藏它。我已经将它应用到我所做的所有新工作中,并且已经将它回顾性地实现了我编写的许多库代码中。所有这些都没有给我带来任何问题,所以我认为我可以向其他人推荐它,并认为它在编码舒适性以及内存和指针安全性方面将C++置于C#和Java之上,而不会损害C++的工作方式。
我不是智能指针专家。我只是需要一个我可以使用的内存保护系统,它不会破坏良好的C++编程的完整性。如果有人是专家,能够以更优雅或更有效的方式重写它,或者更全面地保证或验证其完整性,我将非常高兴。
修订历史
- 2008年5月14日:首次提交。
- 2008年6月11日:源代码和文章已更新。
- 代码更改
- 放弃了尝试优化引用控制器中保存的指针,从而实现了更直接的操作。
owner_ptr
遵循std::auto_ptr
的示例,赋予了破坏性复制构造函数和赋值运算符,但明确建议不要在应用程序代码中使用。- 引用控制器池具有轻微的指数增长率。
- 重新设计了代码布局,使其更易读、更透明、更易于验证。
- 文章章节更改
- XONOR 指针
- 构建一个完整的集成解决方案
- XONOR 指针的集合
- 使用规则
- 仍然可能遇到麻烦的地方
- 工作原理
- 源代码的透明度
- 使用源代码
- 2008年6月27日:源代码更新以解决构建问题并符合Visual Studio 2008。
- 2009年2月4日:源代码和文章已更新。
- 代码更改
owner_ptr
之间的赋值已设为非法——请使用显式.make_copy
或.steal_object
点方法。- 复制构造函数仍然合法,因为集合需要隐式使用它。
- 对
reference_controller
、strong_ptr
和ref_ptr
的更改以支持多线程。 - 新文章章节
- 多线程引入的复杂性
- 影响多线程的设计决策
- 多线程支持如何工作
- 多线程支持的局限性