在C++应用程序中应用观察者模式






4.69/5 (26投票s)
2001年1月9日

161184

2087
本文通过一个简单的例子解释了如何使用观察者模式来避免对象之间的依赖关系。
引言
众人拾柴火焰高,同样,许多对象在复杂系统的开发中协同工作。面向对象设计的一个经验法则是将系统分解为一组协作的对象。每个分解出的对象都必须是完整的,并且应该表现出高度的内聚性和与其他对象低度的耦合性。高度的内聚性意味着对象是自包含的,低度的耦合性意味着它不依赖于其他对象。然而,在对象的世界里,每个对象都需要与同伴对象进行交互,以提供完整的解决方案。本文通过一个简单的例子解释了如何使用观察者模式来避免对象之间的依赖关系。它还讨论了观察者模式何时以及为何需要,它的优点和缺点。
设计模式是为了提供一个通用的词汇来沟通设计原则。在 《设计模式:可复用面向对象软件元素》 (Erich Gamma 等,Addison-Wesley, 1995) 一书中,观察者模式被归类为对象行为模式。在本文中,我将使用“四人组 (GoF)”的术语来解释观察者模式。首先,让我们理解对象交互的问题。
问题
一个对象有一个内部状态,并提供一组服务。通常,服务实现为方法,数据成员决定对象的状态。让我们以两个对象之间的交互为例。对象“A
”需要直接或间接持有对象“B
”的引用才能使用其服务。在 C++ 等语言中,对象“A
”应该包含对象“B
”的类定义的头文件。当对象“A
”和“B
”的类属于同一个抽象时,这就可以了,这样它们就可以一起复用。然而,当类不相关时,它们完全独立,可以在两个不同应用程序的两个不同上下文中复用。在对象“A
”中包含“B
”的头文件,使得在没有“B
”的情况下无法复用“A
”。
有时,对象“A
”的状态可能依赖于对象“B
”的状态。每当“B
”改变时,“A
”应该重新计算其状态,以保持与“B
”同步。当应用程序引入一个新对象“C
”且该对象依赖于“B
”的状态时,情况会变得更加复杂。简而言之,对象之间的依赖和耦合增加,可复用性降低。我们如何才能让两个或多个独立的对象在不知道彼此存在的情况下协同工作呢?答案是观察者模式。
观察者模式
GoF 将观察者模式归类为对象行为模式。观察者模式的目的是“定义对象之间的一对多依赖关系,以便当一个对象的状态改变时,所有它的依赖者都会自动收到通知并更新”。一个易变状态的对象称为主题 (Subject),一个依赖于主题状态的对象称为观察者 (Observer)。在上面的例子中,“B
”可以是主题,对象 A
和 C
可以是观察者。一个主题可以有任意数量的观察者。当主题改变其状态时,它会通知所有观察者,观察者会查询主题以保持状态与主题的一致性。
UML 类图和参与者
观察者模式的参与者在 UML 类图中显示。主题维护一个观察者列表,并提供附加和分离它们的接口。观察者定义一个 抽象的 Update
接口,当主题状态改变时,主题会调用它。具体主题维护具体观察者感兴趣的状态,并在状态改变时通知它们。具体观察者实现了观察者提供的更新接口。它还维护与具体主题的引用,并更新其状态以与具体主题保持同步。
示例
本文使用 Stair-Master(踏步机)作为例子来解释观察者模式的概念。Stair-Master 是一种健身器材,主要用于心血管锻炼。它模拟爬楼梯的效果,并提供各种健身程序,包括燃脂、有氧、随机和爬坡。使用 Stair-Master 的人必须指定锻炼时长、年龄、运动强度和锻炼程序。它包含一组监视器,用于控制和直观显示锻炼的进度。时间监视器跟踪已完成的锻炼时间,并在指定时间后负责停止锻炼。心率监视器读取当前心率,并根据当前锻炼程序的低值和高值进行指示。程序控制器可以在锻炼期间随时更改锻炼程序和运动强度。卡路里监视器显示燃烧的总卡路里和每分钟平均燃烧的卡路里。
一个人的目标心率是根据年龄和选择的锻炼程序计算出来的。通常,燃脂程序的低心率比有氧程序低。时间监视器每秒与心率监视器交互以读取心率,并与卡路里监视器交互以更新燃烧的总卡路里。当锻炼程序更改时,程序控制器会通知心率监视器重新计算目标心率。
时间监视器、心率监视器、程序控制器和卡路里监视器可以被建模为独立的对象,以便在任何有氧器械的设计中使用。一些 Stair-Masters 可能带有距离监视器,以显示爬过的总层数和覆盖的总距离(以米为单位)。此外,高级机器可能使用心率变化监视器代替心率监视器,以指示心率随时间的变化。
以下依赖图显示了此示例中使用的各种对象之间的依赖关系。
直接对象交互
首先,让我们看看独立对象之间直接交互存在哪些问题。如果对象直接相互交互,它们必须持有指向依赖对象的指针或引用。这使得以下操作变得困难:
- 添加新的依赖对象
- 用新对象替换现有依赖对象
- 删除现有的依赖对象
i) 添加新的依赖对象
在下一个版本中,如果将距离监视器添加到 Stair-Master,依赖关系将进一步增加,时间监视器也需要更改以维护到距离监视器的引用。下面显示了改进后的依赖图。
ii) 用新对象替换现有依赖对象
当心率变化监视器替换心率监视器时,时间监视器和程序控制器需要更改(参见上图的对象依赖关系)。
iii) 删除现有的依赖对象
心血管器械不一定需要所有上述监视器。一些椭圆交叉训练机没有心率监视器。由于时间监视器和程序控制器维护到心率监视器的直接引用,因此即使不需要,它们也无法在没有心率监视器的情况下工作。这使得不可能删除心率监视器。
使用观察者模式
观察者模式可以用来解决上述所有问题。时间监视器、心率监视器、卡路里监视器和程序控制器可以被视为观察者,并且可以引入一个新的对象Cardio Subject(有氧主题)充当主题。这些监视器彼此的存在一无所知,它们始终通过 Cardio Subject 进行通信。当其中一个监视器更改其状态(例如时间监视器)时,它会更新 Cardio Subject,Cardio Subject 进而通知所有监视器。作为响应,监视器会查询 Cardio Subject 以更新它们的状态。由于监视器彼此独立,因此可以轻松添加新监视器,或者删除或替换现有监视器。本文通过一个完整的 Visual C++ Stair-Master 应用程序演示了观察者模式。
变更传播机制
当主题的状态发生变化时,观察者也必须改变它们的状态。本节包含两种变更传播机制:通知 (Notification) 和轮询 (Polling),可用于维护主题和观察者之间的状态一致性。
通知
当主题的状态发生变化时,通过调用 Notify
方法来通知其观察者。作为响应,观察者从主题查询所需数据以维护状态一致性。观察者甚至可以注册感兴趣的特定事件,并且在发生此类事件时,主题可以仅将通知发送给感兴趣的观察者。
保持主题的自我一致性并避免冗余通知是应在通知过程中考虑的两个重要问题。
保持主题的自我一致性
自我一致性是指在通知观察者后保留主题的状态。重要的是要确保在调用 Notify
方法之前和之后,主题的状态都是自我一致的,以使观察者的状态与主题保持同步。在继承操作中,尤其是在基类中调用 Notify
方法时,很难维护自我一致性。一个简单的例子显示在列表 1 中。
在此示例中,CDervSubject
重写了 Calculate
操作并调用了基类方法,该方法将通知发送给所有观察者。派生类实现可以更改数据成员 m_nResult
的值,从而带来自我不一致的问题。为了避免这种情况,GoF 建议使用模板方法。原始操作可以定义为 protected virtual
方法,派生类可以重写它们,并将 Notify
方法作为模板方法中的最后一个操作来调用,以维护自我一致性。列表 2 说明了这一点。
避免冗余通知
有时,在每个状态更改方法中调用 Notify
方法并不是必需的。在所有状态更改之后调用一次 Notify
可以避免冗余的通知调用。在列表 3 中,观察者收到了三次通知调用,而不是一次。这可以通过使用具有一组更改跟踪方法的 Change Tracker 类来实现(参见列表 4)。为了确保在所有状态更改结束时只进行一次通知调用,CMySubject
应该继承自 CChangeTracker
并实现这些方法。CMySubject
不会在所有状态更改方法中调用 Notify
方法,而是在进行更改之前调用 StartChange
,在更改之后调用 FinishChange
。在 StartChange
期间会增加一个更改引用计数,在 FinishChange
期间会减少,并且只有当引用计数达到 0
时才会调用 Notify
方法。这种方法的优点是,可以按任何顺序调用任意数量的状态更改方法,并且观察者将在所有状态更改结束时只被通知一次。 列表 4 说明了这一点。
优点
- 在大多数情况下,实现简单直接。
- 主题状态的更改会立即传播到所有观察者。
缺点
- 主题只能与观察者抽象一起复用。这降低了主题在完全不同的上下文中的可复用性。
- 主题应该维护一个观察者列表。在某些情况下,观察者可能只请求特定事件的通知,主题必须将这些信息与观察者列表一起维护,这会增加开销。
- 由于观察者彼此独立,在观察者的
Update
方法中更改主题的状态,而没有明确定义的依赖标准,因此不应提倡这样做。这样做可能会导致:- 递归通知调用
- 不一致的观察者状态,即每个观察者在同一时间处于不同的状态
轮询
在此方法中,观察者会轮询主题以获取状态更改。当主题更改时,观察者不会收到更改通知,而是使用轮询算法从主题读取状态更改。轮询在流行的类库中被广泛使用。MFC 使用轮询技术来更新用户界面对象。有关更多详细信息,请参阅MFC 中的轮询。
优点
- 主题完全不知道观察者的存在。它不需要维护观察者列表,也不需要通知它们状态更改。
- 观察者负责维护状态一致性。这增加了主题的可复用性。
缺点
- 何时轮询主题。应该在正确的时间轮询主题,如果轮询太早,观察者将获得旧状态;如果轮询太晚,它们会丢失中间状态。
- 轮询未更改的主题会引入性能开销。
- 需要有人要求观察者轮询主题。谁来通知观察者这样做可能是一个很大的问题?
对象交互模型
主题和观察者可以通过推模型 (Push Model) 或拉模型 (Pull Model) 进行交互。
推送模型
在此模型中,主题将状态更改推送到观察者。当所有观察者都对常见的状态更改感兴趣时,可以使用推模型。观察者别无选择,只能接收推送的数据。推模型不能用于大量数据,因为观察者可能对所有推送的数据都不感兴趣。此外,主题必须了解它正在推送数据的观察者。因此,观察者必须遵守主题所需的标准接口,并且主题的可复用性仅限于这些观察者。
拉模型
在此模型中,主题将状态更改通知给观察者,观察者仅从主题拉取所需数据。拉模型更灵活,观察者可以只拉取所需数据。但是,实现拉模型需要不止一个方法调用。第一次方法调用是从主题到所有观察者的更改通知,而一个感兴趣的观察者至少需要调用另一个方法来拉取数据。在一个非常动态的环境中,主题的状态可能在这两次调用之间发生变化,即在观察者拉取数据之前。因此,主题和观察者可能不同步。最重要的是,观察者调用特定方法来拉取所需数据,它们需要自己弄清楚在主题提供很少帮助的情况下发生了什么变化。本文提供的示例应用程序使用了拉模型。
优点
- 观察者模式避免了直接的对象交互,并且可以在一个或多个对象对给定对象的状态更改感兴趣时使用。
- 它可以用于开发松耦合的应用程序,同时保持对象状态依赖关系。
- 当状态依赖对象的数量未知,甚至可能随时间变化时,都可以使用它。
- 主题和观察者对象可以单独复用,并且它们可以独立变化。
- 观察者模式可用于分层开发应用程序。例如,一个用户界面应用程序可以包含电子表格、图形和图表对象(高层级的观察者),这些对象依赖于数据库对象(低层级的主题)的数据。当数据库对象中的数据发生更改时,所有观察者都会通过
Observer
基类中定义的抽象
操作自动收到通知。由于抽象
操作是在基类中定义的,因此主题无需了解具体的Observer
类,因此它们是松耦合的。 - 由于所有观察者都通过相同的抽象操作获得通知,因此可以根据需要轻松添加或删除观察者。
负债
- 在大多数情况下,观察者只会收到状态更改的通知。由观察者自己来弄清楚具体发生了什么变化。但是,可以通过在通知中包含额外的信息(或更改的方面)来避免这种情况。这将为观察者提供一些关于更改的线索。
Observer
对象完全独立,它们对同伴Observer
的存在一无所知。因此,一个Observer
对象可以在所有Observer
都收到通知之前,在Update
方法中更改Subject
的状态。这可能导致状态不一致,并且状态更改通知将丢失。- 每当一个观察者改变主题的状态时,所有依赖的观察者都会收到通知。如果依赖标准定义不明确,很容易发生递归更新。通常,在 Stair-Masters 中,锻炼程序和心率是相互依赖的。也就是说,当程序更改时(例如从燃脂到有氧),心率会发生变化,当心率范围低于或高于阈值限制时,程序会自动更改。为了简化起见,示例中并未强制执行此依赖标准。假设示例中依赖标准定义不明确,程序控制器将在程序更改时更新有氧主题,心率监视器将重新计算心率。如果心率低于或高于阈值水平,心率监视器将更新有氧主题以更改程序,这反过来会更新程序控制器,然后这个循环会重复。因此,心率监视器和程序控制器盲目地更新有氧主题,这会通知这两个监视器更新自己,从而导致递归更新调用。
- 观察者模式引入了一个额外的间接层来维护状态一致性。这增加了应用程序设计的灵活性,但确实会产生性能影响。此外,过多的间接性会降低代码的可理解性。
- 当主题被删除时,观察者无法直接得知删除信息。因此,观察者将持有指向已删除主题的悬空引用。
已知用法
本节介绍了观察者模式的已知用法。本节中介绍的一些已知用法摘自 GoF 的《设计模式》一书。
MFC 中的轮询
观察者应该在正确的时间轮询主题以获取其状态。在 MFC 应用程序中,菜单项和工具栏按钮等用户界面对象是观察者,文档、视图、窗口或应用程序对象是主题。MFC 使用轮询技术来更新这些用户界面对象(观察者)。在菜单下拉之前,或者在工具栏按钮的情况下,在空闲循环期间,MFC 会路由一个 update
命令。update
命令的处理程序(使用 ON_UPDATE_COMMAND_UI
定义)会在正确对象中调用,以启用/禁用菜单项或工具栏按钮。在这种情况下使用轮询具有以下优点:
- MFC 可以将用户界面对象的更新推迟到发生特定事件为止。因此,可以在应用程序空闲时更新工具栏状态,并在菜单下拉时更新菜单项。
- 菜单项或工具栏按钮的状态纯粹取决于主题的当前状态(文档、视图、窗口或应用程序中包含的状态),而不是其旧状态。因此,用户界面对象不需要为其状态的每一次更改更新它们的状态。
MFC 的文档/视图架构
MFC 的文档/视图架构使用观察者模式。文档包含数据对象并充当主题。视图是通过用户更新文档的窗口对象,并充当观察者。一个文档可以有多个视图。当其中一个视图更改文档中的数据时,它会通过调用 UpdateAllViews
方法来更新文档,并可选地提供有关修改的提示。为了将更改通知其他视图,文档对象会调用它所附加的每个视图的 OnUpdate
方法(除了调用 UpdateAllViews
的视图)。派生的视图类可以重写 OnUpdate
方法,并通过从文档查询数据来更新自身。
Smalltalk 中的模型/视图/控制器
观察者模式的第一个也是也许最著名的例子出现在 Smalltalk 的模型/视图/控制器 (MVC) 中,这是 Smalltalk 环境的用户界面框架。MVC 的模型类扮演主题的角色,而视图是观察者的基类。
观察者和发布/订阅
由于对象交互,观察者模式也被称为发布/订阅 (Publish/Subscribe)。观察者订阅主题以接收更改通知,而主题则将状态更改发布给订阅的观察者。发布/订阅(也称为发布者/订阅者)可以看作是观察者模式的一种变体。尽管这两种模式的意图相同,但发布/订阅试图解决观察者模式的一些实现限制。
摘要
在复杂的面向对象应用程序背后,许多对象协同工作。处理这些对象之间的状态依赖关系是一项重要任务。本文展示了如何使用观察者模式来维护状态一致性。它从一个常见的编程问题开始,然后用一个简单的例子解释了观察者模式的含义、何时以及为何需要。还介绍了该模式的优点、缺点和已知用法。观察者模式有助于维护对象之间的状态一致性,并提高系统的正确性和质量。然而,它并不是解决所有对象交互问题的万能药。
致谢
特别感谢我的朋友 Sree Meenakshi,感谢她为改进本文的清晰度和呈现方式提出的宝贵建议。
列表 1 - 主题中的自我一致性违规
INT CDervSubject::Calculate( int nVal )
{
// Call the base class method, which implements a complicated
// calculation algorithm and sets the data member m_nResult
CBaseSubject::Calculate( nVal ); // Calling this method sends a
// notification to the Observers
// specific implementation for the derived class
if( m_nResult > 1000 )
{
m_nResult %= 1000;
}
return 0;
}
列表 2 - 使用模板方法维护主题的自我一致性
INT CBaseSubject::Calculate( int nVal )
{
// Call DoCalculate that can be redefined in derived classes.
// DoCalculate is a protected virtual method
DoCalculate( nVal );
// Notify the Observers about the change
Notify();
return 0;
}
INT CBaseSubject::DoCalculate( int nVal )
{
// Do calculation and set m_nResult
return 0;
}
INT CDervSubject::DoCalculate( int nVal )
{
// Call base class method
CBaseSubject::DoCalculate( nVal );
// Specific implementation for the derived class
if( m_nResult > 1000 )
{
m_nResult %= 1000;
}
return 0;
}
列表 3 - 冗余通知
void CMySubject::SetFont( Font & rFont )
{
...
...
// Update member
m_Font = rFont;
...
...
// Notify the Observers
Notify();
}
void CMySubject::SetTextColor( Color & rTextColor )
{
...
...
// Update member
m_TextColor = rTextColor;
...
...
// Notify the Observers
Notify();
}
void CMySubject::SetBkColor( Color & rBkColor )
{
...
...
// Update member
m_BkColor = rBkColor;
...
...
// Notify the Observers
Notify();
}
void CMySubject::SetAttributes( Font & rFont, Color & rTextColor,
Color & rBkColor )
{
// Call SetFont method
SetFont( rFont );
// Call SetTextColor method
SetTextColor( rTextColor );
// Call SetBkColor method
SetBkColor( rTextColor );
}
// Observer code
void CMyObserver::SetAttributes()
{
...
...
m_MySubject.SetAttributes( Font, TextColor, BkColor );
...
...
}
列表 4 - 使用更改跟踪器进行通知
class CChangeTracker
{
protected :
virtual void StartChange() = 0 ;
virtual void FinishChange() = 0;
};
// CMySubject inherits from CSubject and CChangeTracker
class CMySubject : public CSubject, protected CChangeTracker
{
protected :
virtual void StartChange();
virtual void FinishChange();
private :
INT m_nChangeCount;
};
void CMySubject::StartChange()
{
m_nChangeCount++;
}
void CMySubject::FinishChange()
{
m_nChangeCount--;
if( m_nChangeCount == 0 )
{
Notify();
}
}
// State change operations
void CMySubject::SetFont( Font & rFont )
{
// call StartChange
StartChange();
...
...
// Update member
m_Font = rFont;
...
...
// call EndChange
EndChange();
}
void CMySubject::SetTextColor( Color & rTextColor )
{
// call StartChange
StartChange();
...
...
// Update member
m_TextColor = rTextColor;
...
...
// call EndChange
EndChange();
}
void CMySubject::SetBkColor( Color & rBkColor )
{
// call StartChange
StartChange();
...
...
// Update member
m_BkColor = rBkColor;
...
...
// call EndChange
EndChange();
}
void CMySubject::SetAttributes( Font & rFont, Color & rTextColor,
Color & rBkColor )
{
// call StartChange
StartChange();
// call SetFont method
SetFont( rFont );
// call SetTextColor method
SetTextColor( rTextColor );
// call SetBkColor method
SetBkColor( rTextColor );
// call EndChange
EndChange();
}
许可证
本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。
作者可能使用的许可证列表可以在此处找到。