观察者模式 - 进一步的考虑和通用的C++实现

13
我正在编写一个使用观察者模式的C++ MVC框架。我仔细阅读了《设计模式》(GoF,1995年)中相关章节,并查看了许多文章和现有库(包括Boost)中的实现。
但是在我实现该模式时,我感到必须有更好的方法 - 我的客户端代码包含了一些应该重构到模式本身中的行和片段,只要我能够找到一种方式来克服一些C++的限制。此外,我的语法从未像ExtJs库中使用的那样优雅:
// Subscribing
myGridPanel.on( 'render', this.onRender );

// Firing
this.fireEvent( 'render', null, node );

因此,我决定进行进一步的研究以试图得出一个通用的实现,同时优先考虑代码的优雅性、可读性和性能。我相信在第5次尝试中已经击中了大奖。

这个被称为gxObserver实际实现已经可以在GitHub上获取;它有很好的文档说明,readme文件详细列出了其利与弊。它的语法是:

// Subscribing
mSubject->gxSubscribe( evAge, OnAgeChanged );

// Firing
Fire( evAge, 69 );

经过一番繁重的工作,我认为与 SO 社区分享我的发现是公平的。因此,在下面我将回答这个问题:

在实现观察者模式时,程序员应该考虑哪些额外因素(除了设计模式中提出的因素)?

虽然重点是 C++,但下面所述的许多内容适用于任何语言。

请注意:由于 SO 将答案限制在 30000 个字以内,我的答案必须分为两部分,但有时第二个答案(从“Subjects”开始的答案)会先显示。答案的第 1 部分是从设计模式的类图开始的那一部分。


你有没有看过 Boost.Signals2? - Andy Prowl
我已经查看了Boost。 - Izhaki
3
我了解这一点,这是一个高尚的努力,但为了邀请别人阅读长篇回答(实际上是两篇),你应该首先指出为什么现有工具不适用,它们缺少什么。简而言之,需要有证明您工作合理的东西。如果您必须发明新东西,请解释其新颖之处。从您在问题中提到的例子中,我确实看不出您的库相比Boost.Signals2有何不同。 - Andy Prowl
你能否在标题为“如何制作椅子”的文章下留言说“你听说过宜家吗?”?我的意思是,朋友们,你们没有抓住重点,但我不能怪你们——你们的评论是在我发布问题几秒钟后就来了,而你们没有机会阅读答案,其中包括性能、无状态事件、暂停和恢复等问题,这些对于使用Boost作为底层机制的人来说都是非常相关的事件。更不用说我已经断言这篇文章不仅仅适用于C++。 - Izhaki
我从未声称我的实现比Boost更好,但我仍然认为有些人会发现它有用。真正的重点是这篇文章。你得推我很难才会听到我说关于Boost的任何不好的话。唯一我想确认的是,如果Boost也使Don Clugston的文章无用了 - 就我所知,Boost仍然存在Don讨论的问题。无论如何,我不认为这是讨论Boost的地方 - 如我所说,它可能是实现的一部分。请将我的答案视为设计模式的附录。 - Izhaki
显示剩余2条评论
2个回答

15

enter image description here

(第一部分开始)

前提条件

并不是所有都关于状态

设计模式将观察者模式与对象“状态”相关联。如上面的类图所示(来自设计模式),可以使用SetState()方法设置主题的状态;在状态改变时,主题将通知其所有观察者;然后观察者可以使用GetState()方法查询新状态。

但是,GetState()不是主题基类中的实际方法。相反,每个具体的主题都提供了自己专门的状态方法。实际代码可能如下所示:

SomeObserver::onUpdate( aScrollManager )
{
    // GetScrollPosition() is a specialised GetState();
    aScrollPosition = aScrollManager->GetScrollPosition();
}

对象状态是什么?我们定义它为需要持久化(以备后续恢复)的状态变量集合 - 成员变量。例如,BorderWidthFillColour 都可以成为 Figure 类的状态变量。
拥有多个状态变量 - 因此对象的状态可以以不止一种方式更改 - 这个想法很重要。这意味着主题可能会触发多种类型的状态更改事件。这也解释了为什么在主题基类中具有 GetState() 方法没有太多意义。
但只能处理状态更改的观察者模式是不完整的 - 观察者通常观察无状态通知,即与状态无关的通知。例如,KeyPressMouseMove 操作系统事件;或者像 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 )
{
}

值得注意的是,在许多情况下,观察者并不关心发送方的身份。例如,当主题是单例或观察者对事件的处理不依赖于主题时。因此,我们应该允许发送方成为协议的一部分,而不是强制要求它成为协议的一部分,将是否指明发送方的责任留给程序员。
观察者
事件处理程序
观察者处理事件的方法(即事件处理程序)有两种形式:重写和任意形式。这两种形式在实现观察者的过程中起着至关重要和复杂的作用,在本节中进行了讨论。
重写的处理程序
重写的处理程序是设计模式提出的解决方案。基础 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 );
}

然后,主题只需保留一个观察者对象列表,触发代码可能如下所示:

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在他2003年的Dr Dobbs文章中详细描述了“通用观察器”。但是,在C++中实现任意回调并不是一件简单的事情。Herb在他的文章中使用了funcation工具,但不幸的是,他提出的一个关键问题并没有完全解决。下面将介绍这个问题及其解决方案。
由于C++不提供本地委托,我们需要使用成员函数指针(MFP)。在C++中,MFP是类函数指针,而不是对象函数指针,因此我们必须向Subscribe方法提供&ConcreteObserver::OnSizeChanged(MFP)和this(对象实例)。我们将这个组合称为“委托”。

成员函数指针 + 对象实例 = 委托

Subject类的实现可能依赖于比较委托的能力。例如,当我们希望将事件触发到特定的委托时,或者当我们想要取消订阅特定的委托时。如果处理程序不是虚拟的并且属于订阅类(而不是在基类中声明的处理程序),则委托很可能是可比较的。但在大多数其他情况下,编译器或继承树的复杂性(虚拟或多重继承)会使它们无法比较。Don Clugston撰写了一篇深入的文章,探讨了这个问题,并提供了一个C++库来解决这个问题;虽然不符合标准,但该库可以与几乎所有编译器配合使用。

值得问一下,虚拟事件处理程序是否是我们真正需要的;也就是说,我们是否有一种情况,其中观察者子类希望覆盖(或扩展)其(具体观察者)基类的事件处理行为。遗憾的是,答案是肯定的。因此,通用的观察者实现应该允许虚拟处理程序,我们很快将看到一个例子。

更新协议

设计模式的第七个实现点讨论了拉(Pull)和推(Push)模型。本节将对此进行扩展。

拉模型

使用拉模型时,主题仅发送最小限度的通知数据,观察者需要从主题中检索更多信息。

我们已经确定,对于无状态事件(例如BeforeChildRemove),拉模型是行不通的。值得一提的是,使用拉模型时,程序员需要为每个事件处理程序添加代码行,而这在推模型下是不存在的:

// 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)。太多的代码是不好的。

其次,涉及到一些设计问题,这些问题并不容易回答:我们应该在Size类旁声明evSizeChanged,还是在触发它的主体旁声明?如果您仔细思考,两者都不理想。那么,大小更改通知是否总是携带相同的参数,或者会因主体而异?(答案:后者是可能的。)

第三,在触发事件之前,某人需要创建事件的实例,并在触发后将其删除。因此,主体代码将如下所示:

// 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()方法中完成。但这将涉及动态转换(性能代价高),或者我们进行静态转换,如果触发事件和处理程序期望的事件不匹配,可能会导致灾难。
第五,处理程序的参数数量很难读懂:
// 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()方法和观察者处理程序具有不同的元数。代码可读性高且尽可能短。

这种实现方式涉及非常干净的客户端代码,但会带来相当复杂的Subject代码(包含大量函数模板和其他好东西)。这是大多数程序员会接受的权衡 - 最好将复杂的代码放在一个地方(主题类),而不是许多地方(客户端代码);并且鉴于主题类的工作完美无缺,程序员可能只将其视为黑盒子,不太关心它的实现方式。

值得考虑的是如何以及何时确保Fire元数和处理程序元数匹配。我们可以在运行时执行此操作,如果两者不匹配,则引发断言。但是,如果我们想要在编译时获得错误,那将非常好,为此,我们必须显式声明每个事件的元数,例如:

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 )
};

我们稍后会看到这些事件声明还有另一个重要的作用。
(第一部分结束)

11

(第二部分开始)

主题

订阅过程

存储了什么?

根据具体实现,观察者订阅时,主题可能存储以下数据:

  • 事件 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:
    // 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 );

三种策略提供了一个明显的存储与计算权衡案例,并可以使用以下表格进行比较:

enter image description here

采用的方法应考虑以下因素:

  • 主题/观察者比 - 在观察者较少,主题较多的系统中,内存惩罚将更高,特别是在典型主题没有或仅有一个观察者的情况下。
  • 通知频率 - 通知越频繁,性能惩罚越高。

当使用观察者模式来通知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 )
    {
        // 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()可以像这样很短(即使使用Subscribe()提供了MFP):

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列表,并且这也意味着它还有自己的AddListenerRemoveListener方法。
  • 具体的主题是在循环遍历观察者列表。
  • 该主题与其观察者高度耦合,因为它知道其类(ButtonListener)和其中实际回调方法(buttonClicked)。

所有这些都意味着没有基础的主题类。如果采用此方法,则任何触发/订阅机制都必须针对每个具体的主题重新实现。这是反面的面向对象编程。

在一个主题基类中处理观察者的管理、遍历和实际通知是明智的做法;这样,对底层机制(例如引入线程安全性)进行任何更改都不需要更改每个具体主题。这将使我们的具体主题具有良好封装和简单接口,并将触发减少到一行:

// In a concreate subject
Fire( evSize, GetSize() );

事件暂停和恢复

许多应用程序和框架需要暂停特定主题的事件触发。有时,我们希望已暂停的事件排队等待,直到我们恢复触发它们;有时,我们只想忽略这些事件。就主题接口而言:

class Subject
{
public:
    void SuspendEvents( bool aQueueSuspended );
    void ResumeEvents();
};

事件暂停是有用的一种实现方式,例如在组合对象销毁时。当一个组合对象被销毁时,它首先销毁所有子对象,这些子对象又会首先销毁它们所有的子对象,以此类推。如果这些组合对象驻留在模型层中,它们将需要通知视图层中对应的对象(使用evBeforeDestroy事件):

enter image description here

在这种特殊情况下,每个对象都没有必要触发一个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);
    }
});

结论

总之,在实现观察者模式时,应该考虑以下关键点:

  • 主题应该触发包括状态和无状态事件在内的事件——后者只能通过推送模型实现。
  • 主题通常会触发多种类型的事件
  • 观察者可以订阅多个主题的同一事件。这意味着主题-观察者协议应该允许指定发送者
  • 任意事件处理程序极大地简化了客户端代码,并促进了首选的变量推送模型;但它们的实现并不直接,会导致更复杂的主题代码。
  • 事件处理程序可能需要是虚拟的
  • 观察者可以存储在全局哈希表、每个主题或每个事件中。选择形成内存、性能和代码简洁性之间的权衡。
  • 理想情况下,观察者仅对相同主题的相同事件进行一次订阅
  • 保持观察者与主题状态的一致性是需要记住的事情。
  • 事件的触发可能需要被挂起,并提供一个排队选项,然后恢复
  • 多重继承在给类添加主题能力时值得考虑。

(第二部分结束)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接