今天我们来聊聊枚举。你可能会想:枚举那么简单,有什么好讨论的?
没错,枚举确实是一个极为常见的知识点,常见到很多人会忽略它,而只关注它最简单的用法。当然,这和枚举诞生的初衷有关。在 JDK1.5 以前,那是个没有枚举的时代,人们通过 public static final 的常量来定义一些全局使用的标识。甚至到现在,枚举已经诞生很长时间了,但仍有一些人在使用这种方案。而且,在 C 语言中也有类似的"宏"的概念,如果你只是用来做全局标识,那么枚举的意义就没有那么大了。
但是,你真的了解枚举吗?今天我们就分别从实现原理、数据结构和设计模式 3 个方向来重新认识一下枚举。
枚举原理
我们知道一个枚举的定义非常简单。如果只考虑其作为标识的场景,那么从实现成本来看,枚举和 public static final 的传统方式差不多,甚至前者还更简单些。
arduino
public enum Test{
A,B
}
每一个枚举成员都可以看作是枚举类的实例,上面的 Test.A 的类型也是 Test。
ini
Test t=Test.A;
上面这个赋值语句看上去很简单,仔细思考里面包含了几层意思。首先左边是 Test 类型的实例 t,那么右边必然是一个类的实例。但是 Test.A 看上去像是一个类,这里很容易混淆。请注意,Test.A 是一个对象,不要被这里的大写忽悠了,它不是类。
我们把这个枚举翻译成下面的样子你是不是更熟悉?
ini
Test A=new Test();
Test B=new Test();
Java 枚举类型的实现是在编译阶段进行的。这个阶段和泛型的实现一样,也就是说对 JVM 来说执行的字节码集合并没有增加任何新的指令,只是在 Java 代码的层面加了一些语法。说白了,就是对已有的 JVM 指令集加了一层皮。
举个生活化的例子,"进食"是人类最基本的行为,酒店会说"用餐",但"用餐"是人体的新功能吗?并不是。在计算机界这叫"语法糖",看着很神奇,写着也很爽,但底层还是老的功能。
我们可以对 Test.class 文件进行反编译,注意反编译命令是 javap,其中-p 的意思是反编译的时候要包含私有方法。
arduino
javap -p Test.class
输出结果为:
arduino
public final class Test extends java.lang.Enum<Test> {
public static final Test A;
public static final Test B;
public static Test[] values();
public static Test valueOf(java.lang.String);
private Test();
static {};
}
我们可以看到,确实 A 和 B 都是 Test 类的实例。Test 继承了 java.lang.Enum 类,这里还有一个 Test 的无参构造方法,这里的 A 和 B 分别使用这个构造方法来实例化。
而实例化的过程发生在哪呢?
我们注意到上面代码段的最后有一个空的 static 语句块,我们可以基于 javap 的其他参数进一步分析 static 里面的字节码内容。static 里面其实包含了很多字节码指令,正是这些指令在做 A、B 的初始化工作。而 static 代码块是在类加载的时候执行的。也就是说当 Test 被加载的时候,A、B 就被初始化了。
static 内部除了完成初始化 A、B,还创建了一个名叫 ENUM$VALUES 的数组,然后把 A、B 按照定义的顺序放入这个数组中。最后我们可以通过 values 方法来访问这个数组。
这里我们可以增加一个构造方法,这样大家就比较熟悉了。A、B 后面可以加一个括号调用这个构造方法。
arduino
public enum Test{
A("a"),B("b");
Test(String name){
this.name=name;
}
private String name;
}
上面 A("a") 相当于如下代码的简写:
ini
Test A=new Test("a");
我们再把例子做的复杂一些,我们为 Test 增加一个抽象方法 print。这里就不能像上面那样直接初始化了。这里必须使用类似匿名内部类的写法。
typescript
public enum Test{
A("a"){
@Override
public void print(){
System.out.print("a");
}
},
B("b"){
@Override
public void print(){
System.out.print("b");
}
};
Test(String name){
this.name=name;
}
private String name;
public abstract void print();
}
上面这个写法有点不伦不类,你需要适应一下。如果你去编译目录下查看文件,这次你会发现编译后多了 Test和2.class 2 个文件,也就是匿名内部类。可见语法糖已经帮我们做好了一切。
数据结构
说完了枚举的实现原理,我们再看看它支持的一些数据结构。常见的有 EnumSet 和 EnumMap。1.EnumSet
EnumSet 显然是为枚举打造的抽象集合类。它使用了位图来存储数据,因此非常紧凑。
EnumSet 有 2 个实现类,RegularEnumSet 和 JumboEnumSet。当我们创建 EnumSet 的时候,如果枚举成员数量小于等于 64 将会使用 RegularEnumSet,大于 64 则会创建 JumboEnumSet。
为什么创建 EnumSet 的时候,会有不同的实现类呢?
这是因为 RegularEnumSet 采用 long 来存储枚举变量,而 long 是 64 位的,因此只能存储 64 个变量。而 JumboEnumSet 使用 long[]来存储枚举变量,因此没有这个限制。当然,你见过一个枚举类超过 64 个成员变量吗?如果真有这种情况,我认为放到 ZooKeeper 中会更合适一些。
这是一个很有意思的哲学问题,当你的枚举变量只有 2 个,这个枚举一般是很稳定的,但是你的枚举变量超过了 64 个,我相信随着业务发展枚举数量还会新增,这种情况下就不适合用枚举来解决了。也就是说枚举变量越多,业务越不稳定。
ini
EnumSet支持常见的集合操作,如取子集、增加、删除、包含等。可以使用EnumSet的of方法来初始化。
EnumSet<Test> testSet = EnumSet.of(Test.A, Test.B);
2.EnumMap
EnumMap 很明显是一个 Map 结构,它的 key 就是枚举,value 可以由你定义。比如下面这个声明的意思就是对所有的 Object 按照 Test 枚举类型来分类。其结果是输出类型 A 及属于 A 类型的对象,输出类型 B 及属于 B 类型的对象。
swift
EnumMap<Test,List<Object>> testMap;
设计模式
最后我们聊聊枚举和设计模式的关系。
单例模式有很多实现方法,其中最好的就是用枚举来实现。例如,下面的代码段:
csharp
public enum Singleton{
INSTANCE;
private Singleton(){
//做一些初始化工作
}
}
上面的 INSTANCE 就是我们的单例对象,我们可以把一些初始化工作放到 Singleton 的构造方法里面。还记得前面我们说的,枚举的成员就是枚举类的实例化对象,这个过程发生在 static 语句块中。上面这段话所传达的语义类似下面这样:
ini
Singleton INSTANCE=null;
static{
INSTANCE = new Singleton();
}
此外枚举在序列化和反序列化的时候并不会调用构造方法,这在一定程度上保障了单例。序列化仅仅是将枚举对象的 name 属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf 方法来根据名字查找枚举对象。这里的处理和普通的类有很大的差异。
另外枚举还可以实现模板模式、策略模式等。但是注意不要把太多代码放到枚举类,这样不便于维护。关于用枚举实现其他的设计模式读者可以自己试试。
总结
上面就是枚举的核心内容。我们知道枚举本质上是一个语法糖,底层是通过继承 java.lang.Enum 来实现的。枚举的每个成员都是枚举类的实例,并且还有自己的 Set 和 Map 数据结构,通过上面的分析我们可以看出枚举底层实现很普通,但是很多语法特性超越了普通的 Java 类,在设计单例模式以及一些模板模式中将简化编码工作,使得工程整体变的更优雅、更紧凑。