Language to the Rescue
Andrei Alexandrescu, in his excellent "Modern C++ Design," shows that C++ provides two important tools for code reuse that will be useful here. They are Templates and Multiple Inheritance. (I write these words capitalized to show my respect). Andrei says they are orthogonal, in that they are complementary and together they overcome the deficiencies of each other. I will try to mimic the way Andrei designs his classes (it is called policy-based class design) to develop Java-like messaging system without any of the limitations of the brute-force approach that I have presented earlier.
The policy-based class design should begin with finding all the questions that can influence the design of the class (like: "should the class be thread-safe or not?"). Those questions define the levels of freedom of the class and should be orthogonal. In other words, the answers to those question should not influence one another. These are questions that you can ask about the Java-like messaging system:
1. What should be the actual interface of the listener? If you want to use this system, the listener interface should be completely up to you. Also, the system should be able to integrate with new interfaces (that are designed later).
2. What information can be passed with each event? In other words, what defines
the context of the event? It can be a single int
value, but it could
also be the complete, user-defined data type.
3. Should it be thread-safe? If you do not plan to use it in a multithreaded environment, the overhead of unnecessary synchronization would be overkill for your application. Remember: in C++ you do not pay for what you are not using. On the other hand, when multithreading enters the scene, the messaging system should not give up just because the author forgot to take it into account.
4. What collection mechanism should be used to keep all the pointers to listeners?
Should it be std::vector
?
These questions [2] are orthogonal. (In other words, they are independent.)
The answers to these questions define the messaging system on different axes and are dimensions of the whole specification.
Moreover, for some of these questions there are quite reasonable default answers.
For example, I can assume that for most of the time you will not mix this system
with multiple threads, (but the system should be open for this possibility).
I can assume also that for most of the time std::vector
pleases you as
the back-end for the collection of pointers.
Listing Three presents the interface of the helper class that will be used as a wrapper for a back-end collection of pointers to the listener objects. (The implementation is omitted here. Please refer to the complete source code for details.) This helper class combines three policies that provide answers to the first, third, and fourth questions concerning the design of the messaging system.
Listing Three
// the interface of the wrapper to back-end, // synchronized collection of pointers template < class Listener, class Mutex, class BagType > class ListenersCollection { // this is a helper class that locks a given mutex // and unlocks when exiting the scope class MutexHolder { // ... }; public: void add(Listener *p); void remove(Listener *p); BagType getFrozen() const; private: BagType bag_; Mutex mutex_; };
The question: "What should be the actual
interface of the listener?" is answered by the first template parameter. The
back-end physical collection class is defined by the BagType
template
parameter. It can be any class that supports the following:
1. an iterator type should be defined in this class that allows the program
to traverse the collection. (It should comply with at least the InputIterator
concept.)
2. push_back(element)
; — this is supposed to add the
element to the collection.
3. end()
; — this should return the special past-the-end
iterator value.
4. erase(iterator)
; — this method is supposed to remove
the given element from the collection.
Normally, the standard sequence collections conform to these requirements, but if you have your own class that supports this functionality, you can use it here as well.
The threading policy is encapsulated in the Mutex
class. The
minimal interface of this class is:
class NoSynchronization { public: void lock() {} void unlock() {} };
The NoSynchronization
class looks somewhat trivial, but it is important
to understand how it works in the ListenersCollection
class. The collection
helper uses the MutexHolder
class to synchronize the access to the actual,
physical collection. For example, the add
method looks like this:
void add(Listener *p) { MutexHolder m(mutex_); bag_.push_back(p); }
Written like this, the add
method of the helper ListenersCollection
seems to always use synchronization, whether it is needed or not. In fact, MutexHolder
in its constructor calls the lock()
method on the mutex object. This
method, however, is an empty inline function in the NoSynchronization
class, so the smart compiler can optimize it out of existence. In other words,
in optimized builds, the synchronization in the ListenersCollection
imposes no overhead at all if the helper class is instantiated with the NoSynchronization
class as the Mutex
template parameter. If you really need synchronization,
just substitute your own class (that implements the lock()
and unlock()
methods) for the Mutex
template parameter and the synchronization
code will be executed whenever MutexHolder
objects are constructed and
destroyed.
To mimic the non-synchronized collection of pointers to objects that implement
the ActionListener
interface (in other words, the collection that is
used in Listing Two), you can simply write:
ListenersCollection < ActionListener, NoSynchronization, std::vector<ActionListener*> > listeners_;
There is a need for clarification here. In the code above, the ActionListener
is written twice. Not only it is redundant — it is obvious that a vector
of ActionListeners
should be used here — it is also error prone.
The problem is that nothing in the code prevents the user from typing this:
ListenersCollection < ActionListener1, NoSynchronization, std::vector<ActionListener2*> > listeners_;
which would not compile at best and would crash your system at worst. Using
the template
template parameter would solve the problem, but then you
could not use physical collections that have a different template signature (the set of template parameters) than the standard STL classes. I am ready to
compromise the code beauty for the sake of its practicality, sigh.
A few words about the interface of the helper ListenersCollection
class:
- the
add
method is used for registering the listener object, - the
remove
method unregisters them, - the
getFrozen
method returns the copy of the physical collection.
This copy will be needed to solve the problems that otherwise could emerge when the listener wants to register or unregister itself (generally: to change the collection) while it is in the middle of event notification, because this would render the iterator to the collection invalid. The easiest method is to make an immutable copy of the collection and use it to call all the interested recipients. The recipients will be able to change only the original collection, not the copy.
Having the helper collection class, I can show you the final messaging system. Listing Four presents the core interface
(again, refer to the complete source code for details) of the Messaging
class.
Listing Four
// the core interface of the Messaging class template < class Listener, typename Context = int, class Mutex = NoSynchronization, class BagType = std::vector<Listener*> > class Messaging { public: // registers the listener // listeners registered more than once will be notified // once for each registration void addListener(Listener *listener) { listeners_.add(listener); } // unregisters the listener // it doesn't hurt if it's not already registered // multiply registered listeners should also multiply // unregister themselves void removeListener(Listener *listener) { listeners_.remove(listener); } protected: // this method should be called by the derived class // to trigger event dispatching void raiseEvent(const Context &context) { typedef typename BagType::iterator iterator; BagType frozen(listeners_.getFrozen()); iterator it; iterator itend = frozen.end(); for (it = frozen.begin(); it != itend; ++it) { Listener *p = static_cast<Listener*>(*it); dispatchEvent(p, context); } } // this is intended to be overriden in a derived class // and is responsible for the actual dispatching // this method is called for every registered listener // (once for each registration) // the overriding method should call the listener, // using (or not) the context as a clue virtual void dispatchEvent(Listener *listener, const Context &context) = 0; private: // the synchronized container for keeping pointers // to listener objects ListenersCollection<Listener, Mutex, BagType> listeners_; };
This class has to incorporate the last remaining policy: the answer to
the "What context should each event carry?" question. This is hidden in the
Context
template parameter. It can be any class you need. By default,
it is int
, not because int
is a special meaningful type, but because
void
could not be used here. Templates have their limitations, too.
The Messaging
class is supposed to be a base class for every class that
needs to be a source of events. You can also multiply derive from Messaging
instantiated with different parameters, thus making the derived class an event
source for different types of events.