6.7 Addressing the Dead Reference Problem (II): Singletons with Longevity
The Phoenix singleton is satisfactory in many contexts but has a couple of disadvantages. The Phoenix singleton breaks the normal lifetime cycle of a singleton, which may confuse some clients. If the singleton keeps state, that state is lost after a destruction-creation cycle. The implementer of a concrete singleton that uses the Phoenix strategy must pay extra attention to keeping state between the moment of destruction and the moment of reconstruction.
This is annoying especially because in situations such as the KDL example, you do know what the order should be: No matter what, if Log gets created, it must be destroyed after both Keyboard and Display. In other words, in this case we need Log to have a longer lifetime than Keyboard and Display. We need an easy way to control the lifetime of various singletons. If we can do that, we can solve the KDL problem by assigning Log a longer lifetime than both Display and Keyboard.
But wait, there's more: This problem applies not only to singletons but also to global objects in general. The concept emerging here is that of longevity control and is independent of the concept of a singleton: The greater longevity an object has, the later it will be destroyed. It doesn't matter whether the object is a singleton or some global dynamically allocated object. We need to write code like this:
// This is a Singleton class
class SomeSingleton { ... };
// This is a regular class
class SomeClass { ... };
SomeClass* pGlobalObject(new SomeClass);
int main()
{
SetLongevity(&SomeSingleton().Instance(), 5);
// Ensure pGlobalObject will be deleted
// after SomeSingleton's instance
SetLongevity(pGlobalObject, 6);
...
}
The function SetLongevity takes a reference to an object of any type and an integral value (the longevity).
// Takes a reference to an object allocated with new and
// the longevity of that object
template <typename T>
void SetLongevity(T* pDynObject, unsigned int longevity);
SetLongevity ensures that pDynObject will outlive all objects that have lesser longevity. When the application exits, all objects registered with SetLongevity are deleted in decreasing order of their longevity.
You cannot apply SetLongevity to objects whose lifetimes are controlled by the compiler, such as regular global objects, static objects, and automatic objects. The compiler already generates code for destroying them, and calling SetLongevity for those objects would destroy them twice. (That never helps.) SetLongevity is intended for objects allocated with new only. Moreover, by calling SetLongevity for an object, you commit to not calling delete for that object.
An alternative would be to create a dependency manager, an object that controls dependencies between other objects. DependencyManager would expose a generic function SetDependency as shown:
class DependencyManager
{
public:
template <typename T, typename U>
void SetDependency(T* dependent, U& target);
...
};
DependencyManager's destructor would destroy the objects in an ordered manner, destroying dependents before their targets.
The DependencyManager-based solution has a major drawback—both objects must be in existence. This means that if you try to establish a dependency between Keyboard and Log, for example, you must create the Log object—even if you are not going to need it at all.
In an attempt to avoid this problem, you might establish the Keyboard-Log dependency inside Log's constructor. This, however, tightens the coupling between Keyboard and Log to an unacceptable degree: Keyboard depends on Log's class definition (because Keyboard uses the Log), and Log depends on Keyboard's class definition (because Log sets the dependency). This is a circular dependency, and, as discussed in detail in Chapter 10, circular dependencies should be avoided.
Let's get back then to the longevity paradigm. Because SetLongevity has to play nice with atexit, we must carefully define the interaction between these two functions. For example, let's define the exact sequence of destructor calls for the following program.
class SomeClass { ... };
int main()
{
// Create an object and assign a longevity to it
SomeClass* pObj1 = new SomeClass;
SetLongevity(pObj1, 5);
// Create a static object whose lifetime
// follows C++ rules
static SomeClass obj2;
// Create another object and assign a greater
// longevity to it
SomeClass* pObj3 = new SomeClass;
SetLongevity(pObj3, 6);
// How will these objects be destroyed?
}
main defines a mixed bag of objects with longevity and objects that obey C++ rules. Defining a reasonable destruction order for these three objects is hard because, aside from using atexit, we don't have any means of manipulating the hidden stack maintained by the runtime support.
A careful constraints analysis leads to the following design decisions.
Each call to SetLongevity issues a call to atexit.
Destruction of objects with lesser longevity takes place before destruction of objects with greater longevity.
Destruction of objects with the same longevity follows the C++ rule: last built, first destroyed.
In the example program, the rules lead to the following guaranteed order of destruction: *pObj1, obj2, *pObj3. The first call to SetLongevity will issue a call to atexit for destroying *pObj3, and the second call will correspondingly issue a call to atexit for destroying *pObj1.
SetLongevity gives developers a great deal of power for managing objects' lifetimes, and it has well-defined and reasonable ways of interacting with the built-in C++ rules related to object lifetime. Note, however, that, like many other powerful tools, it can be dangerous. The rule of thumb in using it is as follows: Any object that uses an object with longevity must have a shorter longevity than the used object.
|