C++ 中 UML 状态图的轻量级实现






4.68/5 (16投票s)
2005年8月23日
8分钟阅读

99075

2040
这个轻量级的类允许你轻松地在 C++ 中实现 UML 状态图。
引言
这个状态图“引擎”(实现为一个 C++ 模板类)实现了 UML 状态图最常用的方面。你只需要定义一个状态数组并实现事件检查和处理方法。然后,当事件发生时调用引擎。引擎会按正确的顺序调用你的事件检查和处理方法来确定发生了什么事件,并跟踪当前状态。
这是一个轻量级的实现,可以在 Visual Studio 2005 下编译,并且经过一些小的修改后可以在 VC++ 6.0 下编译。这个实现比其他 C++ 实现需要更少的与状态图相关的管理代码。(例如,可以参考 Miro Samek 的实现。)
背景
状态图由 David Harel 开发,用于为具有嵌套和其他特性的平面状态机增加功能。(请参阅 David Harel 的文章“On Visual Formalisms”,Communications of the ACM,第 31 卷,第 5 期,页码 514-530,1988 年。)状态图后来被添加到统一建模语言(UML)中并标准化。它们是建模具有许多不同状态以及它们之间复杂转换的类、子系统和接口的优秀工具。
我个人需要它们是因为在实现两个实时嵌入式系统之间的软件接口时,这些系统需要控制需要物理同步的独立机器。我也用它们来建模和实现具有多种模式的用户界面。
以下是状态图的表示法和行为的简要概述。有关 UML 状态图表示法的完整介绍,请参阅 UML 2.0 规范,可在此 处找到。
在图形上,UML 将状态显示为圆角矩形,将转换显示为矩形之间的箭头。(参见图 1。)转换会标记引起转换的事件,后面可选地跟着一个斜杠,以及转换时将执行的动作。一个称为“守卫”的条件可以用方括号表示。如果事件发生,将评估守卫条件,并且只有当条件为真时才会发生转换。
状态可以嵌套(参见图 2。),这允许高级事件仅用一个箭头即可调用离开多个状态中的任何一个的转换。使用所谓的“复合”状态可以使状态图比平面状态机所需的状态图更简洁。即使状态可以嵌套,系统也必须始终转换为某个简单(即非复合)状态。
箭头开头处的实心圆表示默认起始状态。状态图必须至少有一个指定的默认起始状态,表示系统的初始状态。如果任何转换——包括默认转换——在一个复合状态上结束,那么在该复合状态内必须有一个默认状态的指定,如此类推,最终导向一个简单状态。下面描述了一个例外情况。
状态可以具有内部转换,这些转换不会将系统带到另一个状态,但会关联动作。这些动作显示在处理它们的状态内部。复合状态也可以具有内部转换。两个特殊的内部转换是“entry”(进入)和“exit”(退出)。这些分别在进入和退出相应状态时执行。这允许将常见的动作(例如初始化和销毁)只表达一次,而不是在导致/离开状态的每个事件上都作为动作执行。还可以指定自定义内部事件。
如果一个转换将系统带过了几个状态边界,那么各种动作将按以下顺序执行:
- 所有必须退出的状态的退出动作。
- 转换箭头上的动作。
- 所有进入的状态的进入动作。
状态图允许一个转换返回到复合状态内的先前状态,而无需指定先前所处的状态。这由一个以圆圈“H”(代表“历史”)结尾的转换箭头表示。(参见图 3。)例如,假设一个事件可以从复合状态内的两个简单状态中的任何一个处理。如果你显示一个从复合状态回到该复合状态内部的圆圈“H”的转换,这意味着“处理事件并返回到最近离开的状态”。这比显示两个(或更多)具有相同事件/动作标签的转换要简单得多。
UML 状态图中有两种历史返回:浅历史和深历史。浅历史转换(由“H”表示)返回到“H”所在层级最近退出的状态。如果这不导致进入一个简单状态,则为错误。深历史(由“H*”表示)意味着系统将返回到最近退出的复合状态内的最近简单状态。因此,如果图 3 中的系统在事件 x 发生时处于状态 A,系统将返回到状态 A。
Using the Code
此实现支持状态嵌套、进入、退出和自定义内部事件、默认状态以及深历史转换。如果历史返回在给定层级找不到最近退出的状态,它将尝试使用默认状态指定将系统带到一个简单状态。只有在无法做到这一点时,才会被视为错误。
此实现目前不支持正交状态、分路转换路径、分叉、合并、同步状态或消息广播。其中许多不支持的功能在很大程度上取决于你将此代码集成到的系统,并且可以在你的事件处理程序中进行模拟。
设计好状态图后,执行以下操作。首先,定义一个状态枚举。以下内容来自附带的示例应用程序,该应用程序演示了上述所有功能。该应用程序对状态图引擎执行路径覆盖测试。
enum eStates
{
eStateA,
eStartState = eStateA,
eStateB,
eStateC,
eStateD,
eStateE,
eNumberOfStates
};
这里我命名了起始状态,我还通过最后一个枚举值指定了状态的数量,所以它将始终是正确的。你特定的状态名称可能比这些更有意义。接下来,我分配一个以下内容的数组:
typedef struct
{
int32 m_i32StateName;
std::string m_sStateName;
int32 m_i32ParentStateName;
int32 m_i32DefaultChildToEnter;
int32 (T::*m_pfi32EventChecker)(void);
void (T::*m_pfDefaultStateEntry)(void);
void (T::*m_pfEnteringState)(void);
void (T::*m_pfLeavingState)(void);
} xStateType;
例如,
TStatechart<CStateClass>::xStateType xaStates[eNumberOfStates] = {
/* name */ {eStateA,
/* string name */ "A",
/* parent */ -1,
/* default_substate */ eStateB,
/* event-checking func */ &CStateClass::evStateA,
/* default state entry func */ &CStateClass::defEntryStateA,
/* entering state func */ &CStateClass::entryStateA,
/* exiting state func */ &CStateClass::exitStateA},
/* name */ {eStateB,
/* string name */ "B",
/* parent */ eStateA,
/* default_substate */ eStateC,
/* event-checking func */ &CStateClass::evStateB,
/* default state entry func */ &CStateClass::defEntryStateB,
/* entering state func */ &CStateClass::entryStateB,
/* exiting state func */ &CStateClass::exitStateB},
/* name */ {eStateC,
/* string name */ "C",
/* parent */ eStateB,
/* default_substate */ -1,
/* event-checking func */ &CStateClass::evStateC,
/* default state entry func */ &CStateClass::defEntryStateC,
/* entering state func */ &CStateClass::entryStateC,
/* exiting state func */ &CStateClass::exitStateC},
/* name */ {eStateD,
/* string name */ "D",
/* parent */ eStateA,
/* default_substate */ -1,
/* event-checking func */ &CStateClass::evStateD,
/* default state entry func */ &CStateClass::defEntryStateD,
/* entering state func */ &CStateClass::entryStateD,
/* exiting state func */ &CStateClass::exitStateD},
/* name */ {eStateE,
/* string name */ "E",
/* parent */ eStateB,
/* default_substate */ -1,
/* event-checking func */ &CStateClass::evStateE,
/* default state entry func */ &CStateClass::defEntryStateE,
/* entering state func */ &CStateClass::entryStateE,
/* exiting state func */ &CStateClass::exitStateE}
};
结构体必须按照上面枚举中状态的相同顺序进行初始化。只有最顶层的状态,这里是 `eStateA`,其父状态标识符为 `-1`。没有默认子状态的状态(这将包括所有简单状态)必须为默认子状态指定 `-1`。每个状态都必须有一个事件检查/处理方法,但不必填写最后三个字段。如果未定义,则为这些字段指定 `0`。
字符串名称字段用于打印跟踪信息。在文件 `TStatechart.hpp` 中,将 `TRACING_STATUS` 设置为 `1` 以启用此功能。如果使用 MFC 编译,信息将通过 `TRACE()` 宏写入。否则,文本将发送到 `cout`。引擎在内部通过 void 指针引用,因此使用该状态图的类必须有一个 void 指针供其使用。
class CStateClass
{
.
.
.
void *engine;
};
引擎必须在你的类中创建和销毁。这些宏和下面的宏隐藏了一些必要的细节。引擎名称出现在所有宏中,以便你可以在同一个客户端类中声明多个引擎。
CStateClass::CStateClass(void)
{
CREATE_ENGINE(CStateClass, engine,
xaStates, eNumberOfStates, eStartState);
}
CStateClass::~CStateClass(void)
{
DESTROY_ENGINE(CStateClass, engine);
}
在事件发生的地方,插入以下调用:
PROCESS_EVENT(CStateClass, engine)
由于事件可能比检查一个简单的标量值复杂得多,引擎不会将事件传递给你的事件检查/处理方法。相反,在调用 `PROCESS_EVENT` 之前,你必须将事件存储在你类的成员变量中,以便事件检查/处理方法可以对其进行测试。因此,它不会出现在上述调用中。
对于状态图中的每个状态,必须定义一个事件检查/处理方法,该方法检查该状态可以处理的所有事件。只需为在给定状态下可能发生的每个事件进行测试,如下所示:
uint32 CStateClass::evStateA(void)
{
// Checking for event in state A.
if ('g' == m_cCharRead) // include any guard conditions here
{
BEGIN_EVENT_HANDLER(CStateClass, engine, eStateA);
// Put the transition action code here.
END_EVENT_HANDLER(CStateClass, engine);
return (iHandlingDone);
}
return (iNoMatch);
}
这种安排允许在与事件本身相同的代码点处测试任何守卫条件,从而简化了代码。对于不处理预期事件的处理器,返回 `iNoMatch`。
`BEGIN_EVENT_HANDLER` 宏让引擎知道你已经找到一个事件匹配。它记录这一事实,并执行为了到达目标状态而必须退出的所有状态的退出处理程序。它还记录系统在执行处理程序代码后将转到的状态是 `eStateA`。如果给定的状态是复合状态,那么你最终会进入该复合状态内的某个简单状态。
然后控制返回到此方法,在此执行任何转换动作。`END_EVENT_HANDLER` 宏执行为了进入正确简单状态而必须进入的所有状态的进入处理程序。如果你想转换到一个带有历史的复合状态,请在 `BEGIN_EVENT_HANDLER` 宏中将历史标志“或”到状态名称上。
BEGIN_EVENT_HANDLER(CStateClass, engine, eStateA | iWithHistory);
对于内部转换,使用“无状态更改”标志:
BEGIN_EVENT_HANDLER(CStateClass, engine, iNoStateChange);
就是这样!
内部
在内部,状态定义数组在初始化时被解析,并从中创建更复杂的数据结构。在处理事件时,会引用这些数据结构以及状态定义数组。运行时不要考虑更改状态定义数组!
由于你的代码必须调用状态图引擎,因此引擎会反过来调用你的事件处理程序,以确定谁将处理该事件以及下一步去哪里。因此,在执行事件处理程序时,你与最初调用引擎的线程是同一个线程。
代码中包含许多 `assert()` 语句,用于检查定义状态数组时可能出现的各种简单错误。例如,未能设置初始默认状态,或者状态没有有效的父状态。这些错误在初始化期间被捕获,而不是在运行时。
历史
- 2007 年 8 月 23 日 -- 文章和下载已更新
- 代码已更新,可与 Visual Studio 2005 编译/运行。
- 已添加跟踪功能以辅助调试。
- 文章已更新以匹配。
- 2005 年 8 月 23 日 -- 首次发布