XONOR pointers: eXclusive Ownership & Non Owning Reference pointers

XONOR pointers: eXclusive Ownership & Non Owning Reference pointers

 

This article has been superceeded by:

A Sensible Smart Pointer Wrap for Most of Your Code

Please use the more mature version presented in the new article

Sample Image

Introduction

Here is a smart pointer system suitable for C++ application development, in which:

  • You write less object and reference management code than in Java or C#.
  • You lose none of the precise and deterministic control of object lifetimes that C++ provides.
  • You do not suffer from memory leaks or dangling pointers.
  • You do not suffer from unintended failure to release references.
  • Although there is a small memory and execution overhead, its operation is direct and without iterations.

It adheres to the principle that object lifetimes and memory use should be deterministically controlled by the scope and the programmer's explicit design, and should not be compromised by unreleased secondary references as occurs with the practice of using boost::shared_ptr as a 'catch all' solution.

It is accompanied by an example project which demonstrates and tests many aspects of the smart pointer system. It is a Microsoft Visual Studio 2005 project using ATL/WTL. The smart pointer system itself is pure C++, but this implementation makes use of ATL collection classes to provide arrays and lists.

Update

This article and the associated source code have been updated on 4 Feb 2009 to include support for multithreading.

The following sections have been added to the text of the article:

  • Complexities introduced by multithreading
  • Design decisions affecting multithreading
  • How multithreading support works
  • Limitations of multithreading support

A more complete description of the changes is given in the revision history.

Background

There has been, for many years, a well established model of C++ programming in which all objects dynamically created with the new operator are assigned to a smart pointer which ensures that the object is deleted automatically at the appropriate time - which is when the smart pointer is reset or goes out of scope. This model is good for eliminating memory leaks, and also eliminates the need to write code that calls the delete operator. These smart pointers can be described as exclusive ownership pointers. Examples are:

  • std::auto_pointer
  • boost::scoped_ptr

This works very well, introduces no complications, and is a thoroughly recommended practice, but it still leaves a large problem unsolved which sends shivers up the spine of many developers and, more importantly their bosses, - the 'dangling pointer':

All of the programs I have written have also required me to use pointers which don't own the objects they point at, they just reference them as part of the operation of the program, e.g., a pointer to the current focus. For these, I have always used raw pointers because that is all there are. The problem is that these secondary non owning references can very easily become dangling pointers. Being just plain raw pointers, they don't know when the object they point at has been deleted. Code has to be written to ensure that when an object is deleted, all pointers that reference it are set to NULL first. This code can be complex, repetitive, ugly, tedious to write, and therefore very easily faulty.

There is no doubt that the highly skilled (and therefore expensive) man hours that are lost ensuring that pointers don't get left dangling, or even worse trying to find out why an application crashes inexplicably, have led to the popularity of languages that rely on garbage collection to manage object lifetimes, such as Java and C#.

My feeling is that if programmers choose Java or C# rather than C++ because they are more convenient for certain tasks, then that is fine, but if they are frightened to touch C++ for fear of dangling pointers, then let's fix the problem in C++.

There are garbage collectors for C++, but really, they turn it into another language. For one thing, they will play havoc with many class libraries that encapsulate the releasing of resources in destructors.

The solution that seems to be gaining acceptance for C++ is to wrap all pointers as strong or shared pointers so that objects are only deleted when the last thing pointing at them is reset or goes out of scope. This provides the same guarantee that objects will stay alive as long as something points at them, but instead of waiting for a garbage collector, they are deleted promptly as soon as nothing points at them. This is the solution recommended by C++ Standard Library TR1 extension for memory handling which has adopted the boost::shared_ptr. This worries me because it has two drawbacks that are not good for the health of C++ development:

  1. One is widely acknowledged - the problem of cyclic references. If you use shared_ptr for all secondary references, then when two objects directly or indirectly refer to each other, they will never be released – each keeps the other alive forever. This is a classic permanent memory leak caused by using shared_ptr. The recommended solution is to identify the cyclic reference situation and break the cycle with a weak_ptr. The fact that a weak pointer is needed to break a cycle of strong references simply illustrates how inappropriate it is to use strong pointers for these roles in the first place. Cyclic references are not always easy to identify, so this represents a considerable programming hazard that shared_ptr introduces.
  2. The other problem is hardly ever mentioned, although it is equivalent in the world of garbage collection, has now become an issue, and has a name "failure to release secondary references". Using shared_ptr to wrap all pointers does eliminate the possibility of pointers dangling, but it does so by extending the lifetime of the objects they point at. This is OK if it is what you want, but if it was simply an oversight not to null the secondary reference, then you will be retaining the object it points at in memory for no good reason – probably in conflict with your design intentions.

With both of these problems, neither the compiler nor the run-time system has any way of knowing that you have made a mistake, so you don't even get a warning – you will not know that your design intentions have been compromised until serious memory problems emerge further down the line.

What is being missed here is that the problem in C++ never was objects being deleted too early, it was secondary pointers continuing to point at them when they are not there because of an oversight of the programmer. So, why are we trying to cover up the error of the programmer by making the objects live longer?

To put this simply:

  • To extend the lifetime of an object (which was probably correctly designed) to appease a non nulled secondary pointer (which was an oversight) is plain wrong.
  • Don't extend the life of the object to appease the erroneous pointer – tell the erroneous pointer that the object has been deleted.

I worry that the very action of making all pointers shared_ptrs will lead to sloppy programming, and that the fact that this model specifically hides the consequences of this sloppiness is going to degrade the quality of C++ development.

While considering all of this, I found myself thinking "all I want is that my secondary references automatically test as null if the object they were pointing at has been deleted".

The system presented here is the result of doing just one thing: having a smart pointer for non-owning reference pointers which includes a mechanism to automatically set them to NULL when the object they point at is destroyed. The mechanism necessarily has to have the owner of the object involved, therefore a new type of owner pointer is required to work with it. For this reason, I have called it:

XONOR pointers = eXclusive Ownership & Non Owning Reference pointers.

XONOR Pointers

This is a smart pointer system with a mechanism to eliminate both memory leaks and dangling pointers which respects the distinction between exclusive ownership pointers and non-owning reference pointers. A smart pointer system offers the opportunity for a much needed declarative distinction between the two - and here it is:

owner_ptr<T> is an exclusive ownership pointer to an object of type T.

It is the primary reference to a dynamically allocated object and the controller of its lifetime. Effectively, you can think of it as being the object itself (it is as close as you can get to it!).

ref_ptr<T> is a non owning reference to any object of type T whose lifetime is already controlled by an owner_ptr.

This is a weak pointer, but unlike the second class weak_prt<T> (TR1), it is a first class pointer that can be de-referenced and used in the same way as a raw pointer. You use it everywhere that you want to hold a reference to something that already exists. It is named simply ref_ptr to indicate that it is just an observing reference, and the name is brief to encourage its frequent and almost casual use.

With this distinction made, it is possible to construct a system which handles memory correctly with no anomalies and no new programming hazards.

In addition, a shared ownership pointer is provided for genuine cases of shared ownership (not for holding secondary references!!).

strong_ptr<T> is a shared ownership pointer.

Sometimes, you really do want a shared ownership pointer; for instance, to control the life of a resource or service with widely distributed use. strong_ptr is provided for this.

Also, a strong_ptr survives the storage mechanisms of STL collections that read elements out by making temporary copies. With owner_ptr<T> (or any exclusive ownership pointer), this 'copying out' process has the undesirable effect of causing the pointee to be deleted.

Building a Complete Integrated Solution

The aim of this system is to provide C++ programmers and their managers with reasonable confidence that their code will not generate memory leaks or suffer from dangling pointers. Therefore, it must embrace as much as possible of what they are likely to do. Ideally, it should be possible to code without declaring or using any raw pointers. This idea cannot be fully achieved because most APIs work with raw pointers, but what can be achieved is that the use of raw pointers is considerably reduced and made explicit so that any problems they cause can be quickly found. With this aim, the following has been added:

new_ptr<T>

Sometimes, some work needs to be done on a new object before assigning it to a permanent owner - this may even include deciding what is to be the permanent owner or owners: new_ptr<T> can temporarily hold an object created with the new operator before assigning it to an owner_ptr or strong_ptr. This is useful for initialising a new object before adding it to a collection.

If the new object is not assigned to an owner, new_ptr will delete it when it goes out of scope.

Also, new_ptr can be used as the return value of an object creation function. It has the advantage that if the return value is not assigned to an owner, then the object will be deleted automatically when the function returns.

Once a new_ptr has been assigned to an owner, a ref_ptr can be taken from it which will point at the new owner.

You cannot take a secondary reference (ref_ptr) from a new_ptr until it has been assigned to an owner. If you attempt to, the secondary reference (ref_ptr) will simply hold NULL even though the new_ptr has a value.

The same initialisation work can be done using owner_ptr or strong_ptr as both can be returned from functions; however, new_ptr<T> has three advantages:

  1. It provides a declarative indication within a function or code block that its purpose is the initialisation of a new object before assigning it to an owner.
  2. It allows creation functions to be created that work for both owner_ptr and strong_ptr (conversion between owner_ptr and strong_ptr simply cannot be allowed).
  3. It allows a slightly more concise and tidier initialisation sequence in many cases. as shown in the code samples below:

Using owner_ptr<T>:

C++
//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;
}

Using new_ptr<T>:

C++
//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;
}

The difference is that with owner_ptr (as with std::auto_ptr), assignment is necessarily destructive. This means that the assignee takes ownership and the assigner is set to NULL - this has to be the case because two pointers can't both be exclusive owners. new_ptr behaves differently; instead of setting to NULL when it is assigned to an owner, it becomes a passive non-owning observer (for the rest of its short lifetime). References can then be taken which will point to the new owner.

It is not necessary to use new_ptr<T>, but its use is recommended.

fast_ptr<T>

This is a variant of ref_ptr<T>ref_ptr has a bit of execution overhead each time you deference it - it checks that the object is still there first (that is what it is for). If you have a code block that makes extensive use of a ref_ptr, de-referencing it many times, then it is a bit obsessive and wasteful of execution time to check that the object exists each time. fast_ptr behaves slightly differently - it doesn't check that the object exists at all when it is de-referenced; instead, it locks the object into existence when the fast_ptr is initialised, and unlocks when it goes out of scope. This doesn't mean it keeps the object alive, it means that any attempt to delete the object while the fast_ptr is using it will cause an exception to be thrown.

Use fast_ptr only within code blocks within which you can be reasonably confident that nothing is going to try and delete the object it is referencing. A fast_ptr can never be null. It must be initialised on construction with a valid non null reference, and cannot be reset. The only way to use it is as follows:

C++
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.....
}

It is not necessary to use fast_ptr<T>, but its use can speed up the execution of critical code blocks.

enable_ref_ptr_to_this<T> class and its ref_ptr_to_this() method:

Sometimes it is necessary to take a reference to the this pointer from within a class. enable_ref_to_this<T> is an add-in base class which you can add to the inheritance list of the class that you are working with. It provides the ref_to_this() method which returns a ref_ptr wrapping the this pointer.

enable_self_delete<T> class and its self_delete() method:

Some objects delete themselves: for instance, modeless dialogs. enable_self_delete<T> is an add-in base class which you can add to the inheritance list of the class that you are working with. It must be initialised by an owner_ptr, either on construction, or by calling its set_owner() method. The object should delete itself by calling the self_delete() method. The self_delete method ensures that the owner_ptr is nulled as the object is deleted. In this case, the owner_ptr must not be stored in a dynamic array or any kind of container that moves its elements around in memory.

referencable(Type, value variable name) is a macro for declaring objects by value so that a ref_ptr can be taken from them. Sometimes, we have a class member declared by value because there is no reason to declare a pointer and create the object with the new operator, but we still want to be able to take a safe ref_ptr reference from it.

ref_ptr_to(value variable name) is a macro for taking a ref_ptr from an object declared by value using the referencable macro.

Collections of Xonor Pointers

Some care is needed with storing owner_ptr<T> in collections. It seems that all collections make temporary copies of their elements as they are added, and owner_ptr<T> is OK with this (due to its destructive copy constructor). This means that you can use owner_ptr<T> in ATL collections such as CAtlArray and CAtlList.

For ATL, the optional XonorAtlColls.h defines owner_ptr_array<T> and owner_ptr_list<T> as follows:

C++
template <class T> class owner_ptr_array : public CAtlArray<owner_ptr<T> >
{
};
template <class T> class owner_ptr_list : public CAtlList<owner_ptr<T> >
{
};

Some collections (in particular, STL (Standard Template Library) collections) also make temporary copies of their elements as they are read out and while performing various internal operations. owner_ptr<T> is not ok with this - it causes the elements involved to delete their objects. You cannot directly use any kind of exclusive ownership pointer in STL collections - they may become unintentionally deleted. For STL collections, you must use strong_ptr<T> for elements that own their object. strong_ptr<T>, as its name suggests, is very robust, and will survive just about anything. Of course, if you do this, you lose the declarative guarantee of exclusive ownership, but if you are careful not to share these elements with other strong_ptr<T>s, they will still behave as exclusive owners.

For STL, the optional XonorSTLColls.h defines owner_ptr_array<T> and owner_ptr_list<T> as follows:

C++
template <class T> class owner_ptr_array : public vector<strong_ptr<T> >
{
};

template <class T> class owner_ptr_list : public list<strong_ptr<T> >
{
};

If you really want to have an STL collection that holds owner_ptr<T>s (because you want a declarative assurance that they will not be shared), you can use owner_ptr<T, false> (which doesn't delete its pointee when it goes out of scope) for the collection elements, and override the collection class to handle every occurrence that involves removing elements from the collection so that reset() is called on the owner_ptr. This is a bit of work, but results in a solidly engineered generic collection which does exactly what you want.

There is no problem whatsoever with storing ref_ptr in any kind of collection, and fast_ptr just doesn't belong in collections.

Rules of Use

The following operations are the same for all smart pointers in this system, except that fast_ptr cannot be initialised as NULL:

dereference operator -> opObject->AMethod() All pointers
initialise as NULL owner_ptr<CObject> opObject;<br />owner_ptr<CObject> opObject(NULL);<br />owner_ptr<CObject> opObject=NULL; All pointers except fast_ptr
test for non NULL if(opObject)<br />if(opObject!=NULL) All pointers except fast_ptr
test for NULL if(!opObject)<br />if(NULL==opObject) All pointers except fast_ptr

Comparisons test if two pointers point at the same object. In the case of comparing two owner_ptrs or an owner_ptr with a strong_ptr, it will always return false. Two owner_ptrs and also an owner_ptr and a strong_ptr cannot point at (and therefore own) the same object; therefore, by definition, the comparison must return falsenew_ptr does not support comparison.

comparison if(opObject==rCurrentSelection) ref_ptr with ref_ptr Tests that both pointers point at the same object
ref_ptr with fast_ptr
ref_ptr with owner_ptr
ref_ptr with strong_ptr
fast_ptr with strong_ptr
fast_ptr with owner_ptr
strong_ptr with strong_ptr
strong_ptr with owner_ptr Always returns false
owner_ptr with owner_ptr
new_ptr Does not support comparison

The rules of assignment and construction are what really determines the shape of this system, because they determine how each kind of pointer gets to hold a non null reference.

assignment and construction new_ptr an uncast raw pointer returned by the new operator
owner_ptr an uncast raw pointer returned by the new operator
new_ptr which then becomes a passive observer from which references can be taken
another owner_ptr which will lose ownership and be reset to NULL

ref_ptr & fast_ptr

(fast_ptr construction only)

new_ptr that has been assigned to an owner
an owner_ptr
ref_ptr
fast_ptr
strong_ptr
strong_ptr an uncast raw pointer returned by the new operator
new_ptr which then becomes a passive observer from which references can be taken
another strong_ptr
ref_ptr; assigns NULL if not a reference to another strong_ptr

The = operator of owner_ptr applied to another owner_ptr performs destructive assignment in the same way as std:auto_ptr. It transfers ownership and leaves the source pointer empty. This operation is not intended to be used in application code, but has to be there so that owner_ptr can be returned from functions. Destructive assignment is anti-intuitive (you would expect = to make a copy and leave the source unchanged); therefore, it is not helpful for it to appear in application code. Instead, use one of the two dot methods which make it clear what is being done:

  • .make_copy(owner_ptr<T>) makes a value copy of the object owned by the other owner_ptr.
  • .steal_object(owner_ptr<T>) takes the other owner_ptr object and makes it its own. The other owner_ptr is left as NULL. This is the same effect the = operator would have.

Similarly, owner_ptr has a public destructive copy constructor which is needed so it can be used in collections. Although it is public, you should not use it in application code - in this case, it is hard to see why you should want to!

Polymorphism

Polymorphism is fully supported. Automatic implicit downcasts from derived to base classes are provided for all assignments.

With a polymorphic system, it is normally important that the base class destructor is declared as virtual - this ensures that the delete operator always deletes an object of the derived class that was created using the new operator - otherwise, only the base class portion will be deleted, leaving a memory leak (class slicing). It is very easy to forget to do this! With this smart pointer system, this is unnecessary - the smart pointer system knows which derived class was used to create the object, and ensures that an object of the same class is deleted. It is very important not to cast an object explicitly before or during its assignment to an owner.

An explicit up_cast<T, U>() function is provided to cast all types from base to derived types where this is necessary. Otherwise, there should be no explicit casting.

What Can Still Bite You

The points of vulnerability with this system are its interfaces with raw pointers.

    1. owner_ptrstrong_ptr, and new_ptr are initialised by a raw pointer returned by the new operator. The pointer returned by the new operator should be immediately assigned to one of these smart pointers without being changed in any way and then should never be used again.

      E.g.:

      C++
      owner_ptr<U> op=new T; //Correct and safe

      It is not hard to see that the following would break the system:

      • C++
        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;

        but this one is not so obvious:

        C++
        //op will eventually delete an object of type U even though
        //the object created was type T
        owner_ptr<U> op=(U*)new T; 

        It may look like the cast (U*) is harmless or even helpful, but it conceals the original type T from the initialisation of the owner_ptr. This line of code will prevent the anti class slicing mechanism from working, resulting in memory leaks. The owner_ptr has its own implicit casting operator, but it will only act correctly if it is assigned with the original uncast pointer returned by the new operator.

      To avoid such problems:

      • Never even name a variable to hold the pointer returned by the new operator - always assign it directly to a XONOR pointer.
      • Never cast the pointer returned by the new operator - not even during the assignment to a XONOR pointer.

      If you need to carry out initialisation on a new object before giving it an owner, then assign the pointer returned by the new operator directly to a new_ptr which can hold it temporarily before it is assigned to an owner.

    2. Many APIs take raw pointers as parameters. In general, this is fine - you can pass in the raw pointer of any XONOR pointer by using the dot method .get_pointer(). There are two things an API can do with these pointers which would break the system:
      1. Delete the pointer you have passed - the smart pointer system will know nothing of this, and will try to delete it again later.
      2. Store the pointer you have passed - the stored pointer will not know when you delete the object and could dangle.

Most APIs will not do these things, and if they do, it will usually be made clear in the documentation.

    1. Many APIs have return values which are raw pointers - here, you should take a great deal of care. You need to know if you are being given a secondary non owning reference pointer (which it will be in most cases), or if it is a pointer that you have to take ownership of (if this is the case, then the documentation should say so quite clearly).

If you have to own the object, then assign the return value directly to an owner_ptrstrong_ptr, or new_ptr as if it was the return value from the new operator.

If (as in most cases) it is a non owning reference pointer, then use it and store it as a raw pointer. I'll say that again - use it and store it as a raw pointer. It is futile to try and hold it with a XONOR pointer because we know nothing about its lifetime. Handle this raw pointer with care - this is something between you and the API that you are calling.

  1. XONOR pointers can throw two types of exceptions: xonor_ptr_exception and xonor_ptr_fatal_exception. Both types of exceptions are only thrown because of coding errors which you should correct. No external factors, not even memory or resource allocation will throw xonor_ptr_exceptions. It is not really a good idea to handle either type and continue execution, but if you insist on covering up mistakes in this way, then you can handle a xonor_ptr_exception and continue execution, but never handle a xonor_ptr_fatal_exception and continue execution - these fatal exceptions are typically thrown in constructors and destructors, and therefore damage has already been done. If a xonor_ptr_fatal_exception occurs, you have made a serious coding error and you must correct it.
  2. The destructive copy constructor and assignment operator of owner_ptr applied to another owner_ptr has already been mentioned. Misunderstanding this won't provoke memory leaks or dangling pointers, but it may surprise you.
  3. The enable_self_delete add-in base class is, strictly speaking, not part of the XONOR pointers system; it is a utility that works with it, and it has the capacity to break it. enable_self_delete uses the address of an owner_ptr, so it is essential that the owner_ptr must not move. This means that it must not be stored in a dynamic array. Passing an owner_ptr stored in a dynamic array to enable_self_delete can result in the worst type of memory corruption that we have gone to so much trouble to avoid. So far, I haven't found a way of detecting and preventing this, so it is best to regard enable_self_delete as potentially dangerous but sometimes very useful. Taking the address of an owner_ptr is, in general, prevented, but enable_self_delete has a special permission to do this, but is unable to detect when it is inappropriate.

How it Works

For the most part, these smart pointers are similar to others, implemented as template classes, with a great deal of operator overriding, and making use of destructors to either delete the pointee or reduce its reference count. What is different is the integration of several smart pointers with different roles to work together.

All smart pointers hold a pointer to an object of type T, and also a pointer to a reference controller.

The reference_controller holds:

  • A weak reference count.
  • A strong reference count.
  • A pointer to the original object and of the same type as the original object, and also has a virtual function that is called to delete objects.

A reference controller is created whenever two or more xonor_ptrs point at the same object. It is also created on construction and assignment from the new operator when the stored type is a base class of the type created because its virtual function is required for the anti class slicing mechanism.

The strong reference count indicates the number of xonor_ptrs owning the object. This can be:

    • 1 - owner_ptr or a single strong_ptr
    • many - multiple strong_ptrs
    • or 0 - the object has been deleted

The strong reference count controls the object lifetime - when it goes to zero, the object is deleted.

The weak reference count indicates the number of xonor_ptrs referencing the object. This can be:

  • 1 or many - there is still a reference to this object
  • or 0 - nothing references the object anymore

The weak reference count controls the lifetime of itself. When the count goes to zero, it is deleted.

The reference controller associated with a XonorPtr may not be specialised to the same type as the XonorPtr itself. This is because the reference controller must hold (and delete) a pointer of the same type as the original object created. The XonorPtr doesn't know the type specialisation of its reference controller. It only knows it as an unspecialised base class - for this reason, it calls a virtual function of the reference controller when it is time to delete the object.

It does appear that there is some redundancy in holding the pointer to the created object. There is a copy in each XonorPtr and also in the reference controller. I think the redundancy is real, one copy should be enough if we could have a system that casts it appropriately depending on where it is used. I think that this could be done, but my efforts, although partly successful, produced too much obfuscation, which I particularly wanted to avoid. So for now, the redundancy remains in the interest of having solid transparent guarantees of correct operation.

Now, let's look at the "user forgets to null a reference" scenario in the simplest way:

C++
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

In the first line, the internal pointer of opT is assigned by new T - no reference controller is created. In the second line, the internal pointer of rT is assigned by the internal pointer of opT, and a reference controller is created with a strong count of 1 and a weak count of 2. In the third line, rT checks that the strong count is non zero and DoSomething() is called. In the fourth line, opT is reset. The object is destroyed, and its internal pointer is set to NULL. Also, the strong count is set to zero and the weak count is reduced to 1. In the fifth line, rT is tested for being non-null, and returns false because the strong count is zero. The pointer knows now that the object is gone, and therefore resets itself and releases the reference controller, reducing its reference count to zero which causes the reference controller to be deleted. The sixth line does not get called.

If the test for non null had been omitted, then the dereference operator would have made the same check on the strong count and would have thrown an exception.

The key functionality is that the reference_controler can outlive the object it is associated with, and continues to exist so that any remaining secondary references are able to see that the object no longer exists.

strong_ptr has the same interaction with the WeakCount, but additionally increments the StrongCount on taking a reference, and decrements it when it goes out of scope or is reset.

To summarize:

  • When the StrongCount decrements to zero, the object pointed at is deleted. The life of the object is over.
  • An owner_ptr can simply delete the object. If it has a reference_controler, its StrongCount will be set to zero. The reference_controler itself will only be deleted if the owner_ptr was the last thing pointing at it.
  • When the WeakCount decrements to zero, the reference_controler itself is deleted. There are no more secondary references that need to know about it.

There are two further complications:

If an object is held by a strong_ptr, then its weak count is held as a negative number. This is to make it possible to assign strong_ptr from a ref_ptr if the object it references has shared ownership strong_ptr(s). This is correct and useful. A negative weak count indicates that it references an object with shared ownership, and therefore this can be done. Otherwise, the object is exclusively owned, and this cannot be done - the strong_ptr will be simply assigned NULL.

fast_ptr uses a negative StrongCount to lock the object against destruction. If the StrongCount is negative when an attempt is made to delete the object (for instance, its primary reference goes out of scope), then an exception is thrown.

A side effect is that if a fast_ptr is taken from a strong_ptr, then the StrongCount is already in use with a positive value, which produces a conflict. The solution is that the fast_ptr abandons the idea of locking, and increments the StrongCount, thus acting as another strong_ptr. This isn't really a problem because already the object is no longer directly controlled by scope, and as fast_ptr normally has a short lifetime, it is unlikely to unnaturally extend the life of the object. In any case, if the fast_ptr is the last reference left keeping a shared object alive when it goes out of scope, it will throw an exception.

One thing that concerned me most was the allocation of reference_controlers. Initially, I had them being allocated directly from the heap using the new operator, and then I realised two undesirable consequences:

  • Frequent heap allocation of small objects is costly.
  • Any reference_controler which remains in memory due to live references to deleted objects will tend to cause fragmentation, making new memory allocations more complicated.

The solution to this was to create a pool from which the reference_controlers are allocated. A pool of 100 is created on startup, and if that is exhausted, then it is extended by a further block which is 10% larger, up to a maximum of 100 blocks. This mildly exponential growth rate doesn't increase the granularity of block allocations too dramatically, but allows a maximum of 13 million reference controllers.

The idea is that this:

Image 2

is better than this:

Image 3

Although the first has 100 slots reserved for reference controllers and the second only occupies the space needed by the 4 live reference_controlers, the second case is more problematic for future memory allocations.

The reference controller pool always knows where the next free slot is (easy to achieve because they are all the same size), so allocation and deallocation is very fast.

Because the reference controller pool is global, its methods for allocation and de-allocation are protected by a single critical section so that using XONOR pointers in more than one thread doesn't cause thread conflicts. As indicated below, that does not mean that these smart pointers are thread safe - they are not!

Transparency of Source Code

I have completely revised the layout of the source code implementing these smart pointers to try and make their operation as transparent as possible.

Each smart pointer class has a small number of private or protected methods that carry out specific actions, each with a verb like name. These are laid out as I normally lay out code - a new line for everything, indentation, and lots of white space.

Having different types of smart pointers interacting with each other means that there are a large number of public methods representing the possible conversions between them - mostly constructors and overriding of the assignment operator. These are laid out as one-liners, making a simple sequence of calls to the verb, like private/protected methods. This makes it easier to get an overview of how they work, check them for consistency, and verify their correctness.

I have done this very specifically in the hope that it will encourage programmers to try it and consider adopting it. To trust this system, it is not enough to agree that it is a good idea, you also have to be able to see that it has been done right!

Overhead and Performance

Yes - these is an overhead with this system:

  • In general, XONOR pointers occupy up to three times more space than if raw pointers were used.
  • Assignment of one XONOR pointer to another involves the execution of several lines of code.
  • Dereferencing of ref_ptrs involves an extra step of checking the strong ref count.

Mitigating this:

  • Dereferencing of an owner_ptr or strong_ptr only involves a non null check of its own internal pointer.
  • Dereferencing of a fast_ptr is instantaneous - as if it is a raw pointer.
  • None of the execution overhead involves any iterations or walking of collections.
  • A reference controller pool avoids continual construction and destruction of reference controllers on the heap.

Complexities Introduced by Multithreading

Whilst all operations on an object are in one thread, we are able to make assumptions that simplify things. For instance, we can allow a ref_ptr to be directly de-referenced. This is because we can be sure that in the following code:

C++
ref_ptr<T> rp;
…
….
if(rp)
    rp->DoFunction();

between if(rp) and rp->DoFunction();, nothing else can change rp.

If the object is also referenced in another thread, then we cannot rely on this. It is possible that between if(rp) and rp->DoFunction();, the other thread may have invalidated rp.

Also, while in a single thread, the reference counting can be implemented as simple increment ++ and decrement --. If the object is also referenced in another thread, it is possible that both threads may try to change the reference count at the same time, and we will need to use InterlockedIncrement and InterlockedDecrement, which carry more overhead.

Design Decisions Affecting Multithreading

  1. Only an object with shared ownership can be shared between threads.
  2. A thread may hold a shared_ptr or a ref_ptr to an object in another thread.
  3. ref_ptr to an object in another thread must not be de-referenced directly; it must be converted to a strong_ptr before it is used, and the strong_ptr should then be tested before use.

We will examine these in reverse:

    • #3. The idea of a ref_ptr is that it doesn't keep the object it points at alive. So, if we are holding a ref_ptr to an object in another thread and it gets deleted, then when we go to use it, we test it first and we see it is NULL, and that is perfectly correct. The problem is if we test it and find it is valid, and then immediately afterwards and before we can execute the next line of code, it gets deleted by the other thread. The only way to make sure that this doesn't happen is by owning the object and keeping it alive while we use it. This is why we have to convert it to a strong_ptr first.

Furthermore, if we test it first and then convert it – it could get deleted by another thread between the test and the conversion. Therefore, it is necessary that the test and incrementing the strong reference count are one and the same operation: Conversion from ref_ptr to strong_ptr has been modified to achieve this. The strong_ptr keeps the object alive as long as that strong_ptr is valid. If the object was not valid, then the strong_ptr will test as NULL. A Lock() method has also been added to ref_ptr to do exactly the same thing, but more explicitly. Lock() returns a strong_ptr with which you can work after testing that it is not null.

  • #2. There are times when we want to hold a pointer to an object in another thread and we want that object to be kept alive by the pointer – in this case, we hold a strong_ptr to the object in the other thread. There are other times when we just want to hold a pointer to the object as long as the object exists. In this case, we use a ref_ptr; if the strong_ptr that we convert it to tests as NULL, then we know the object is no longer there. If the strong_ptr is valid, then we know it was still alive and that we will now keep it alive as long as the strong_ptr is valid.
  • #1. An object whose lifetime is controlled by single ownership cannot be shared between threads. This is because the single owner can destroy the object unconditionally, and a user in another thread can do nothing to stop this. I did think about allowing a temporary relaxation of single ownership (the other thread can keep the object alive for just a short time each time it works with the pointer), but I decided that if you declare single ownership, then you want deterministic destruction. You want your destructors to be called, and you want them called in the correct sequence (usually well controlled by scope). The problem is that if you keep a singly owned object alive even for a short time, it can delay the calling of its destructor and cause it to be called out of sequence. We don't want to mess with this!

So, we have two ways of sharing a pointer to an object across threads:

Thread 1:

C++
strong_ptr<T> opT;

Thread 2:

C++
strong_ptr<T> opT2= opT;

    if(opT2)
        opT2->UseIt();

and:

Thread 1:

C++
strong_ptr<T> opT;

Thread 2:

C++
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

It is important to restate the rules negatively – what you must not do.

  • Pass any kind of pointer to an owner_ptr to another thread. owner_ptr is strictly single thread.
  • Do anything with a ref_ptr to an object in another thread other than convert it to a strong_ptr or set it to NULL. Setting a ref_ptr to NULL is simply telling it not to take any further interest in what it is pointing at, this is never a problem.

Understanding what you must not do is important because there is no way of enforcing these rules without introducing an unacceptable overhead. I think it is very important that a ref_ptr can be directly de-referenced within a single thread, and it does not make sense to prohibit this just to prevent its misuse with multiple threads.

The following exemplify wrong use:

Thread 1:

C++
owner_ptr<T> opT;

Thread 2:

C++
ref_ptr<T> rT= opT;  //never share an owner_ptr between threads

and:

Thread 1:

C++
strong_ptr<T> opT;

Thread 2:

C++
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

How Multithreading Support Works

First of all, we are already carrying an indication in the reference controller of shared ownership – if it is shared, then the weak reference count is held as a negative number. As shared ownership objects can be shared across threads, all changes to the reference counts are made using InterlockedIncrement and InterlockedDecrement if the weak reference count tests as negative.

The second issue is that testing and incrementing the strong reference count should be one and the same thing during conversion from ref_prt to strong_ptr:

All conversions from ref_prt to strong_ptr call an internal method ShareOwnership(spT) of strong_ptr, and it is here that we make the necessary changes.

C++
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));
    }
}

First, we test to see if there is a reference controller. The m_pReferenceControler pointer will either be NULL (we never pointed it at anything), or it will be valid for as long as anything points at it – it cannot be deleted from under us by another thread – no worries here.

Next is the first important trick. We want to know if the object has shared ownership (it is illegal to construct a valid strong_ptr to a singly owned object). A negative weak reference count tells us this, but we don't want to be reading it while another thread is changing its value (we may pick up an intermediate value which is gobledegook). So, we call InterlockedDecrement on it and read the return value (guaranteed to be meaningful). If this is negative, then we have shared ownership, and can go ahead (remembering that we have already increased the weak count). Otherwise, we have single ownership, and the strong_ptr should be left as null, and we call InterlockedIncrement on the weak count to restore it.

We now do a similar trick with the strong reference count. Remember that if we test it before we increment it, we risk it being deleted between one operation and another. So, we just go ahead and call InterlockedIncrement unconditionally without even knowing if the object was valid (we always know the reference controller is valid). If the return value of InterlockedIncrement is greater than 1, then we know that there was an object, and we go ahead and make the conversion to a valid object. However, if the return value of InterlockedIncrement is only 1, then we know that there was no object there – its previous strong count was 0 – so we call InterlockedDecrement to restore its value, and we do no more, and leave the strong_ptr as null.

This care is only needed with the conversion from ref_ptr to strong_ptr because it is only with ref_ptr that the object that it points at can be deleted unexpectedly.

Limitations of Multithreading Support

This multithreading support, used correctly, provides guarantees that there will not be conflicts between threads over the existence of an object and the validity of pointers to it. This is all a smart pointer system can do on its own.

It does not do anything to stop two threads from trying to modify an object at the same time. Any object shared between threads needs to have its own protection against threads clashing as they try to modify the same data.

It does not provide an integrated solution to sharing data between threads – this is a complex issue! It just guarantees that using these pointers doesn’t undermine whatever solution you create.

Using the Source Code

Copy all of the XonorPtrs header files (all the header files that begin with 'Xonor', found in the XonorPtrs folder) into an include directory, and include the following in the file you want to build:

C++
#include <XonorPtrs.h>
#include <XonorPtrs.hpp>
using namespace XonorPtrs;

If you compile more than one *.cpp file, then you should only include XonorPtrs.hpp in one of them.

To use it, just start declaring your pointers as XonorPtrss, and code as normal. If you stay with it, you can enjoy the luxury of not having track down and NULL multitudes of secondary references.

Caveat

This system has been designed so that normal object orientated programming is more comfortable and less hazardous, and I have tried to embrace as much as possible of the kind of scenarios that I have found in my programming experience.

  • If you try strange and unconventional constructions, then it may not work - I have tried to prohibit unusual use by generating compiler errors, but I doubt that this is watertight.
  • It is not 'thread safe'. It does not provide a mechanism for the smart pointers to be simultaneously accessible to two threads.

One of the problems with developing a smart pointer system is that if it does not work properly, it will cause the very problems it has been designed to solve. What I present here works, and has not failed any test I have put it through, but the code implementing it is sufficiently lengthy and complex to have some chance of containing errors or oversights that have not been detected. I hope that by making the code transparent, I have made it easier to spot and correct any mistakes, and that this has reduced the risks involved with adopting it.

The Example Program

The example program is a simple diagram builder/editor. You can create rounded rectangles and ellipses, you can move and size them graphically, you can join them together with lines, fill them with text, and select an individual font and text colour for each box.

It has been written specifically to demonstrate and test the use of XONOR pointers. In most cases, I have tried to come up with plausible applications of the functionality of XONOR pointers.

There are two classic polymorphic subsystems:

  • Visual Objects, which is very concretely object orientated.
  • Mouse Modes. which is a more abstract use of polymorphism. but still totally practical.

These demonstrate and test the use of new_ptrowner_ptrowner_ptr_arrayref_ptrfast_ptrenable_ref_ptr_to_this, and its ref_ptr_to_this method and the referencable and ref_to macros.

There is a modeless dialog which demonstrates and tests the use of:

  • enable_self_delete and its self_delete method

There is a font pool which demonstrates and tests the use of:

  • strong_ptr

It is a WTL application - you will have to install the WTL library - free from SourceForge.

Summary

It does seem to work quite reliably, and most importantly, it solves rather than hides the problem of the programmer forgetting to null secondary references by nulling them automatically. I have adopted it for everything new that I do, and I have retrospectively implemented it in much of the library code that I have written. None of this has given me any problems, so I feel I can recommend it to others with the argument that it puts C++ ahead of C# and Java in terms of both coding comfort and memory and pointer safety without compromising how C++ works.

I am not an expert on smart pointers. I just needed to have a memory protection system that I could work with that doesn't undermine the integrity of good C++ programming. If someone who is an expert can re-write this in a more elegant or efficient way, or that more comprehensively assures or verifies its integrity, I will be delighted.

posted @ 2022-12-13 14:22  小风风的博客  阅读(23)  评论(0编辑  收藏  举报