(第二部分开始)
主题
订阅过程
存储了什么?
根据具体实现,观察者订阅时,主题可能存储以下数据:
- 事件 ID - 表示感兴趣的事件,即观察者订阅的内容。
- 观察者实例 - 通常以对象指针的形式存在。
- 成员函数指针 - 如果使用任意处理程序。
这些数据将形成 subscribe
方法的参数:
// Subscription with an overridden handler (where the observer class has a base class handler method).
aSubject->Subscribe( "SizeChanged", this );
// Subscription with an arbitrary handler.
aSubject->Subscribe( "SizeChanged", this, &ThisObserverClass::OnSizeChanged );
值得注意的是,如果使用任意处理程序,成员函数指针很可能会与观察者实例一起打包在类或结构体中形成委托。因此,Subscribe()
方法可能具有以下签名:
// Delegate = object pointer + member function pointer.
void Subject::Subscribe( EventId aEventId, Delegate aDelegate )
{
//...
}
实际存储(可能在std::map
中)将使用事件 ID 作为键,委托作为值。
实现事件ID
在触发它们的主题类之外定义事件ID可以简化对这些ID的访问。但一般来说,由主题触发的事件是该主题独有的。因此,在大多数情况下,在主题类中声明事件ID是合理的。
虽然有很多方法可以声明事件ID,但只讨论了其中最感兴趣的三种:
枚举 看起来表面上是最合适的选择:
class FigureSubject : public Subject
{
public:
enum {
evSizeChanged,
evPositionChanged
};
};
枚举类型的比较(将在订阅和触发时发生)很快。这种策略唯一的不便之处可能是观察者需要在订阅时指定类:
// 'FigureSubject::' is the annoying bit.
aSubject->Subscribe( FigureSubject::evSizeChanged, this );
字符串提供了比枚举更加“宽松”的选项,因为通常主题类不会像枚举一样声明它们;而是客户端将只是使用:
// Observer code
aFigure->Subscribe( "evSizeChanged", this );
字符串的好处是大多数编译器会将其与其他参数以不同颜色区分开来,这些区分方式可以增加代码的可读性:
// Within a concrete subject
Fire( "evSizeChanged", mSize, iOldSize );
但是字符串的问题在于,我们无法在运行时检测到事件名称是否拼写错误。此外,与枚举比较相比,字符串比较需要更长的时间,因为必须逐个字符比较。
类型是本文讨论的最后一个选项:
class FigureSubject : public Subject
{
public:
class SizeChangedEventType : public Event {} SizeChangedEvent;
class PositionChangedEventType : public Event {} PositionChangedEvent;
};
使用类型的好处在于它们允许方法的重载,如 Subscribe()
(我们很快将看到它如何解决观察者中的常见问题):
FigureSubject::Subscribe( SizeChangedType aEvent, void *aObserver )
{
Subject::Subscribe( aEvent, aObserver );
Fire( aEvent, GetSize(), aObserver );
}
但是,观察者需要一些额外的代码才能订阅:
// Observer code
aFigure->Subscribe( aFigure->SizeChangedEvent, this );
观察者存储在哪里?
设计模式中的实现点1涉及每个主题的观察者应该存储在哪里。本节提供了3个选项以补充该讨论:
如设计模式中所建议的,存储主题-观察者映射的一种方法是使用全局哈希表。该表将包括主题、事件和观察者(或委托)。这是最省内存的方法,因为主题不使用成员变量来存储观察者列表 - 只有一个全局列表。如果该模式在 JavaScript 框架中实现,这可能非常有用,因为浏览器提供的内存有限。该方法的主要缺点是它也是最慢的 - 对于每个触发的事件,我们首先必须从全局哈希中筛选出请求的主题,然后筛选请求的事件,最后才遍历所有观察者。
设计模式还建议每个主题都保留其观察者列表。这会占用稍微更多的内存(以每个主题的std :: map成员变量的形式),但它比全局哈希提供更好的性能,因为主题只需要筛选请求的事件,然后遍历该事件的所有观察者。代码如下所示:
class Subject
{
protected:
typedef std::pair< EventId, Delegate > Callback;
typedef std::multimap< EventId, Delegate > Callbacks;
typedef Callbacks::iterator CallbackIterator;
typedef std::pair< CallbackIterator, CallbackIterator> CallbacksRange;
Callbacks mCallbacks;
public:
void Fire( EventId aEventId )
{
CallbacksRange iEventCallbacks;
CallbackIterator iIterator;
iEventCallbacks = mCallbacks.equal_range( aEventId );
for ( iIterator = iEventCallbacks.first; iIterator != iEventCallbacks.second; ++iIterator )
{
}
}
};
在设计模式中不建议使用将每个事件作为成员变量,然后在事件本身内存储观察者的选项。这是最消耗内存的策略,因为每个事件不仅会使用成员变量,而且还有一个std::vector
来存储每个事件的观察者。然而,该策略提供了最佳性能,因为无需进行过滤,我们只需要遍历附加的观察者即可。与其他两种策略相比,该策略还将涉及最简单的代码。要实现它,事件必须提供订阅和触发方法:
class Event
{
public:
void Subscribe( void *aDelegate );
void Unsubscribe( void *aDelegate );
void Fire();
};
主题可能长这样:
class ConcreteSubject : public Subject
{
public:
class SizeChangedEventType : public Event {} SizeChangedEvent;
class PositionChangedEventType : public Event {} PositionChangedEvent;
};
虽然观察者理论上可以直接订阅事件,但我们会看到通过主题进行订阅更加划算:
// Subscribing to the event directly - possible but will limit features.
aSubject->SizeChangedEvent.Subscribe( this );
// Subscribing via the subject.
aSubject->Subscribe( aSubject->SizeChangedEvent, this );
三种策略提供了一个明显的存储与计算权衡案例,并可以使用以下表格进行比较:
采用的方法应考虑以下因素:
- 主题/观察者比 - 在观察者较少,主题较多的系统中,内存惩罚将更高,特别是在典型主题没有或仅有一个观察者的情况下。
- 通知频率 - 通知越频繁,性能惩罚越高。
当使用观察者模式来通知MouseMove
事件时,我们可能更需要考虑实现的性能。 就内存惩罚而言,以下计算可能会有所帮助。 假设:
- 使用每个事件策略
- 典型64位系统
- 每个主题平均有8个事件
800万个主题实例将消耗不到1GB的RAM(仅为事件内存)。
同一观察者,同一事件?
实施观察者模式的一个关键问题是是否允许同一观察者订阅相同主题的相同事件(of the same subject)。
首先,如果允许这样做,我们可能会使用std::multimap
而不是std::map
。 此外,以下行将存在问题:
aSubject->Unsubscribe( evSizeChanged, this );
由于主体不知道要取消订阅哪个之前的订阅(可能有多个!),因此Subscribe()
将返回一个标记,供Unsubscribe()
使用,并且整个实现变得更加复杂。
乍一看似乎相当愚蠢-为什么同一对象想要多次订阅同一个事件? 但考虑以下代码:
class Figure
{
public:
Figure( Subject *aSubject )
{
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
void OnSizeChanged( Size aSize )
{
}
};
class Circle : public Figure
{
public:
Circle( Subject *aSubject )
: Figure( aSubject)
{
aSubject->Subscribe( evSizeChanged, this, &Circle::OnSizeChanged );
}
void OnSizeChanged( Size aSize )
{
}
};
这段代码会导致同一对象订阅同一个事件两次。值得注意的是,由于OnSizeChanged()
方法不是虚方法,两个订阅调用之间的成员函数指针将不同。因此,在这种特定情况下,主题也可以比较成员函数指针,取消订阅的签名将是:
aSubject->Unsubscribe( evSizeChanged, this, &Circle::OnSizeChanged )
但如果 OnSizeChanged()
是虚拟的,则没有办法在没有令牌的情况下区分两个订阅调用。
说实话,如果 OnSizeChanged()
是虚拟的,那么 Circle
类再次订阅事件没有任何理由,因为它自己的处理程序将被调用,而不是基类的处理程序:
class Figure
{
public:
Figure( Subject *aSubject )
{
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
virtual void OnSizeChanged( Size aSize )
{
}
};
class Circle : public Figure
{
public:
Circle( Subject *aSubject )
: Figure( aSubject) { }
virtual void OnSizeChanged( Size aSize )
{
Figure::OnSizeChanged( aSize );
}
};
这段代码可能是在基类和其子类都必须响应同一事件时最好的折衷方案。但它要求处理程序是虚拟的,并且程序员需要知道基类订阅了哪些事件。
不允许同一个观察者多次订阅同一个事件大大简化了模式的实现。它省去了比较成员函数指针(一件棘手的事情)的需要,使得Unsubscribe()
可以像这样很短(即使使用Subscribe()
提供了MFP):
aSubject->Unsubscribe( evSizeChanged, this );
文章订阅一致性
观察者模式的主要目的之一是使观察者与其主题状态保持一致,我们已经看到状态更改事件确切地实现了这一点。
令人惊讶的是,在《设计模式》的作者们断言当观察者订阅主题时,前者的状态尚未与后者的状态一致。考虑以下代码:
class Figure
{
public:
Figure( FigureSubject *aSubject )
{
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
virtual void OnSizeChanged( Size aSize )
{
mSize = aSize;
Refresh();
}
private:
Size mSize;
};
创建Figure
类时,它会订阅其主题,但其大小与主题的大小不一致,也不会刷新视图以显示其正确大小。
当使用观察者模式触发状态更改事件时,通常需要在订阅后手动更新观察者。一种实现方法是在观察者内部进行:
class Figure
{
public:
Figure( FigureSubject *aSubject )
{
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
OnSizeChanged( aSubject->GetSize() );
}
};
假设有一个具有12个状态变化事件的主题,如果订阅后主题能够自动地发出正确的事件给观察者就很好了。
实现这个功能的一种方法是在具体主题中重载 Subscribe()
方法:
FigureSubject::Subscribe( evSizeChanged aEvent, Delegate aDelegate )
{
Subject::Subscribe( aEvent, aDelegate );
Fire( aEvent, GetSize(), aDelegate );
}
然后是观察者代码:
class Figure
{
public:
Figure( FigureSubject *aSubject )
{
aSubject->Subscribe( evSizeChanged, MAKEDELEGATE( this, &Figure::OnSizeChanged ) );
}
};
请注意,Fire
调用现在需要一个额外的参数 (aDelegate
),因此它只能更新该特定观察者,而不能更新已经订阅的观察者。
gxObserver 通过定义绑定事件来处理此场景。这些事件的唯一参数(除了可选的发送器)是绑定到 getter 或成员变量的:
class Subject : virtual public gxSubject
{
public:
gxDefineBoundEvent( evAge, int, GetAge() )
int GetAge() { return mAge; }
private:
int mAge;
}
这还允许主题仅提供事件类型来触发事件:
// Same as Fire( evAge, GetAge() );
Fire( evAge );
无论使用什么机制,值得记住的是:
- 需要确保观察者在状态事件订阅之后与其主题保持一致的方式。
- 最好将此实现在主题类中,而不是在观察者代码中。
Fire()
方法可能需要一个额外的可选参数,以便可以向单个观察者(刚刚订阅的观察者)发送事件。
触发过程
从基类触发
下面的代码片段显示了在 JUCE 中实现事件触发的方式:
void Button::sendClickMessage (const ModifierKeys& modifiers)
{
for (int i = buttonListeners.size(); --i >= 0;)
{
ButtonListener* const bl = (ButtonListener*) buttonListeners[i];
bl->buttonClicked (this);
}
}
以下方法存在一些问题:
- 从代码中可以看出,该类维护了自己的
buttonListeners
列表,并且这也意味着它还有自己的AddListener
和RemoveListener
方法。
- 具体的主题是在循环遍历观察者列表。
- 该主题与其观察者高度耦合,因为它知道其类(
ButtonListener
)和其中实际回调方法(buttonClicked
)。
所有这些都意味着没有基础的主题类。如果采用此方法,则任何触发/订阅机制都必须针对每个具体的主题重新实现。这是反面的面向对象编程。
在一个主题基类中处理观察者的管理、遍历和实际通知是明智的做法;这样,对底层机制(例如引入线程安全性)进行任何更改都不需要更改每个具体主题。这将使我们的具体主题具有良好封装和简单接口,并将触发减少到一行:
// In a concreate subject
Fire( evSize, GetSize() );
事件暂停和恢复
许多应用程序和框架需要暂停特定主题的事件触发。有时,我们希望已暂停的事件排队等待,直到我们恢复触发它们;有时,我们只想忽略这些事件。就主题接口而言:
class Subject
{
public:
void SuspendEvents( bool aQueueSuspended );
void ResumeEvents();
};
事件暂停是有用的一种实现方式,例如在组合对象销毁时。当一个组合对象被销毁时,它首先销毁所有子对象,这些子对象又会首先销毁它们所有的子对象,以此类推。如果这些组合对象驻留在模型层中,它们将需要通知视图层中对应的对象(使用evBeforeDestroy
事件):
在这种特殊情况下,每个对象都没有必要触发一个evBeforeDestroy
事件——只有顶级模型对象会触发(删除顶级视图对象也将删除其所有子对象)。因此,每当这样的组合对象被销毁时,它将希望暂停其子对象的事件(而不将它们排队)。
另一个例子是加载涉及许多对象的文档,其中一些对象观察其他对象。虽然一个主题可能首先加载并基于文件数据设置其大小,但它的观察者可能尚未加载,因此不会收到大小更改通知。在这种情况下,在加载之前,我们想暂停事件,但将它们排队直到完全加载文档。然后触发所有排队的事件将确保所有观察者与它们的主题保持一致。
最后,优化的队列不会多次排队同一主题的相同事件。当通知恢复时,如果稍后的排队事件将通知(20,20),则没有必要通知观察者大小更改为(10,10)。因此,队列应该保留每个事件的最新版本。
如何为类添加主题功能?
典型的主题接口如下:
class Subject
{
public:
virtual void Subscribe( aEventId, aDelegate );
virtual void Unsubscribe( aEventId, aDelegate );
virtual void Fire( aEventId );
}
问题是如何将此接口添加到各种类中。有三个选项可供考虑:
继承
在设计模式中,一个ConcreteSubject
类从一个Subject
类继承。
class ScrollManager: public Subject
{
}
《设计模式》中的类图和示例代码会让人误以为这就是正确的做法。但同一本书也警告我们不要过度使用继承,而是推荐使用组合。这是有道理的:考虑一个应用程序,其中有许多复合对象,只有其中一些是主题;那么Composite
类应该继承Subject
类吗?如果这样做,许多复合对象将具备它们不需要的主题功能,并且可能会有内存损耗,因为观察者列表变量总是为空。
组合
大多数应用程序和框架都需要将主题功能“插入”到选定的类中,这些类不一定是基类。组合允许精确实现该目标。实际上,一个类将拥有一个成员mSubject
,提供对所有主题方法的接口,如下所示:
class ScrollManager: public SomeObject
{
public:
Subject mSubject;
}
这种策略的一个问题是,它会对每个支持主题的类带来内存开销(成员变量)。另一个问题是,它使得访问主题协议有点繁琐:
// Notification within a class composed with the subject protocol.
mSubject.Fire( ... );
// Or the registration from an observer.
aScrollManager.mSubject.Subscribe( ... );
多重继承
多重继承允许我们随意将主题协议组合到一个类中,但不会出现成员组合的陷阱:
class ScrollManager: public SomeObject,
public virtual Subject
{
}
这样,我们就从之前的例子中摆脱了mSubject
,现在只剩下:
// Notification within a subject class.
Fire( ... );
// Or the registration from an observer.
aScrollManager.Subscribe( ... );
请注意,我们在主题继承中使用 public virtual
,因此如果 ScrollManager
的子类决定重新继承协议,我们不会重复得到接口。但可以合理地假设程序员会注意到基类已经是一个主题,因此没有理由重新继承它。
虽然多重继承通常不被鼓励,也并非所有语言都支持,但对于这个目的来说,它是值得考虑的。ExtJs基于Javascript,不支持多重继承,使用混合(mixins)来实现相同的功能:
Ext.define('Employee', {
mixins: {
observable: 'Ext.util.Observable'
},
constructor: function (config) {
this.mixins.observable.constructor.call(this, config);
}
});
结论
总之,在实现观察者模式时,应该考虑以下关键点:
- 主题应该触发包括状态和无状态事件在内的事件——后者只能通过推送模型实现。
- 主题通常会触发多种类型的事件。
- 观察者可以订阅多个主题的同一事件。这意味着主题-观察者协议应该允许指定发送者。
- 任意事件处理程序极大地简化了客户端代码,并促进了首选的变量推送模型;但它们的实现并不直接,会导致更复杂的主题代码。
- 事件处理程序可能需要是虚拟的。
- 观察者可以存储在全局哈希表、每个主题或每个事件中。选择形成内存、性能和代码简洁性之间的权衡。
- 理想情况下,观察者仅对相同主题的相同事件进行一次订阅。
- 保持观察者与主题状态的一致性是需要记住的事情。
- 事件的触发可能需要被挂起,并提供一个排队选项,然后恢复。
- 多重继承在给类添加主题能力时值得考虑。
(第二部分结束)