枚举类型很适合用来实现状态机。状态机可以处于有限数量的特定状态。它们通常根据输入,从一个状态移动到下一个状态,但同时也会存在瞬态。当任务执行完毕后,状态机会立即跳出所有状态。
每个状态都有某些可接受的输入,不同的输入会使状态机从当前状态切换到新的状态。由于枚举限制了可能出现的状态集大小(即状态数量),因此很适合表达(枚举)不同的状态和输入。
每种状态一般也会有某种对应的输出。
自动售货机是个很好的状态机应用的例子。首先,在一个枚举中定义一系列输入:
Input.java
import java.util.Random;
public enum Input {
NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100),
TOOTHPASTE(200), CHIPS(75), SODA(100), SOAP(50),
ABORT_TRANSACTION {
@Override
public int amount() { // Disallow
throw new RuntimeException("ABORT.amount()");
}
},
STOP { // 这必须是最后一个实例
@Override
public int amount() { // 不允许
throw new RuntimeException("SHUT_DOWN.amount()");
}
};
int value; // 单位为美分(cents)
Input(int value) {
this.value = value;
}
Input() {
}
int amount() {
return value;
}
; // In cents
static Random rand = new Random(47);
public static Input randomSelection() {
//不包括 STOP:
return values()[rand.nextInt(values().length - 1)];
}
}
注意其中两个 Input 有着对应的金额,所以在接口中定义了 amount() 方法。然而,对另外两个 Input 调用 amount() 是不合适的,如果调用就会抛出异常。尽管这是个有点奇怪的机制(在接口中定义一个方法,然后如果在某些具体实现中调用它的话就会抛出异常),但这是枚举的限制所导致的。
VendingMachine(自动售货机)接收到输入后,首先通过 Category(类别) 枚举来对这些输入进行分类,这样就可以在各个类别间切换了。下例演示了枚举是如何使代码变得更清晰、更易于管理的。
VendingMachine.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
enum Category {
MONEY(Input.NICKEL, Input.DIME, Input.QUARTER, Input.DOLLAR),
ITEM_SELECTION(Input.TOOTHPASTE, Input.CHIPS, Input.SODA, Input.SOAP),
QUIT_TRANSACTION(Input.ABORT_TRANSACTION),
SHUT_DOWN(Input.STOP);
private Input[] values;
Category(Input... types) {
values = types;
}
private static EnumMap<Input, Category> categories = new EnumMap<>(Input.class);
static {
for (Category c : Category.class.getEnumConstants()) {
for (Input type : c.values) {
categories.put(type, c);
}
}
}
public static Category categorize(Input input) {
return categories.get(input);
}
}
public class VendingMachine {
private static State state = State.RESTING;
private static int amount = 0;
private static Input selection = null;
enum StateDuration {TRANSIENT} // 标识 enum
enum State {
RESTING {
@Override
void next(Input input) {
switch (Category.categorize(input)) {
case MONEY:
amount += input.amount();
state = ADDING_MONEY;
break;
case SHUT_DOWN:
state = TERMINAL;
default:
}
}
},
ADDING_MONEY {
@Override
void next(Input input) {
switch (Category.categorize(input)) {
case MONEY:
amount += input.amount();
break;
case ITEM_SELECTION:
selection = input;
if (amount < selection.amount()) {
System.out.println(
"Insufficient money for " + selection);
} else {
state = DISPENSING;
}
break;
case QUIT_TRANSACTION:
state = GIVING_CHANGE;
break;
case SHUT_DOWN:
state = TERMINAL;
default:
}
}
},
DISPENSING(StateDuration.TRANSIENT) {
@Override
void next() {
System.out.println("here is your " + selection);
amount -= selection.amount();
state = GIVING_CHANGE;
}
},
GIVING_CHANGE(StateDuration.TRANSIENT) {
@Override
void next() {
if (amount > 0) {
System.out.println("Your change: " + amount);
amount = 0;
}
state = RESTING;
}
},
TERMINAL {
@Override
void output() {
System.out.println("Halted");
}
};
private boolean isTransient = false;
State() {
}
State(StateDuration trans) {
isTransient = true;
}
void next(Input input) {
throw new RuntimeException("Only call " + "next(Input input) for non-transient states");
}
void next() {
throw new RuntimeException("Only call next() for " + "StateDuration.TRANSIENT states");
}
void output() {
System.out.println(amount);
}
}
static void run(Supplier<Input> gen) {
while (state != State.TERMINAL) {
state.next(gen.get());
while (state.isTransient) {
state.next();
}
state.output();
}
}
public static void main(String[] args) {
Supplier<Input> gen = new RandomInputSupplier();
if (args.length == 1) {
gen = new FileInputSupplier(args[0]);
}
run(gen);
}
}
// 基本的稳健性检查:
class RandomInputSupplier implements Supplier<Input> {
@Override
public Input get() {
return Input.randomSelection();
}
}
// 从以“;”分割的字符串的文件创建输入
class FileInputSupplier implements Supplier<Input> {
private Iterator<String> input;
FileInputSupplier(String fileName) {
try {
input = Files.lines(Paths.get(fileName))
.skip(1) // Skip the comment line
.flatMap(s -> Arrays.stream(s.split(";")))
.map(String::trim)
.collect(Collectors.toList())
.iterator();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public Input get() {
if (!input.hasNext()) {
return null;
}
return Enum.valueOf(Input.class, input.next().trim());
}
}
下面是用于生成输出的文本文件:
VendingMachine.txt
QUARTER;QUARTER;QUARTER;CHIPS;
DOLLAR;DOLLAR;TOOTHPASTE;
QUARTER;DIME;ABORT_TRANSACTION;
QUARTER;DIME;SODA;
QUARTER;DIME;NICKEL;SODA;
ABORT_TRANSACTION;
STOP;
以下是运行参数配置:
运行结果如下:
因为通过 switch 语句在枚举实例中进行选择操作是最常见的方式(注意,为了使 switch 便于操作枚举,语言层面需要付出额外的代价),所以在组织多个枚举类型时,最常问的问题之一就是“我需要什么东西之上(即以什么粒度)进行 switch”。这里最简单的办法是,回头梳理一遍 VendingMachine,就会发现在每种 State 下,你需要针对输入操作的基本类别进行 switch 操作:投入钱币、选择商品、退出交易、关闭机器。并且在这些类别内,你还可以投入不同类别的货币,选择不同类别的商品。Category 枚举会对不同的 Input 类型进行分类,因此 categorize() 方法可以在 switch 中生成恰当的 Category。这种方法用一个 EnumMap 实现了高效且安全的查询。
如果你研究一下 VendingMachine 类,便会发现每个状态的区别,以及对输入的响应区别。同时还要注意那两个瞬态:在 run() 方法中,售货机等待一个 Input,并且会一直在状态间移动,直到它不再处于某个瞬态中。
VendingMachine 可以通过两种不同的 Supplier 对象,以两种方法来测试。RandomInputSupplier 只需要持续生成除 SHUT_DOWN 以外的任何输入。通过一段较长时间的运行后,就相当于做了一次健康检查,以确定售货机不会偏离到某些无效状态。FileInputSupplier 接收文本形式的输入描述文件,并将它们转换为 enum 实例,然后创建 Input 对象。下面是用于生成以上输出的文本文件:
FileInputSupplier 的构造器将这个文件转换为行级的 Stream 流,并忽略注释行。然后它通过 String.split() 方法将每一行都根据分号拆开。这样就能生成一个字符串数组,可以通过先将该数组转化为 Stream,然后执行 flatMap(),来将其注入(前面 FileInputSupplier 中生成的)Stream 中。结果将删除所有的空格,并转换为 List,并从中得到 Iterator。
上述设计有个限制:VendingMachine 中会被 State 枚举实例访问到的字段都必须是静态的,这意味着只能存在一个 VendingMachine 实例。这可能不会是个大问题——你可以想想一个实际的(嵌入式Java)实现,每台机器可能就只有一个应用程序。