状态机编程实例及适用范围
简介
状态机这一概念并不源于软件开发,但其思想确深入软件发展之中(已然成为了一种设计模式),如果之前有好好学过组成原理或者编译原理,一定不会对这个概念陌生。写这篇文章的目的是为了尽可能通俗地总结一下状态机编程思想的特点及适用范围,但介于笔者履历有限,疏漏之处在所难免,请酌情参考。*注:本文只讨论的状态机特指“有限状态自动机”。
状态机编程思想特点
与传统的上下文编程不同,状态机将程序的行为划分为若干个状态,对于每一个状态规定其行为和可能的状态转换关系。状态机的状态即可以由其内部定义的状态转换关系改变,也可由外部操作改变,从而影响状态机的行为。这样说也不是很通俗,我们看一个例子。
状态机编程实例
问题情景:求自然数1到100的和。
这个问题很简单,一般而言,我们会这样解:
int sum = 0;
for(int i = 1;i <= 100;i++)
{
sum+=i;
}
System.out.println(sum);
接下来看看状态机编程是如何解决同一个问题的:
int status = 0;
int sum = 0;
int i = 1;
while(status != -1)
{
switch(status)
{
case 0:{
if(i <= 100)
{
sum+=i;
i++;
}
else
{
status = 1;
}
}break;
case 1:{
System.out.println(sum);
status = -1;
}break;
}
}
对于上面的status变量,就是状态机的状态变量了,从上面程序中得知,status的有效变量有三个:-1,0,1;我们对这三个状态用语言来概括一下:
状态 | 状态概括 | 行为描述 | 状态转换关系 |
0 | 加法状态 | 将sum的值加i,将i自加 | 若i大于100,则跳入状态1 |
1 | 输出状态 | 将sum的值输出 | 跳转到状态-1 |
-1 | 终止状态 | 程序结束 | 无 |
可以看到,由于程序开始时初始状态为0,i为1,所以执行了0状态100次,之后将sum值输出并退出。同样的一个问题,显然状态机编程更为复杂,那它存在的必要性在哪呢?什么情况下应该使用它呢?
状态机编程思想的适用范围
在讲述状态机编程的适用范围之前,先来回忆一下,以前编写的很大一部分程序是这样的:编写-》运行-》输入参数-》获得结果-》结束。包括上面的问题实例和很多算法或是数据结构的小程序,程序运行一次输出结果就结束了,换句话说,程序本身没有保持自身持续运行的必要性,在这种情况下使用状态机编程思想无疑是自找麻烦。但是实际上,有很多程序是需要保持自身持续运行的,尤其是为了解决某些阻塞过程而使用多线程机制编写的模块;在这种情形下,应用自动机编程来控制这种模块的行为再好不过。我举个例子说明一下。
问题情景:[音乐播放器]编写音乐播放器,控制其播放、停止、暂停、继续等。
为了能将主要重点放在状态机上,我们对音乐播放器这一模块进行一个抽象,一个音乐播放过程大致能概括如下:
1)打开一个音频输出设备,获得设备句柄
2)打开一个音频文件,获得文件句柄
3)从音频文件句柄读入byte到缓存byte组
4)将缓存byte组写入音频设备句柄(这里是阻塞过程,同时伴随着音乐的播放)
5)若读入过程遇到结尾,则结束播放
我们将上述音频设备抽象为“SoundDevice”,并进行播放器模块的API设计,例如这样的API:
public void play(InputStream mediaFileInputStream);//播放音频文件
public void stop();//关闭音频文件
public void pasue();//暂停播放
public void resume();//继续播放
API的设计不是很困难,现在考虑这样的问题,这个模块必须足够强大,对一些非法操作进行足够的容错处理,例如在关闭的情况下再次关闭,或者在关闭后继续播放等等的调用都要考虑,这时,使用原来“上下文”流水式的编程思想就会很麻烦了,我们看一下它的状态设计:
状态(enum) | 状态概括 | 行为叙述 | 状态转换关系 |
CLOSE | 停止状态 | 无行为 | 可跳转至播放状态 |
PLAYING | 播放状态 | 从给定缓冲区读取音频byte组,写入设备,若遇到结尾则跳转到CLOSE | 可跳转到停止或暂停状态 |
WAIT | 暂停状态 | 无行为 | 可跳转回播放状态 |
TERM | 终止状态 | 终止模块任务 结束线程 |
public class MediaPlayer extends Thread
{
SoundDevice soundDevice;//抽象的播放设备
byte[] buffer;//音频数据缓存
InputStream is;//音频文件流(代指数据源)
MediaStatus status = MediaStatus.CLOSE;
public enum MediaStatus{PLAYING,WAIT,CLOSE,TERM}
//播放音频
public void play(InputStream mediaFileInputStream)
{
if(status == MediaStatus.CLOSE)
{
synchronized(is)
{
is = mediaFileInputStream;
status = MediaStatus.PLAYING;
}
}
}
//关闭音频文件
public void stop()
{
if(status == MediaStatus.PLAYING)
{
synchronized(is)
{
is.close();
status = MediaStatus.CLOSE;
}
}
}
//暂停播放
public void pasue()
{
if(status == MediaStatus.PLAYING)
{
status = MediaStatus.WAIT;
}
}
//继续播放
public void resume()
{
if(status == MediaStatus.WAIT)
{
status = MediaStatus.PLAYING;
}
}
@Override
public void run()
{
while(status != MediaStatus.TERM)
{
synchronized(is)
{
switch(status)
{
case PLAYING:{
if(0 != is.read(buffer))
{
soundDevice.write(buffer);
}
else
{
is.close();
status = MediaStatus.CLOSE;
}
}break;
case WAIT:{}
case CLOSE:{}
default:{Thread.sleep(30)}break;//不要让线程空跑
}
}
}
}
}
虽然从代码量上讲没有明显的缩减,但是由于编写每个接口时都只关注某一个状态和转换关系,因此每个问题都不复杂。在给定的接口中,除play需要更改内部数据源引用外,其他接口都是在给定状态下跳转这种简单语句。RUN方法中虽然WAIT和CLOSE都没有任何行为,但是却分出两个状态,这是为了区别“播放一个新文件”与“在暂停基础上继续播放"的两种情况,这样使播放本身(也就是我们对音频设备的写入)不需要重复了。
PS:
其实以前写过的很多程序都是这样的线程自动机,我们把任务划分为状态量与过程量,我们通过外部接口恰当的更改状态量,线程内部循环则能根据给定的状态量完成对应过程,笔者认为这个模式还是很重要的,关于它今后有机会还会在深入总结探讨的。