我正在编写的C ++ MVC框架大量使用了观察者模式。我已经详细阅读了设计模式(GoF,1995)中的相关章节,并了解了文章和现有库(包括Boost)中的大量实现。
但是当我实现这个模式时,我无法理解必须有一个更好的方法 - 我的客户端代码涉及线条和片段,我认为它应该被重构到模式本身,如果我能找到一个克服一些C ++限制的方法。此外,我的语法从未像ExtJs库中那样优雅:
// Subscribing
myGridPanel.on( 'render', this.onRender );
// Firing
this.fireEvent( 'render', null, node );
所以我决定进行进一步的研究,试图达到一般化的实现,同时优先考虑代码的优点,可读性和性能。我相信我在第5次尝试中获得了累积奖金。
actual implementation,名为gxObserver
,可在GitHub上找到;它是完整的文档和自述文件拼写出来的优点和缺点。它的语法是:
// Subscribing
mSubject->gxSubscribe( evAge, OnAgeChanged );
// Firing
Fire( evAge, 69 );
做了过多的工作后,我觉得只是与SO社区分享我的发现。所以下面我将回答这个问题:
在实现观察者模式时,程序员应该考虑哪些额外的考虑因素(设计模式中提供的那些)?
虽然专注于C ++,但下面的许多内容都适用于任何语言。
请注意:由于SO限制了30000个单词的答案,我的答案必须分为两部分,但有时候第二个答案(首先是“主题”出现的答案)。答案的第1部分是从设计模式的类图开始的。
答案 0 :(得分:11)
(第一部分开始)
设计模式将观察者模式与对象'状态联系起来。如上面的类图(来自设计模式)所示,可以使用SetState()
方法设置主题的状态;在国家变更后,主体将通知其所有观察员;然后观察者可以使用GetState()
方法查询新状态。
但是,GetState()
不是主题基类中的实际方法。相反,每个具体主题都提供自己专门的状态方法。实际代码可能如下所示:
SomeObserver::onUpdate( aScrollManager )
{
// GetScrollPosition() is a specialised GetState();
aScrollPosition = aScrollManager->GetScrollPosition();
}
什么是对象状态?我们将其定义为状态变量的集合 - 需要持久化的成员变量(以便稍后恢复)。例如,BorderWidth
和FillColour
都可以是Figure类的状态变量。
我们可以拥有多个状态变量的想法 - 因此对象的状态可以以多种方式改变 - 这一点非常重要。这意味着受试者可能会发生多种类型的状态变化事件。它还解释了为什么在主题基类中使用GetState()
方法毫无意义。
但是只能处理状态变化的观察者模式是不完整的 - 观察者观察无状态通知(即与状态无关的通知)是很常见的。例如,KeyPress
或MouseMove
OS事件;或类似BeforeChildRemove
的事件,这显然不表示实际的状态变化。这些无状态事件足以证明推送机制的合理性 - 如果观察者无法从主题中检索更改信息,则所有信息都必须与通知一起提供(稍后将详细介绍)。
很容易看出现实生活中的情况如何?一个主题可以解雇许多类型的事件;快速浏览一下ExtJs库,可以看出有些类提供了超过30个事件。因此,广义的主题 - 观察者协议必须集成设计模式所谓的“兴趣”和“兴趣”。 - 允许观察者订阅特定事件,并且只对感兴趣的观察者发起该事件。
// A subscription with no interest.
aScrollManager->Subscribe( this );
// A subscription with an interest.
aScrollManager->Subscribe( this, "ScrollPositionChange" );
单个观察者可以观察来自多个主体的相同事件(使观察者 - 主体关系多对多)。例如,属性检查员可以监听许多所选对象的相同属性的更改。如果观察者对发送通知的主体感兴趣,则通知必须包含发件人:
SomeSubject::AdjustBounds( aNewBounds )
{
...
// The subject also sends a pointer to itself.
Fire( "BoundsChanged", this, aNewBounds );
}
// And the observer receives it.
SomeObserver::OnBoundsChanged( aSender, aNewBounds )
{
}
但值得注意的是,在许多情况下,观察者并不关心发件人的身份。例如,当主体是单身时,或者当观察者对事件的处理不依赖于主体时。因此,代替强制发件人成为协议的一部分,我们应该允许它,将其留给程序员,无论是否拼写发件人。
处理事件的观察者方法(即事件处理程序)可以有两种形式:覆盖或任意。在观察员的实施中提供了关键和复杂的部分,本节将讨论这两者。
重写的处理程序是Design Patterns提供的解决方案。基本Subject类定义虚拟OnEvent()
方法,子类重写它:
class Observer
{
public:
virtual void OnEvent( EventType aEventType, Subject* aSubject ) = 0;
};
class ConcreteObserver
{
virtual void OnEvent( EventType aEventType, Subject* aSubject )
{
}
};
请注意,我们已经解释了主题通常会触发多种类型的事件。但是在OnEvent
方法中处理所有事件(特别是如果它们有几十个)是不实用的 - 如果每个事件都在自己的处理程序中处理,我们可以编写更好的代码;实际上,这使得OnEvent
成为其他处理程序的事件路由器:
void ConcreteObserver::OnEvent( EventType aEventType, Subject* aSubject )
{
switch( aEventType )
{
case evSizeChanged:
OnSizeChanged( aSubject );
break;
case evPositionChanged:
OnPositionChanged( aSubject );
break;
}
}
void ConcreteObserver::OnSizeChanged( Subject* aSubject )
{
}
void ConcreteObserver::OnPositionChanged( Subject* aSubject )
{
}
拥有重写(基类)处理程序的优点是它易于实现。订阅主题的观察者可以通过提供对自己的引用来做到这一点:
void ConcreteObserver::Hook()
{
aSubject->Subscribe( evSizeChanged, this );
}
然后主题只保留Observer
个对象的列表,并且触发代码可能如此:
void Subject::Fire( aEventType )
{
for ( /* each observer as aObserver */)
{
aObserver->OnEvent( aEventType, this );
}
}
重写处理程序的缺点是它的签名是固定的,这使得额外参数(在推模型中)的传递变得棘手。此外,对于每个事件,程序员必须维护两位代码:路由器(OnEvent
)和实际处理程序(OnSizeChanged
)。
克服被覆盖的OnEvent
处理程序的不足的第一步是......没有全部!如果我们可以告诉主题哪个方法来处理每个事件,那将是很好的。像这样:
void SomeClass::Hook()
{
// A readable Subscribe( evSizeChanged, OnSizeChanged ) has to be written like this:
aSubject->Subscribe( evSizeChanged, this, &ConcreteObserver::OnSizeChanged );
}
void SomeClass::OnSizeChanged( Subject* aSubject )
{
}
请注意,通过此实现,我们不再需要我们的类继承Observer
类;实际上,我们根本不需要Observer类。这个想法不是新的,它在Herb Sutter’s 2003 Dr Dobbs article called ‘Generalizing Observer’中有详细描述。但是,在C ++中实现任意回调并不是一件简单的事情。赫伯在他的文章中使用function
设施,但不幸的是,他提案中的一个关键问题尚未完全解决。该问题及其解决方案如下所述。
由于C ++不提供本机委托,我们需要使用成员函数指针(MFP)。 C ++中的MFP是类函数指针而不是对象函数指针,因此我们必须为Subscribe
(MFP)和&ConcreteObserver::OnSizeChanged
(对象实例)提供this
方法。我们将此组合称为委托。
成员函数指针+对象实例=委托
Subject
类的实现可能依赖于比较代理的能力。例如,在我们希望向特定代表发起事件的情况下,或者当我们想要取消订阅特定代表时。如果处理程序不是虚拟处理程序并且属于订阅类(与基类中声明的处理程序相对),则委托可能具有可比性。但在大多数其他情况下,编译器或继承树(虚拟或多重继承)的复杂性将使它们无法比拟。 Don Clugston has written a fantastic in-depth article关于这个问题,他还提供了一个克服问题的C ++库;虽然不符合标准,但该库几乎适用于所有编译器。
值得一提的是,虚拟事件处理程序是否是我们真正需要的东西;也就是说,我们是否可能有一个观察者子类想要覆盖(或扩展)其(具体观察者)基类的事件处理行为的场景。可悲的是,答案是这很可能。因此,广义的观察者实现应该允许虚拟处理程序,我们很快就会看到一个这样的例子。
Design Patterns的实现第7点描述了pull vs push模型。本节扩展了讨论范围。
使用拉模型,主题发送最少的通知数据,然后观察者需要从主题中检索更多信息。
我们已经确定拉模型不适用于BeforeChildRemove
等无状态事件。或许还值得一提的是,使用pull模型,程序员需要为每个事件处理程序添加代码行,这些代码不会与推送模型一起存在:
// Pull model
void SomeClass::OnSizeChanged( Subject* aSubject )
{
// Annoying - I wish I didn't had to write this line.
Size iSize = aSubject->GetSize();
}
// Push model
void SomeClass::OnSizeChanged( Subject* aSubject, Size aSize )
{
// Nice! We already have the size.
}
另一件值得记住的事情是我们可以使用推模型实现拉模型,但不是相反。虽然推模型为观察者提供了所需的所有信息,但程序员可能不希望发送特定事件的信息,并让观察者向主体询问更多信息。
使用固定推送模型,通知所携带的信息将通过约定的数量和类型的参数传递给处理程序。这很容易实现,但由于不同的事件将具有不同数量的参数,因此必须找到一些解决方法。在这种情况下,唯一的解决方法是将事件信息打包到一个结构(或类)中,然后传递给处理程序:
// The event base class
struct evEvent
{
};
// A concrete event
struct evSizeChanged : public evEvent
{
// A constructor with all parameters specified.
evSizeChanged( Figure *aSender, Size &aSize )
: mSender( aSender ), mSize( aSize ) {}
// A shorter constructor with only sender specified.
evSizeChanged( Figure *aSender )
: mSender( aSender )
{
mSize = aSender->GetSize();
}
Figure *mSender;
Size mSize;
};
// The observer's event handler, it uses the event base class.
void SomeObserver::OnSizeChanged( evEvent *aEvent )
{
// We need to cast the event parameter to our derived event type.
evSizeChanged *iEvent = static_cast<evSizeChanged*>(aEvent);
// Now we can get the size.
Size iSize = iEvent->mSize;
}
现在虽然主题及其观察者之间的协议很简单,但实际实现相当漫长。有一些缺点需要考虑:
首先,我们需要为每个事件编写大量代码(请参阅evSizeChanged
)。很多代码都不好。
其次,涉及的一些设计问题并不容易回答:我们是否应该在evSizeChanged
课程旁边宣布Size
,或者与激发它的主题一起宣布?如果你考虑一下,两者都不理想。那么,尺寸变化通知是否总是带有相同的参数,还是依赖于主体? (答案:后者是可能的。)
第三,有人需要在开始之前创建事件的实例,然后将其删除。因此,主题代码将如下所示:
// Argh! 3 lines of code to fire an event.
evSizeChanged *iEvent = new evSizeChanged( this );
Fire( iEvent );
delete iEvent;
或者我们这样做:
// If you are a programmer looking at this line than just relax!
// Although you can't see it, the Fire method will delete this
// event when it exits, so no memory leak!
// Yes, yes... I know, it's a bad programming practice, but it works.
// Oh.. and I'm not going to put such comment on every call to Fire(),
// I just hope this is the first Fire() you'll look at and just
// remember.
Fire( new evSizeChanged( this ) );
第四,有一个铸造业务正在进行中。我们在处理程序中完成了转换,但也可以在主题的Fire()
方法中完成。但是这将涉及动态转换(性能代价高昂),或者我们进行静态转换,如果事件被触发并且处理程序期望的事件不匹配,则可能导致灾难。
第五,处理程序arity几乎没有可读性:
// What's in aEvent? A programmer will have to look at the event class
// itself to work this one out.
void SomeObserver::OnSizeChanged( evSizeChanged *aEvent )
{
}
与此相反:
void SomeObserver::OnSizeChanged( ZoomManager* aManager, Size aSize )
{
}
这引导我们进入下一部分。
就查看代码而言,许多程序员都希望看到这个主题代码:
void Figure::AdjustBounds( Size &aSize )
{
// Do something here.
// Now fire
Fire( evSizeChanged, this, aSize );
}
void Figure::Hide()
{
// Do something here.
// Now fire
Fire( evVisibilityChanged, false );
}
这个观察者代码:
void SomeObserver::OnSizeChanged( Figure* aFigure, Size aSize )
{
}
void SomeObserver::OnVisibilityChanged( aIsVisible )
{
}
主题的Fire()
方法和观察者处理程序每个事件都有不同的arity。代码是可读的,并且可以达到我们希望的那么短。
此实现涉及非常干净的客户端代码,但会带来相当复杂的Subject
代码(具有大量功能模板和可能的其他好东西)。这是大多数程序员将要采取的权衡 - 最好在一个地方(Subject类)拥有复杂的代码,而不是许多(客户端代码);如果主题类完美无缺,那么程序员可能只会把它视为一个黑盒子,对它的实现方式一无所知。
值得考虑的是如何以及何时确保Fire
arity和处理程序arity匹配。我们可以在运行时完成它,如果两者不匹配,我们会提出一个断言。但是如果我们在编译期间遇到错误会非常好,为此我们必须明确地声明每个事件的arity,如下所示:
class Figure : public Composite,
public virtual Subject
{
public:
// The DeclareEvent macro will store the arity somehow, which will
// then be used by Subscribe() and Fire() to ensure arity match
// during compile time.
DeclareEvent( evSizeChanged, Figure*, Size )
DeclareEvent( evVisibilityChanged, bool )
};
我们稍后会看到这些事件声明如何发挥另一个重要作用。
(第一部分结束)