Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Intercom: A template-based notification library

0.00/5 (No votes)
19 Feb 2003 3  
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 atest.  I'm not much for wasting my efforts (readers of my previous artcicles 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 his 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 hetrogenous 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 inheritence). 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 annonymous comment to Implementing a Subject/Observer Pattern with Templates stated "When you use this kind of machnism, 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 declerations 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 typedefs everywhere possible which should make it easy to substitute alternate implementations. 

Sample Image

Figure 1 -  The core of Intercom is made up of a set of loosely coupled parameterized classes (templates).

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 typedefs, two data members and a two constructors.  The typedefs serve to "publish" Message's parameters to other code.  Inside Observer and Notifier those typedefs 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-volitile.  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 (albiet 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 a const 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 it's 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 strings to function pointers.  The funtion pointers are typed as member functions of the class D with a single paramter 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 perferred 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 scenerio.  Each example demonstrates a different way to solve the same problem. 

For consistency and simplicity I've reused the scenerio from the examples in [reference 1].  This scenerio 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 couples (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 unmanagable.  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 scenerio 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 Nofifier<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.  Remeber, 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 a innapropriate 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 typedefs 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 remains 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 Obsever 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 notifiications 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 here about it.

Issues

  1. 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 using MessageMap) please make a suggestion.
  2. It would sometimes be desirable to have a MessageMap manage message distribution for more than one target (see ObjectType in the template).  I haven't found a satisfactory way to do this, and suggestions would be very welcome.
  3. This isn't production code.  Use it at your own risk.

Conclusion

I've introduced the notification implmentation 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