(第二部分开始)
科目
订阅过程
存储的是什么?
根据具体实现,在观察者订阅时,主体可能会存储以下数据:
- 事件 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 )
{
//...
}
实际存储(可能在 a 中std::map
)将涉及事件 id 作为键和委托作为值。
实现事件 ID
在触发它们的主题类之外定义事件 ID 可以简化对这些 ID 的访问。但一般来说,主体触发的事件是该主体独有的。因此,在大多数情况下,在主题类中声明事件 ID 是合乎逻辑的。
虽然声明事件 ID 的方法不止几种,但这里只讨论最感兴趣的 3 种:
从表面上看,枚举似乎是最合乎逻辑的选择:
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:
// Declaring the events this subject supports.
class SizeChangedEventType : public Event {} SizeChangedEvent;
class PositionChangedEventType : public Event {} PositionChangedEvent;
};
使用类型的好处是它们允许重载方法,例如Subscribe()
(我们很快就会看到它可以解决观察者的常见问题):
// This particular method will be called only if the event type is SizeChangedType
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:
// A callback is represented by the event id and the delegate.
typedef std::pair< EventId, Delegate > Callback;
// A map type to store callbacks
typedef std::multimap< EventId, Delegate > Callbacks;
// A callbacks iterator
typedef Callbacks::iterator CallbackIterator;
// A range of iterators for use when retrieving the range of callbacks
// of a specific event.
typedef std::pair< CallbackIterator, CallbackIterator> CallbacksRange;
// The actual callback list
Callbacks mCallbacks;
public:
void Fire( EventId aEventId )
{
CallbacksRange iEventCallbacks;
CallbackIterator iIterator;
// Get the callbacks for the request event.
iEventCallbacks = mCallbacks.equal_range( aEventId );
for ( iIterator = iEventCallbacks.first; iIterator != iEventCallbacks.second; ++iIterator )
{
// Do the firing.
}
}
};
设计模式中不建议将每个事件作为成员变量,然后将观察者存储在事件本身中。这是最消耗内存的策略,因为不仅每个事件消耗一个成员变量,而且每个事件还有一个std::vector
存储观察者。然而,这种策略提供了最好的性能,因为没有过滤要做,我们可以遍历附加的观察者。与其他两个相比,此策略还将涉及最简单的代码。要实现它,事件必须提供订阅和触发方法:
class Event
{
public:
void Subscribe( void *aDelegate );
void Unsubscribe( void *aDelegate );
void Fire();
};
主题可能看起来像这样:
class ConcreteSubject : public Subject
{
public:
// Declaring the events this subject supports.
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 );
这 3 种策略提供了存储与计算权衡的明确案例。并且可以使用下表进行比较:
所采取的方法应考虑以下因素:
- 受试者/观察者比率——在观察者少而受试者多的系统中,记忆损失会更高,尤其是在典型的受试者没有观察者或只有一个观察者的情况下。
- 通知的频率——通知越频繁,性能损失就越高。
当使用观察者模式来通知MouseMove
事件时,可能需要更多地考虑实现的性能。就内存损失而言,以下计算可能会有所帮助。鉴于:
- 使用按事件策略
- 典型的 64 位系统
- 每个科目平均有 8 个事件
800 万个主题实例将消耗略低于 1GB 的 RAM(仅限事件内存)。
同一个观察者,同一个事件?
观察者模式实现中的一个关键问题是我们是否允许同一个观察者多次订阅同一个事件(同一个主题)。
首先,如果我们允许它,我们可能会使用std::multimap
而不是std::map
. 此外,以下行将有问题:
aSubject->Unsubscribe( evSizeChanged, this );
由于主题无法知道要取消订阅之前的哪个订阅(可能不止一个!)。因此Subscribe()
必须返回一个Unsubscribe()
将使用的令牌,整个实现变得更加复杂。
从表面上看,这似乎很愚蠢——为什么同一个对象要多次订阅同一个事件?但请考虑以下代码:
class Figure
{
public:
Figure( Subject *aSubject )
{
// We subscribe to the subject on size events
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
void OnSizeChanged( Size aSize )
{
}
};
class Circle : public Figure
{
public:
Circle( Subject *aSubject )
: Figure( aSubject)
{
// We subscribe to the subject on size events
aSubject->Subscribe( evSizeChanged, this, &Circle::OnSizeChanged );
}
void OnSizeChanged( Size aSize )
{
}
};
此特定代码将导致同一对象订阅同一事件两次。还值得注意的是,由于该OnSizeChanged()
方法不是虚拟的,因此两次订阅调用之间的成员函数指针会有所不同。因此,在这种特殊情况下,主题还可以比较成员函数指针,取消订阅签名将是:
aSubject->Unsubscribe( evSizeChanged, this, &Circle::OnSizeChanged );
但是如果OnSizeChanged()
是虚拟的,那么没有令牌就无法区分两个订阅调用。
说实话,如果OnSizeChanged()
是虚拟的,则该类没有理由Circle
再次订阅该事件,因为它将被调用的是它自己的处理程序,而不是基类的处理程序:
class Figure
{
public:
// Constructor
Figure( Subject *aSubject )
{
// We subscribe to the subject on size events
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
virtual void OnSizeChanged( Size aSize )
{
}
};
class Circle : public Figure
{
public:
// Constructor
Circle( Subject *aSubject )
: Figure( aSubject) { }
// This handler will be called first when evSizeChanged is fired.
virtual void OnSizeChanged( Size aSize )
{
// And we can call the base class handler if we want.
Figure::OnSizeChanged( aSize );
}
};
当基类及其子类都必须响应同一事件时,此代码可能代表了最佳折衷方案。但它要求处理程序是虚拟的,并且程序员需要知道基类订阅了哪些事件。
不允许同一个观察者多次订阅同一个事件可以极大地简化模式的实现。它省去了比较成员函数指针的需要(一项棘手的工作),并且它允许Unsubscribe()
像这样短(即使 MFP 提供了Subscribe()
):
aSubject->Unsubscribe( evSizeChanged, this );
订阅后一致性
观察者模式的主要目标之一是让观察者与其主体状态保持一致——我们已经看到状态变化事件正是这样做的。
有点令人惊讶的是,设计模式的作者断言当观察者订阅一个主题时,前者的状态与后者的状态不一致。考虑这段代码:
class Figure
{
public:
// Constructor
Figure( FigureSubject *aSubject )
{
// We subscribe to the subject on size events
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
}
virtual void OnSizeChanged( Size aSize )
{
mSize = aSize;
// Refresh the view.
Refresh();
}
private:
Size mSize;
};
创建时,Figure
该类确实订阅了它的主题,但它的大小与主题的大小不一致,它也不会刷新视图以显示其正确大小。
当使用观察者模式触发状态改变事件时,经常会发现订阅后需要手动更新观察者。实现这一点的一种方法是在观察者内部:
class Figure
{
public:
Figure( FigureSubject *aSubject )
{
// We subscribe to the subject on size events
aSubject->Subscribe( evSizeChanged, this, &Figure::OnSizeChanged );
// Now make sure we're consistent with the subject.
OnSizeChanged( aSubject->GetSize() );
}
// ...
};
但是想象一个有 12 个状态变化事件的主题。如果整个事情会自动发生,那就太好了,在订阅时,主题将向观察者发送正确的事件。
实现这一点的一种方法需要Subscribe()
在具体主题中使用重载方法:
// This method assumes that each event has its own unique class, so the method
// can be overloaded.
FigureSubject::Subscribe( evSizeChanged aEvent, Delegate aDelegate )
{
Subject::Subscribe( aEvent, aDelegate );
// Notice the last argument in this call.
Fire( aEvent, GetSize(), aDelegate );
}
然后是观察者代码:
class Figure
{
public:
Figure( FigureSubject *aSubject )
{
// We subscribe to the subject on size events.
// The subject will fire the event upon subscription
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
决定重新继承协议,我们不会获得两次接口。但是可以公平地假设程序员会注意到基类已经是一个主题,因此没有理由重新继承它。
虽然通常不鼓励多重继承,并且并非所有语言都支持它,但为此目的值得考虑。基于 Javascript 的 ExtJs 不支持多重继承,使用 mixins 来实现同样的功能:
Ext.define('Employee', {
mixins: {
observable: 'Ext.util.Observable'
},
constructor: function (config) {
this.mixins.observable.constructor.call(this, config);
}
});
结论
总结本文,观察者模式的通用实现应考虑以下关键点:
- 主体会触发有状态和无状态事件——后者只能通过推送模型来实现。
- 对象通常会触发不止一种类型的事件。
- 观察者可以订阅具有多个主题的同一事件。这意味着主题观察者协议应该允许发送者被拼写。
- 任意事件处理程序极大地简化了客户端代码并促进了首选的可变性推送模型;但它们的实现并不直接,并且会导致更复杂的主题代码。
- 事件处理程序可能需要是虚拟的。
- 观察者可以存储在全局散列中、每个主题或每个事件中。该选择形成了内存、性能和代码简单性之间的权衡。
- 理想情况下,观察者只订阅一次相同主题的相同事件。
- 在订阅后立即让观察者与其主题的状态保持一致是需要牢记的。
- 事件的触发可能需要使用排队选项暂停,然后再恢复。
- 向类添加主题功能时,值得考虑多重继承。
(第二部分结束)