Intercom: A Template-Based Notification Library






4.61/5 (24 votes)
Feb 20, 2003
16 min read

113133

688
This article introduces a template-based off-shoot of the subject/observer pattern called Intercom. Intercom achieves some advantages over subject-observer designs by using a three component model (Message, Notifier, Observer).
Introduction
Notification is a subject (no pun intended) of some interest here on CodeProject, as at least four previous articles attest. I'm not much for wasting my efforts (readers of my previous articles know I'm the archetypical lazy programmer), but I think one more treatment of notification can add some value.
This article introduces a template-based off-shoot of the subject/observer pattern called Intercom. Intercom achieves some advantages over subject-observer designs by using a three component model (Message
, Notifier
, Observer
). The implementation exhibits the desirable qualities of simplicity and extensibility, othogonality, scalability, and is quite flexible in supporting diverse developer requirements.
Definition of the Problem
T. Kulathu Sarma's article Applying Observer Pattern in C++ Applications makes a good case for the observer pattern. If you're interested in a more detailed discussion of the general idea of Subject/Observer, reading this article is a good place to start. The basic idea involves using abstract
interfaces to break compile and link-time dependencies between two classes, one a client and the other, the subject. The problem statement by Gamma indicates the client needs be aware of changes in the state of the subject, and that it is desirable for neither class definition to include the headers of the other. These are sound principles of object-oriented design.
I use this pattern often, and enjoy programming with it. When modeling data, I often add notification sources to objects so that they can be more easily reused in different systems. For example, in MVC (model-view-controller) systems, the view and controllers can often be enhanced by making them observers of the model. Views are coded to handle notifications and react to the changes they encode. This kind of programming allows me to add multiple heterogeneous views to a model very easily. Doing so using MFC, the method CView::Update()
would have to be heavily overloaded (using the untyped pHint
parameter). Possible, but ugly and only available at the granularity of the document.
The greatest weakness I found with previous implementations here on CodeProject has to do with derivation requirements. I really dislike adding base-classes to existing data objects (subject or observer). It's hard to get right, and I tend to avoid hard problems wherever possible. Sometimes, it isn't possible (e.g., when using third party libraries or libraries that excluded multiple inheritance). Intercom allows me to avoid derivation requirements, and I think that is motivational enough to justify this article.
Others have also raised concerns based on the previous articles:
- Marc Clifton wrote in response to Observer Pattern "There's no way to send useful messages to the observer, as to "what state" or other information." I find this critical functionality, too. Even MFC's message passing includes basic 'message code' and 'user data' information in well-defined messages. Intercom includes a
Message
template at its foundation, and allows extension of the message to include user-defined data. - An anonymous comment to Implementing a Subject/Observer Pattern with Templates stated "When you use this kind of mechanism, the observer class needs to "know" about the subject, and this matter breaks the whole idea of encapsulation. That's why they call it Observer. I needs to observe, without really knowing any class declarations of any subjects ." That view reflects Gamma's definition of the problem -- isolation of concerns. Intercom meets this criteria with respect to both the subject and the observer. In fact, although my examples use object pointers as the subject of the message, any agreed upon object type will do. Using this technique, it is very much possible to implement notification protocols around built-in types.
So I think my approach does offer something new, and desirable. The implementation here isn't the one I use in production (sorry) but communicates the concepts well enough, and should get you on the way to a robust solution. That said, I don't think there are any obvious catastrophic problems with the code.
Design and Implementation
Intercom is composed of a handful of templates, at the core of which are Message
, Observer
and Notifier
. In addition to the core templates, MessageMap
provides a concrete implementation of Observer
, and MessageSource
provides a specialization of Notifier
more consistent with the Gamma, et. al. definition of 'Subject'. Each of templates is parameterized on at least the message type and can be specified at compile-time. I've used typedef
s everywhere possible which should make it easy to substitute alternate implementations.
Intercom differs from the Gamma, et. al. pattern in a significant but not unreconcilable way. In Intercom, the type of the subject is left undefined and in fact must be specified as a template parameter. Any type for which std::less<>
may be defined is acceptable as a subject, though it will be likely in practice to use a pointer to an existing type from your data model. To support this model, Intercom assigns the responsibility of taking registrations and distributing messages to a third-party Notifier
. This appears to be a more general solution than Gamma, since implementation of the two components model falls directly out via MessageSource
(continue reading).
As an added constraint of the design of Intercom, it had to be compatible with my earlier article on transactions (Undo and Redo the Easy Way). To that end, some of the templates are parameterized on implementations and allow the substitution of map representations. That was required since my transaction model requires the use of custom allocators for STL containers. The sample code demonstrates such an integration -- and extremely powerful programming model that results from it.
The code of Intercom is extremely simple, so I'm going to just walk through it class-by-class here.
Message
template <class ST, class CT>
struct Message
{
public:
typedef ST SubjectType;
typedef CT CodeType;
Message() : subject(), code() {}
Message(SubjectType s, CodeType c) : subject(s), code(c) {}
SubjectType subject;
CodeType code;
};
The message template provides the type on which Observer
and Notifier
are paramaterized. Different instantiations of Message
will lead to different instantiations of Observer
and Notifier
(and MessageMap
and MessageSource
, for that matter), so it's important to understand it well. The good news is that there isn't much to Message
-- just two typedef
s, two data members and two constructors. The typedef
s serve to "publish" Message
's parameters to other code. Inside Observer
and Notifier
, those typedef
s are used to instantiate code (for example, MessageMap
has a member that is a map of Message<>::CodeType
to PMethod
function pointers). You can easily create a substitute for Message
so long as it adheres to the basic "interface
" shown here.
Objects of Message
types are fairly lightweight, containing just two data members, only one of which is likely to be a pointer. Still, we don't want to make a lot of copies of them, or repeatedly construct and destruct them. I stopped short of adding a private copy constructor and assignment operator, for flexibility, but they might be well advised if your code or user data types are large or complex.
The following line declares a Message
instantiation with a code type of int
and a subject type of const Foo*
:
typedef Mm::Message<const Foo*, int> MyMessage;
In this example, I chose a const
subject type to illustrate the need for the non-default constructor. There are situations where aspects (code, subject or user data) of a message type should always be considered non-volatile. It is possible in those cases to make the subject and or code types const
, and hence protect them from modification outside initialization. However, since a const public
member cannot be assigned outside of the initializer, a non-default constructor is required. Specializing Message
is possible and probably the best way to add user-defined data to messages. For example:
typedef Mm::Message<const Foo*, int> MyMessage;
struct PingMessage : public MyMessage
{
PingMessage() : MyMessage() {}
PingMessage(MyMessage::SubjectType s, MyMessage::CodeType c)
std::list<Bar*> responders;
}
This example declares a message
class that included a list of responders. Assuming that any Bar
handling the message
adds itself to the list, this type of construct can be useful as a 'ping' -- that is, to see who's listening to a particular Foo
. Of course, adding a reference to Bar
in the message definition creates a link (albeit a weak one) between the classes that may not be desirable. Using an orthogonal SubjectType
would eliminate the link.
Observer
template <class M>
struct Observer
{
public:
typedef M MessageType;
virtual Result OnMessage(const MessageType& message) { return Result(R_OK); }
virtual Result Goodbye(const MessageType::SubjectType s) { return Result(R_OK); }
};
Observer
defines the interface of the client. That is, messages are passed to and handled by objects implementing the Observer
interface appropriate to the message type. Observer is templatized on the message type, so each type of message will have an insulated, non-overlapping interface. It is expected that users of Intercom
will create implementations of this interface, or use the concrete MessageMap
described below.
The interface consists of two methods:
- The method
OnMessage()
is called each time a message dispatched on behalf of a subject to which the observer is registered. The observer receives aconst
reference to a message of the type used to instantiate the template (MessageType
), and is generally expected to return some status though it goes unused in Intercom at the moment. - The method
Goodbye()
is not described by Gamma, though is often found in practice. This method is called when the registration of the observer is revoked from a particular subject and will no longer get notifications. The method provides the observer with a convenient opportunity to do finalization or cleanup without having to define a protocol specific to the purpose.
Examples of both methods of using Observer
can be found below.
Notifier
template <class M>
class Notifier
{
public:
typedef M MessageType;
static Notifier<MessageType><MESSAGETYPE>* Singleton();
Result Register(MessageType::SubjectType subject,
ObserverType* observer);
Result Revoke(MessageType::SubjectType s, ObserverType* o);
Result RevokeAll(MessageType::SubjectType subject);
Result Dispatch(const MessageType& message) const;
MapType objectObservers;
};
Notifier
is the class responsible for tracking registration information (which subjects are being watched and by who) and for distributing messages. Using Notifier
's API, you can register an observer with a subject, revoke the registration (stop getting messages) and send messages on behalf of a subject.
The interesting thing about Notifier
is that as a template, it is specialized on the message type. So, each message type will have its own notifier
type and static
singleton. The singleton exists as a convenience. Generally, you aren't going to want to have multiple notifiers of the same type since each will independently map subjects to observers (although there are certainly times when it is useful to do so). If you want to use multiple notifiers, consider using MessageSources
instead.
I've added some global
methods to simplify using global notifiers. See DispatchMessage
, RegisterForMessages
and RevokeRegistration
.
MessageSource
template <class M>
class MessageSource : public M, protected Notifier<M>
{
public:
typedef M MessageType;
typedef Notifier<MESSAGETYPE, I> NotifierType;
typedef Observer<MESSAGETYPE> ObserverType;
MessageSource(MessageType::SubjectType s, MessageType::CodeType c)
: MessageType(s, c), NotifierType() {}
Result Register(ObserverType* o)
{ return NotifierType::Register(subject, o); }
Result Revoke(ObserverType* o)
{ return NotifierType::Revoke(subject, o); }
Result Dispatch() const { return NotifierType::Dispatch(*this); }
};
As you can see from the template definition, MessageSource
combines the concept of Message
and Notifier
into a single object. The result is a subscribable event. Message
source can be specialized through derivation, but is probably better used as a member of some containing data object. For example:
struct Foo {
typedef Message<Foo*, std::string> FooMessage;
typedef MessageSource<FooMessage> FooEvent;
FooEvent event1, event2;
Foo() : event1(this, "hello"), event2(this, "world") {}
~Foo() {}
void DoSomething() { event1.Dispatch(); event2.Dispatch(); }
};
...
Foo f;
f.event1.Register(pMyObserver);
f.event2.Register(pMyOtherObserver);
...
MessageSource
gives Intercom a pretty nice programming model on the subject side of things. At the observer end, MessageMap
provides something similar.
MessageMap
template <class M, class D, class B = Observer<M> >
class MessageMap : public B
{
typedef M MessageType;
typedef D ObjectType;
typedef B BaseClassType;
typedef MethodCall<M,D><M, D> MethodCallType;
MessageMap(ObjectType* t) : target(t) {}
Result Set(MessageType::CodeType code, MethodCallType::PMethod method);
{ eventMap[code] = method; return Result(R_OK); }
virtual Result OnMessage(const MessageType& message)
{
MethodMapType::iterator i = eventMap.find(message.code);
if (i != eventMap.end())
return (*i).second.Call(target, message);
else
return BaseClassType::OnMessage(message);
}
MethodMapType eventMap;
ObjectType* target;
};<CLASS class="" B="Observer<M" D, M,>
MessageMap
is a concrete implementation of Observer
that maps message codes (CodeType
) to member functions on a particular type. MethodCall
is a simple function pointer wrapper used by MessageMap
. So, if your message
type was declared with a CodeType
of string
, MessageMap
will map string
s to function pointers. The function pointers are typed as member functions of the class D
with a single parameter of type const
MessageType&
and a return type of Result
. The member functions do not have to be virtual
, which can be very nice.
To use a MessageMap
, just set the map entries and connect it to a Notifier
(or MessageSource
). See example three, below, for details. Like MessageSource
, MessageMap
can be specialized through derivation or used as an independent object. The latter is the preferred method, at least in my view.
Examples
The templates of Intercom
can be used in a wide variety of ways -- this is a good for flexibility (as described above), but can make it difficult to get started. For that reason, I've constructed this section as a series of examples based on a common scenario. Each example demonstrates a different way to solve the same problem.
For consistency and simplicity, I've reused the scenario from the examples in [reference 1]. This scenario includes a temperature sensor object that reads data from hardware, a temperature display that creates a graphical representation of the temperature, and an alarm that signals temperature out of range conditions. The goal is a software design that minimizes the cost of adding, changing and removing objects.
Here is the (very simplistic) code we'll be starting with:
class TemperatureSensor {
public:
TemperatureSensor() {}
float Poll() {
float temp = ReadTemperatureFromHardware();
return temp;
}
};
class TemperatureDisplay {
public:
TemperatureDisplay() {}
void Update(float t) { /* code to display t */ };
};
class TemperatureAlarm {
public:
TemperatureAlarm() {}
void StartNoise() { /* noise */ }
void StopNoise() { /* silence */ }
};
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
float tempPrevious = MIN_FLOAT;
while (1) {
float temp = sensor.Poll();
if (temp != tempPrevious) {
display.Update(temp);
if (temp > TEMP_MAX || temp < TEMP_MIN)
alarm.StartNoise();
else if (tempPrevious > TEMP_MAX || tempPrevious < TEMP_MIN)
alarm.StopNoise();
tempPrevious = temp;
}
}
}
This may be a perfectly good solution for the specific problem it solved, but when more and/or different sensors, displays and alarms are added, it will start to fall apart. The main problem is that although the objects themselves are loosely coupled (no link-time or compile-time dependencies), the over code is not. The main loop contains all logic about when to poll, what constitutes a temperature change, when the temperature is out of range and when to turn alarms on and off. That makes the loop complex and a source of code churn and conflicts (who has it checked-out now!). In a more complicated system, this situation would quickly become unmanageable. At least, that's the assumption the following examples are based on... ;)
The first example will address the Message
, Observer
and Notifier
templates. The second demonstrates MessageSource
, and the third adds MessageMap
to the mix. So let's start ripping up code!
Example 1: Using Notifier Singletons
In most cases, the first step is to define a protocol. Or, more specifically, to typedef
the required template instantiations (Message, Notifier, Observer
). The Message
declaration requires "code
" and "subject
" type parameters. The code parameter can be used to create different message instantiations with the same subject
type, or as generalized user-data [Issue #1]. The subject
parameter identifies the type of objects that will be mapped to observers. Usually, the subject will be a pointer type, since it will be copied into the Notifier
's map by value. The Notifier
and Observer
templates require the Message
instantiation as the only parameter.
In our scenario, all actions originate with the temperature sensor detecting a change in temperature. So, it seems natural to make the sensor the subject of the message and have the new temperature carried as user-data (we aren't using 'code' yet, so ignore it):
typedef Message<unsigned char, TemperatureSensor*> MessageBase;
struct TSMessage : public MessageBase
{
TSMessage() : MessageBase(), temp(0.0) {}
TSMessage(MessageBase::SubjectType s, MessageBase::CodeType c, float t)
: MessageBase(s, c), temp(t) {}
float temp;
};
typedef Notifier<TSMessage> TSNotifier;
typedef Observer<TSMessage> TSObserver;
Now, let's add code, the temperature sensor to dispatch a message when the temperature changes, and code to the Alarm
and Display
to catch the message and respond appropriately. Remember, we're using the global singleton notifier:
class TemperatureSensor {
public:
TemperatureSensor() {}
float PollTemperature() {
float oldTemp = temp;
temp = ReadTemperatureFromHardware();
if (oldTemp != temp) {
TSMessage m(this, '!', temp);
TSNotifier::Singleton()->Dispatch(m);
}
return temp;
}
private:
float temp;
};
class TemperatureDisplay : public TSObserver {
public:
TemperatureDisplay() {}
protected:
void Update(float t) { /* code to display t */ };
virtual Result OnMessage(const TSMessage& m) {
Update(m.temp);
}
};
class TemperatureAlarm : public TSObserver {
public:
TemperatureAlarm() : alarmIsOn(false) {}
protected:
bool alarmIsOn;
void StartNoise() { alarmIsOn = true; /* noise */ }
void StopNoise() { alarmIsOn = false; /* silence */ }
virtual Result OnMessage(const TSMessage& m) {
if (m.temp > TEMP_MAX || m.temp < TEMP_MIN) {
if (!alarmIsOn) StartNoise();
} else if (alarmIsOn) StopNoise();
}
};
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
TSNotifier::Singleton()->Register(&sensor, &display);
TSNotifier::Singleton()->Register(&sensor, &alarm);
while (1) {
sensor.PollTemperature();
}
}
Well, the code is different -- but is it better? We've moved temperature change detection to the sensor, out of range detection to the alarm, and the display was largely unchanged. We've moved a couple methods to a higher protection level (which makes it harder to do bad things like display an inappropriate value by calling Update
directly). We've also made the state of the alarm explicit through a variable instead of being implied by the temperature, which increases storage but also makes it obvious to a neophyte programmer trying to change the code down the road. As far our goal goes, it looks like adding and removing sensors should be easy enough, as should adding new types of displays and even multiple displays. Certainly easier than the original code, anyway.
One of the really unpleasant things we did was alter the derivation hierarchy of TemperatureDisplay
and TemperatureAlarm
. To receive messages directly, a class must be derived from Observer
, and in this case we had to make the derivation public
so that main could connect them to the notifier. This is an unpleasant requirement, but as we'll see in Example 3, something we can get around using MessageMap
.
Overall, this is sort of an inelegant solution (using a static
object) which might lead to problems with module initialization order, threading, and scalability. Using MessageSource
removed the static notifier from the picture, so let's take a look at that approach now.
Example 2: Using MessageSource
MessageSource
is a subscribable event. That is, it manages its own list of observers so we don't need to use the global singleton. To the typedef
s of example 1, we'll add the MessageSource
definition:
typedef MessageSource<TSMessage> TSEvent;
In the data model, only TemperatureSensor
needs change. We need to add and use a TSEvent
member:
class TemperatureSensor {
public:
TSEvent tempChanged;
float temp;
TemperatureSensor() : tempChanged('!', this) {}
protected:
void PollTemperature() {
float oldTemp = temp;
temp = ReadTemperatureFromHardware();
if (oldTemp != temp) tempChanged.Dispatch();
}
};
We added code to construct the message source, and modified PollTemperature
to use it rather than the singleton.
In main()
, the code for hooking up the message source and observer needs change only slightly:
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
sensor.tempChanged.Register(&display);
sensor.tempChanged.Register(&alarm);
while (1) {
sensor.PollTemperature();
}
}
The code for the Display
and Alarm
remain unchanged.
We might have also changed the constructors/destructors of Display
and Sensor
to automatically register and revoke themselves from the temperature changed event. Unfortunately, doing so would have created compile-time dependencies between the objects, which we probably don't want. On the plus side, it would have allowed us to make the derivation from Observer protected
. But, we can remove the derivation requirement entirely using MessageMap
.
Example 3: Using MessageMap
MessageMap
provides a concrete Observer
implementation that switches messages to object member functions based on the "code" of the message. There are a variety of ways to use a MessageMap
, but in this example we're going to add members to Display
and Alarm
. These members will handle events from the sensor and call the indicated methods on the enclosing class. The code for the sensor will not change.
Adding MessageMap
to the mix, we get:
class TemperatureDisplay {
public:
typedef MessageMap<TemperatureDisplay, TSMessage> TSMessageMap;
TSMessageMap mm;
TemperatureDisplay() : mm(this) { mm.Set('!', &OnMessage); }
protected:
void Update(float t) { /* code to display t */ };
void OnMessage(const TSMessage& m) {
Update(m.data);
}
};
class TemperatureAlarm {
public:
typedef MessageMap<TemperatureDisplay, TSMessage> TSMessageMap;
TSMessageMap mm;
TemperatureAlarm() : mm(this), alarmIsOn(false)
{ mm.Set('!', &OnMessage); }
protected:
bool alarmIsOn;
void StartNoise() { alarmIsOn = true; /* noise */ }
void StopNoise() { alarmIsOn = false; /* silence */ }
void OnMessage(const TSMessage& m) {
if (m.data > TEMP_MAX && !alarmIsOn) StartNoise();
else if (m.data < TEMP_MIN && !alarmIsOn) StartNoise();
else if (alarmIsOn) StopNoise();
}
};
We've removed the Observer
base class from Display
and Alarm
, and all virtual
methods along with them.
The code in main
becomes:
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
sensor.tempChanged.Register(&display.mm);
sensor.tempChanged.Register(&alarm.mm);
while (1) {
sensor.PollTemperature();
}
}
Example 4: Transactions
In my article, Undo and Redo the Easy Way, I introduced a way to do transactions by managing changes to objects at the bit level. That approach is very well applied (perhaps even required) for implemented undo and redo support where extensive notification is in use. The reason is that objects that handle notifications often make changes to their own state, or the state of other objects, in response to a change in the subject. Implementing a procedural undo mechanism when you don't know the set of objects that will change (that's the point of the decoupling, right?) can be difficult, to say the least.
The final example, Example4.cpp, shows how my transaction approach can work with Intercom. You might want to check it out. I know of no other way to accomplish the same result with less code or higher performance. If there is a way, I'd love to hear about it.
Issues
- I don't like that the
Message
template requires space for "code" and "data" even when they aren't used. I've looked into a few idioms in an effort to resolve this, but I haven't found one I like. If you can think of a way to make these members optional (obviously "code" is required when usingMessageMap
), please make a suggestion. - It would sometimes be desirable to have a
MessageMap
manage message distribution for more than one target (seeObjectType
in the template). I haven't found a satisfactory way to do this, and suggestions would be very welcome. - This isn't production code. Use it at your own risk.
Conclusion
I've introduced the notification implementation Intercom
and provided four examples of how it can solve problems in an extremely flexible way. Intercom
is adaptable to many programming styles, and I've shown that it can integrate with other code bases through parameterization.
Version History
- Version 1 posted Feb. 20, 2003 to CodeProject
License
This article has no explicit license attached to it, but may contain usage terms in the article text or the download files themselves. If in doubt, please contact the author via the discussion board below.
A list of licenses authors might use can be found here.