Intercom:一个基于模板的通知库






4.61/5 (24投票s)
2003年2月20日
16分钟阅读

114238

706
本文介绍了一种基于模板的、主题/观察者模式的变体,称为 Intercom。Intercom 通过使用三组件模型(消息 Message、通知者 Notifier、观察者 Observer)在主题-观察者设计方面取得了一些优势。
引言
通知是 CodeProject 上一个备受关注的主题(无双关之意),至少有四篇之前的文章为此作证。我不太喜欢浪费我的精力(我的先前文章的读者知道我是典型的懒惰程序员),但我认为再写一篇关于通知的文章可以增加一些价值。
本文介绍了一种基于模板的、主题/观察者模式的变体,称为 Intercom。Intercom 通过使用三组件模型(Message
、Notifier
、Observer
)在主题-观察者设计方面取得了一些优势。该实现展现了简洁、可扩展、正交、可伸缩等理想的品质,并且在支持多样化的开发人员需求方面非常灵活。
问题的定义
T. Kulathu Sarma 的文章 Applying Observer Pattern in C++ Applications 为观察者模式提供了一个很好的论证。如果您对主题/观察者的一般概念更感兴趣,阅读这篇文章是一个很好的起点。基本思想是使用 abstract
接口来打破两个类之间的编译和链接时依赖,一个类是客户端,另一个是主题。Gamma 的问题陈述表明客户端需要了解主题状态的变化,并且理想情况下两个类的定义都不应包含对方的头文件。这些是健全的面向对象设计原则。
我经常使用这种模式,并且喜欢用它编程。在建模数据时,我经常向对象添加通知源,以便它们能在不同的系统中更轻松地重用。例如,在 MVC(模型-视图-控制器)系统中,通过让视图和控制器成为模型的观察者,可以增强它们。视图被编码为处理通知并对它们编码的变化做出反应。这种编程方式使我能够非常轻松地向模型添加多个异构视图。使用 MFC 进行此操作时,必须大量重载 CView::Update()
方法(使用非类型化的 pHint
参数)。虽然可行,但很丑陋,并且只能在文档的粒度上使用。
我在 CodeProject 上先前实现中发现的最大弱点在于派生要求。我非常不喜欢为现有数据对象(主题 或 观察者)添加基类。这很难做到正确,而且我倾向于尽可能避免困难的问题。有时,这是不可能的(例如,在使用第三方库或不包含多重继承的库时)。Intercom 允许我避免派生要求,我认为这足以证明本文的价值。
其他人也对先前的文章提出了担忧
- Marc Clifton 在回应 Observer Pattern 时写道:"无法向观察者发送有用的消息,例如“什么状态”或其他信息。" 我也发现这个功能至关重要。即使是 MFC 的消息传递也在定义明确的消息中包含基本的“消息代码”和“用户数据”信息。Intercom 以
Message
模板为基础,并允许扩展消息以包含用户定义的数据。 - 对 Implementing a Subject/Observer Pattern with Templates 的匿名评论指出:“当你使用这种机制时,观察者类需要“知道”主题,而这个问题破坏了封装的整个理念。这就是为什么他们称之为观察者。它需要观察,而无需真正了解任何主题的类声明。” 这种观点反映了 Gamma 的问题定义——关注点的隔离。Intercom 在主题 和 观察者方面都满足这一标准。事实上,尽管我的示例使用对象指针作为消息的主题,但任何约定好的对象类型都可以。使用此技术,完全有可能围绕内置类型实现通知协议。
所以,我认为我的方法确实提供了新颖且可取的东西。这里的实现并非我生产中使用的那个(抱歉),但它足以传达概念,并应能帮助您构建一个健壮的解决方案。话虽如此,我认为代码中没有明显严重的错误。
设计与实现
Intercom 由几个模板组成,核心是 Message
、Observer
和 Notifier
。除了核心模板外,MessageMap
提供了 Observer
的具体实现,而 MessageSource
提供了 Notifier
的一种专门化,与 Gamma 等人定义的“Subject”更一致。每个模板都至少由消息类型参数化,并且可以在编译时指定。我尽可能使用 typedef
,这应该可以轻松地替换其他实现。
Intercom 与 Gamma 等人的模式在重要但并非不可调和的方面有所不同。在 Intercom 中,主题的类型是未定义的,实际上必须指定为模板参数。任何可以定义 std::less<>
的类型都可以作为主题,尽管在实践中更有可能使用来自数据模型的现有类型的指针。为了支持此模型,Intercom 将注册和分发消息的责任分配给第三方 Notifier
。这似乎比 Gamma 更通用的解决方案,因为两组件模型的实现通过 MessageSource
直接显现(继续阅读)。
作为 Intercom 设计的一个附加约束,它必须与我关于事务的早期文章(Undo and Redo the Easy Way)兼容。为此,一些模板以实现为参数,并允许替换映射表示。这是必需的,因为我的事务模型需要为 STL 容器使用自定义分配器。示例代码演示了这种集成——由此产生了一个极其强大的编程模型。
Intercom 的代码极其简单,所以我将在此逐类介绍。
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;
};
消息模板提供了 Observer
和 Notifier
参数化的类型。Message
的不同实例化将导致 Observer
和 Notifier
(以及 MessageMap
和 MessageSource
)的不同实例化,因此理解它很重要。好消息是 Message
并没有太多内容——只有两个 typedef
、两个数据成员和两个构造函数。typedef
用于将 Message
的参数“发布”到其他代码。在 Observer
和 Notifier
内部,这些 typedef
用于实例化代码(例如,MessageMap
有一个成员,它是 Message<>::CodeType
到 PMethod
函数指针的映射)。只要它符合此处显示的“interface
”基本规则,您就可以轻松创建 Message
的替代品。
Message
类型的对象相当轻量级,只包含两个数据成员,其中只有一个可能是指针。尽管如此,我们不想复制它们大量副本,或反复构造和析构它们。为了灵活性,我没有添加私有的复制构造函数和赋值运算符,但如果您的代码或用户数据类型很大或很复杂,它们可能会很有用。
下面的行声明了一个 Message
实例化,其代码类型为 int
,主题类型为 const Foo*
typedef Mm::Message<const Foo*, int> MyMessage;
在此示例中,我选择了一个 const
主题类型来演示非默认构造函数的必要性。在某些情况下,消息类型的各个方面(代码、主题或用户数据)应始终被视为非易失性。在这些情况下,可以将主题和/或代码类型设为 const
,从而在初始化外保护它们免遭修改。但是,由于 const public
成员不能在初始化列表之外赋值,因此需要一个非默认构造函数。专门化 Message
是可能的,并且可能是向消息添加用户定义数据的最佳方式。例如:
typedef Mm::Message<const Foo*, int> MyMessage;
struct PingMessage : public MyMessage
{
PingMessage() : MyMessage() {}
PingMessage(MyMessage::SubjectType s, MyMessage::CodeType c)
std::list<Bar*> responders;
}
此示例声明了一个 message
类,其中包含一个响应者列表。假设任何处理该 message
的 Bar
都将自己添加到列表中,这种结构可以作为“ping”——也就是说,用于查看谁正在监听特定的 Foo
。当然,在消息定义中添加对 Bar
的引用会创建一个类之间的链接(尽管是弱链接),这可能不合适。使用正交的 SubjectType
可以消除此链接。
观察者
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
定义了客户端的接口。也就是说,消息通过实现适合消息类型的 Observer
接口的对象进行传递和处理。Observer 使用消息类型进行模板化,因此每种消息类型都将拥有一个独立的、不重叠的接口。预计 Intercom
的用户将创建此接口的实现,或使用下面描述的具体 MessageMap
。
接口由两个方法组成:
- 每次调度代表某个主题给已注册的观察者的消息时,都会调用
OnMessage()
方法。观察者接收实例化模板(MessageType
)所用类型消息的const
引用,并且通常期望返回某个状态,尽管目前在 Intercom 中未使用。 Goodbye()
方法未被 Gamma 描述,但在实践中很常见。当撤销观察者对特定主题的注册并且不再接收通知时,会调用此方法。该方法为观察者提供了一个方便的机会来进行最终化或清理,而无需定义特定的协议。
在下面可以找到使用 Observer
的两种方法的示例。
通知器
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
是负责跟踪注册信息(哪些主题被观察以及由谁观察)和分发消息的类。使用 Notifier
的 API,您可以将观察者注册到主题,撤销注册(停止接收消息)以及代表主题发送消息。
Notifier
有趣之处在于,作为一个模板,它被专门化为消息类型。因此,每种消息类型都有自己的 notifier
类型和 static
单例。单例是一种便捷的实现。通常,您不会想拥有多个相同类型的通知者,因为每个通知者都会独立地将主题映射到观察者(尽管肯定存在有用的时候)。如果您想使用多个通知者,请考虑使用 MessageSources
。
我添加了一些 global
方法来简化使用全局通知者。请参阅 DispatchMessage
、RegisterForMessages
和 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); }
};
从模板定义可以看出,MessageSource
将 Message
和 Notifier
的概念合并到一个对象中。结果是一个可订阅的事件。Message
源可以通过派生进行专门化,但最好将其用作某个包含数据对象的成员。例如:
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
为 Intercom 在主题方面提供了一个非常好的编程模型。在观察者端,MessageMap
提供了类似的功能。
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
是 Observer
的一个具体实现,它根据消息的“代码”(CodeType
)将消息映射到特定类型的成员函数。MethodCall
是 MessageMap
使用的一个简单的函数指针包装器。因此,如果您的 message
类型使用 string
作为 CodeType
声明,MessageMap
将把 string
映射到函数指针。函数指针的类型是类 D
的成员函数,该函数有一个类型为 const
MessageType&
的参数,返回类型为 Result
。成员函数 不必 是 virtual
,这可能非常有用。
要使用 MessageMap
,只需设置映射条目并将其连接到 Notifier
(或 MessageSource
)。有关详细信息,请参阅下面的示例三。与 MessageSource
一样,MessageMap
可以通过派生进行专门化,也可以作为独立对象使用。至少在我看来,后者是首选方法。
示例
Intercom
的模板可以以多种方式使用——这对灵活性很有好处(如上所述),但可能难以入门。因此,我将本节构建为基于常见场景的一系列示例。每个示例都演示了一种不同的解决相同问题的方法。
为了保持一致性和简洁性,我重用了 [参考 1] 示例中的场景。该场景包括一个读取硬件数据的温度传感器对象、一个创建温度图形表示的温度显示器以及一个指示温度超出范围条件的警报。目标是软件设计,可最大限度地降低添加、更改和删除对象的成本。
这是我们将要开始使用的(非常简单的)代码:
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;
}
}
}
这可能是解决特定问题的一个完美解决方案,但当添加更多/不同类型的传感器、显示器和警报时,它就会开始崩溃。主要问题是,尽管对象本身是松散耦合的(没有链接时或编译时依赖),但整个代码并非如此。主循环包含有关何时轮询、什么构成温度变化、何时温度超出范围以及何时开启和关闭警报的所有逻辑。这使得循环变得复杂,成为代码变动和冲突(现在谁签出了?)的来源。在更复杂的系统中,这种情况会很快变得难以管理。至少,以下示例是基于此假设的... ;)
第一个示例将处理 Message
、Observer
和 Notifier
模板。第二个示例演示 MessageSource
,第三个示例将 MessageMap
添加到混合中。那么,让我们开始重构代码吧!
示例 1:使用 Notifier 单例
在大多数情况下,第一步是定义协议。或者,更具体地说,是 typedef
所需的模板实例化(Message, Notifier, Observer
)。Message
声明需要“code
”和“subject
”类型参数。代码参数可用于创建具有相同 subject
类型的不同消息实例化,或作为通用用户数据 [问题 #1]。subject
参数标识将映射到观察者的对象类型。通常,subject
将是指针类型,因为它将按值复制到 Notifier
的映射中。Notifier
和 Observer
模板只需要 Message
实例化作为唯一参数。
在我们的场景中,所有操作都源于温度传感器检测到温度变化。因此,将传感器作为消息的主题,并将新温度作为用户数据传递似乎很自然(我们还没有使用“code”,所以请忽略它)
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;
现在,让我们添加代码,为温度传感器添加在温度变化时分发消息的代码,并为 Alarm
和 Display
添加代码以捕获消息并做出适当响应。请记住,我们正在使用全局单例通知者:
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();
}
}
嗯,代码不同了——但它更好吗?我们将温度变化检测移到了传感器,将超出范围检测移到了警报,显示器基本未变。我们将几个方法移到了更高的保护级别(这使得通过直接调用 Update
来执行错误操作,例如显示不合适的值,变得更加困难)。我们还通过一个变量明确了警报的状态,而不是通过温度来暗示,这增加了存储但对试图在以后更改代码的新手程序员来说也很明显。就我们的目标而言,添加和删除传感器应该足够容易,添加新类型的显示器甚至多个显示器也应该如此。当然比原始代码容易,至少是这样。
我们做的非常不愉快的一件事是改变了 TemperatureDisplay
和 TemperatureAlarm
的派生层次结构。要直接接收消息,一个类必须派生自 Observer
,并且在这种情况下,我们不得不使派生成为 public
,以便 main 可以将它们连接到通知者。这是一个令人不快的要求,但正如我们将在示例 3 中看到的,使用 MessageMap
可以绕过这一点。
总的来说,这是一种不优雅的解决方案(使用 static
对象),可能会导致模块初始化顺序、多线程和可伸缩性方面的问题。使用 MessageSource
可以将静态通知者从图片中移除,所以让我们来看看那种方法。
示例 2:使用 MessageSource
MessageSource
是一个可订阅的事件。也就是说,它管理自己的观察者列表,因此我们不需要使用全局单例。对于示例 1 的 typedef
,我们将添加 MessageSource
定义:
typedef MessageSource<TSMessage> TSEvent;
在数据模型中,只有 TemperatureSensor
需要改变。我们需要添加并使用一个 TSEvent
成员:
class TemperatureSensor {
public:
TSEvent tempChanged;
float temp;
TemperatureSensor() : tempChanged('!', this) {}
protected:
void PollTemperature() {
float oldTemp = temp;
temp = ReadTemperatureFromHardware();
if (oldTemp != temp) tempChanged.Dispatch();
}
};
我们添加了构建消息源的代码,并修改了 PollTemperature
以使用它而不是单例。
在 main()
中,连接消息源和观察者的代码只需稍作更改:
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
sensor.tempChanged.Register(&display);
sensor.tempChanged.Register(&alarm);
while (1) {
sensor.PollTemperature();
}
}
Display
和 Alarm
的代码保持不变。
我们还可以更改 Display
和 Sensor
的构造函数/析构函数,使其自动注册和撤销对温度变化事件的注册。不幸的是,这样做会在对象之间创建编译时依赖,这可能不是我们想要的。有利的一面是,它允许我们将派生自 Observer
的访问级别设为 protected
。但是,我们可以使用 MessageMap
完全消除派生要求。
示例 3:使用 MessageMap
MessageMap
提供了一个具体的 Observer
实现,它根据消息的“代码”将消息切换到对象成员函数。MessageMap
有多种用法,但在此示例中,我们将向 Display
和 Alarm
添加成员。这些成员将处理来自传感器的事件,并调用包含类上的指定方法。传感器的代码不会改变。
将 MessageMap
添加到混合中,我们得到:
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();
}
};
我们已从 Display
和 Alarm
中删除了 Observer
基类,以及所有 virtual
方法。
main
中的代码变成:
void main()
{
TemperatureSensor sensor;
TemperatureDisplay display;
TemperatureAlarm alarm;
sensor.tempChanged.Register(&display.mm);
sensor.tempChanged.Register(&alarm.mm);
while (1) {
sensor.PollTemperature();
}
}
示例 4:事务
在我题为 Undo and Redo the Easy Way 的文章中,我介绍了一种通过在位级别管理对象更改来处理事务的方法。这种方法在实现需要广泛通知的撤销和重做支持时非常适用(甚至可以说是必需的)。原因是处理通知的对象通常会在响应主题更改时更改其自身的状态或其他对象的状态。在不知道将要更改的对象集的情况下(这是解耦的要点,对吧?)实现过程式撤销机制,至少可以说是困难的。
最后一个示例 Example4.cpp 展示了我的事务方法如何与 Intercom 一起工作。您可能想看看它。我知道没有其他方法可以用更少的代码或更高的性能来实现相同的结果。如果存在方法,我将非常乐意听到。
问题
- 我不喜欢
Message
模板即使在未使用“code”和“data”时也需要为它们分配空间。我已经研究了几种惯用法来解决这个问题,但还没有找到我喜欢的。如果您能想到一种使这些成员成为可选的方法(显然,当使用MessageMap
时,“code”是必需的),请提出建议。 - 有时,希望
MessageMap
管理多个目标的邮件分发(参见模板中的ObjectType
)。我还没有找到令人满意的方法来做到这一点,非常欢迎提出建议。 - 这不是生产代码。使用风险自负。
结论
我介绍了通知实现 Intercom
,并提供了四个示例,说明它如何以极其灵活的方式解决问题。Intercom
适用于多种编程风格,并且我已经展示了它可以通过参数化与其他代码库集成。
版本历史
- 版本 1 于 2003 年 2 月 20 日发布到 CodeProject
许可证
本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。
作者可能使用的许可证列表可以在此处找到。