枚举和注解是Java1.5版本中新增的特性,本章讨论使用它们时的最佳实践。
本章内容导图:
1.用enum代替int常量
枚举类型是指由一组固定的常量组成合法值的类型,如一年中的季节、太阳系中的行星、一副牌中的花色等。在编程语言没有引入枚举之前,表示枚举类型的常用模式是声明一组具名的int常量,每个类型成员一个常量:
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
这种方法称作int枚举模式,它存在着诸多不足:
1.类型安全性问题
可能会传递错误的值
2.没有自己的命名空间
一般只能通过前缀的形式区分
3.采用int枚举模式的程序十分脆弱
int枚举是编译时常量,被编译到使用它们的客户端中,如果枚举常量值发生了变化,客户端必须重新编译才行。
4.无法提供便利的方法打印信息
int枚举的打印信息只是数字
String枚举模式是int枚举模式的变体,虽然它可以提供可打印的字符串,但存在性能及书写时的安全性问题。
Java1.5开始,提供了枚举类型,它不仅可以避免int枚举模式和String枚举模式的缺点,还可以提供许多额外的好处:
public enum Apple {
FUJI,
PIPPIN,
GRANNY_SMITH
}
枚举的好处有:
1.提供编译时的类型安全
如果声明一个参数的类型为枚举类型Apple,就可以保证,被传递到该参数上的任何非null的对象引用一定属于三个有效的Apple之一。试图传递类型错误的值时,会导致编译错误。
2.每个枚举类型都有自己的命名空间
枚举类是独立的类型,有自己的命名空间,可以增加或者重新排列枚举类型中的常量。
3.可提供便利的打印信息
通过toString(),可以将枚举转换成可打印的字符串。
4.允许添加任意的方法和域,并实现任意的接口
枚举是一种类型,可以拥有自己的方法和域,并实现接口。
枚举的缺点:
1.装载和初始化枚举时会有空间和时间的成本
在枚举中添加域和方法的动机:
1.想将数据与它的常量关联起来
2.添加方法增强枚举类型功能
如果一个枚举具有普适性,就应该成为一个顶层类;如果它只是被用在一个特定的顶层类中,就应该成为该顶层类的一个成员类。
在枚举类中添加方法时,这些方法是枚举常量共有的,但有时每个常量都会关联本质上完全不同的行为,可以使用特定于常量的方法实现来完成。它的实现过程如下:
1.在枚举类型中声明一个抽象的方法
2.在特定常量的类主体中,用具体的方法实现抽象方法
enum Operation {
PLUS {
@Override
double apply(double x, double y) {
return x + y;
}
},
MINUS {
@Override
double apply(double x, double y) {
return x - y;
}
},
TIMES {
@Override
double apply(double x, double y) {
return x * y;
}
},
DIVIDE {
@Override
double apply(double x, double y) {
return x / y;
}
};
abstract double apply(double x, double y);
}
使用枚举的时机:
每当需要一组固定常量的时候。
1.包括“天然的枚举类型”,如行星、一周的天数、一年中的季节等;
2.包括在编译时就知道其所有可能值的其他集合,如操作代码、命令行标记、菜单的选项等。
枚举类型中的常量集并不一定要始终保持不变,专门设计枚举特性也是考虑到枚举类型二进制兼容演变的需求。
与int常量相比,枚举类型的优势很多。枚举更加易读,也更加安全,功能更加强大。
许多枚举都不需要显式的构造器或者成员,但如有需求,你可以提供与常量相关联的属性和方法。还可以使用特定于常量的方法将多种行为与单个方法关联。
如果多个枚举常量同时共享相同的行为,可考虑策略枚举。
2.用实例域代替序数
所有的枚举都有一个ordinal方法,它返回每个枚举常量在类型中的数组位序。
依赖ordinal()
返回的枚举常量序数会使得代码极难维护。因为枚举常量可能会进行重新排序,也可能会添加新的枚举常量。
永远不要根据枚举序数去得到与它关联的值,而是要将它保存在一个实例域中。
//不当的使用方式
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,
SEXTET, SEPTET, OCTET, NONET, DECTET;
//依赖ordinal()返回与枚举常量关联的值
public int numberOfMusicians() {
return ordinal() + 1;
}
}
//推荐的使用方式
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
NONET(9), DECTET(10), TRIPLE_QUARTET(12);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
Enum规范中对ordinal()的描述为:大多数程序员都不需要这个方法,它被设计成用于像EnumSet、EnumMap这种基于枚举的通用数据结构的。除非你在编写这种数据结构,否则最好完全避免使用ordinal方法。
3.用EnumSet代替位域
如果一个枚举类型的元素主要用在集合中,可能会使用int枚举模式:
public class Text {
public static final int STYLE_BOLD = 1 << 0; //1
public static final int STYLE_ITALIC = 1 << 1; //2
public static final int STYLE_UNDERLINE = 1 << 2; //4
public static final int STYLE_STRIKETHROUGH = 1 << 3; //8
public void applyStyles(int styles) {
...
}
}
这种表示法让你用or位运算符将几个常量合并到一个集合中,这个集合称作位域:
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
位域表示法也允许利用位操作,执行像交集、并集这样的集合操作。但位域具有int枚举常量所有的缺点,甚至更多。位域以数字形式打印时,翻译位域比翻译int枚举常量要困难的多,遍历位域表示的所有元素也相当不容易。
Set是一种集合,只能向其中添加不重复的对象,enum也要求其成员都是唯一的,看起来也具有集合的行为,但不能从enum中删除/添加元素。Java1.5引入了EnumSet替代传统的基于int枚举类型的位域集合,它表示从单个枚举类型中提取多个枚举值的集合。
EnumSet是与enum类型一起使用的专用Set类型,EnumSet中的所有元素都必须来自同一个enum。
使用EnumSet代替位域后的代码更加简短、更加清楚、更加安全:
public class Text {
public enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
}
public void applyStyles(Set<Style> styles) {
...
}
}
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
EnumSet设计时充分考虑了性能因素,它内部将一个long值作为比特向量,且其of()被重载了很多次,不但为可变数量的参数进行了重载,而且为接收2-5个显式的参数情况都进行了重载,这也从侧面表现了EnumSet对性能的关注。
只使用可变参数已经可以解决整个问题了,但是对比显式参数,会有一点性能损失。因为可变参数机制是通过先创建一个数组,然后将参数值传到数组中,最后将数组传递给方法的。
4.用EnumMap代替序数索引
Enum的ordinal()返回枚举常量的序数。
有时候,会见到利用枚举常量的序数作为数组下标来索引数组的代码,对应映射关系如下图所示:
这种方法的确可行,但是隐藏着很多问题:
1.数组不能与泛型兼容,使其使用受限
2.数组不知道它的索引代表着什么,需要手工标注
3.错误的索引值会引发数组越界异常
Java1.5版本引入了EnumMap类型,它是一种特殊的Map,它要求其中的key必须来自一个enum,使用enum实例作为键在EnumMap中进行各种操作。EnumMap在运行速度方面可以与数组相媲美,它在内部实现中使用了数组,但是它对程序员隐藏了实现细节,它具有Map的丰富功能、类型安全,以及数组的快速访问。映射关系如下图:
最好不要用序数来索引数组,而要使用EnumMap。
应用程序的程序员在一般情况下都不使用Enum.ordinal()。
5.用接口模拟可伸缩的枚举
枚举类型不可扩展,但有时又需要枚举类型具备可伸缩的特性,一种好的方法就是利用接口:
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements Operation {
PLUS("+") {
@Override
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
@Override
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
@Override
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
@Override
public double apply(double x, double y) {
return x / y;
}
};
private final String symbol;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
public enum ExtendedOperation implements Operation {
EXP("^") {
@Override
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
@Override
public double apply(double x, double y) {
return x % y;
}
};
private final String symbol;
ExtendedOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
只要API是被写成采用接口类型(Operation)而非实现(BasicOperation),那么在可以使用基础操作的任何地方,都可以使用新的操作。
//方式一
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(ExtendedOperation.class, x, y);
}
private static <T extends Enum<T> & Operation> void test(Class<T> opSet, double x, double y) {
for (Operation op : opSet.getEnumConstants()) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
//方式二
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(Arrays.asList(ExtendedOperation.values()), x, y);
}
private static void test(Collection<? extends Operation> opSet, double x, double y) {
for (Operation op : opSet) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样允许客户端编写自己的枚举来实现接口。
如果API是根据接口编写的,那么在可以使用基础枚举类型的任何地方,也都可以使用这些枚举。
6.注解优先于命名模式
Java1.5版本之前,一般使用命名模式表明有些程序元素需要通过某种工具或者框架进行特殊处理。例如,JUnit4之前原本要求测试方法要以test
作为开头。这种方法可行,但有几个很严重的缺点。
命名模式的缺点有:
1.文字拼写错误会导致失败,且没有任何提示。
2.无法确保它们只用于相应的程序元素上。
如将某个类称作testSafetyMechanisms,希望JUnit可以自动地测试它的所有方法,而不管类中的方法名字是什么。虽然JUnit不会出错,但也不会执行测试。
3.没有提供将参数值与程序元素关联起来的好方法。
注解很好地解决命名模式的所有问题,因此,Java1.5版本后,JUnit4使用注解代替命名模式,重新实现了整个测试框架,使之更加强大、易用。
7.坚持使用Override注解
Override注解只能用在方法声明中,它表示被注解的方法声明覆盖(重写)了超类型中的一个方法声明。坚持使用这个注解,可以防止一大类的非法错误。这类错误基本上都是由于不小心而造成的,使用Override注解后,编译器会做自动检查,可以避免这类无意识的错误。
8.用标记接口定义类型
标记接口是没有包含方法声明的接口,它只是标明一个类实现了具有某种属性的接口。例如,通过实现Serializable接口,表明类的实例可以被序列化。
标记注解:一种被用来“标注”程序元素的注解。
标记接口的优点:
1.标记接口定义的类型是由被标记类的实例实现的,允许在编译时发现标记接口的使用错误。
2.标记接口可以被更加精确地进行锁定,它可以用来标记某类特殊接口的实现。
标记注解的优点:
1.它可以通过默认的方式添加一个或者多个注解类型元素,给已被使用的注解类型添加更多信息。
2.它是更大的注解机制的一部分,在那些支持注解作为编程元素的框架中具有一致性。
标记接口和标记注解的使用选择:
如果标记是用到程序元素而不是类或接口,要使用注解;
如果标记只应用给类和接口,就该优先使用接口。
标记接口和标记注解各有用处。
如果要定义一个任何新方法都不会与之关联的类型,标记接口就是最好的选择。
如果要标记程序元素而非类和接口,考虑到未来可能要给标记添加更多信息,或者标记要适合于已经广泛使用了注解类型的框架,标记注解就是正确的选择。