本章内容:
1. 用enum代替int常量
2. 用实例域代替序数
3. 用EnumSet代替位域
4. 用EnumMap代替充数索引
5. 用接口模拟可伸缩的枚举
6. 注解优先于命名模式
7. 坚持使用Override注解
8. 用标记接口定义类型
1. 用enum代替int常量
枚举类型是指由一组固定的常量组成合法值的类型,该特征是在Java 1.5 中开始被支持的,之前的Java代码都是通过“公有静态常量域字段”的方法来简单模拟枚举的,如:
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
... ...
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
这样的写法是比较脆弱的。首先是没有提供相应的类型安全性,如两个逻辑上不相关的常量值之间可以进行比较或运算(APPLE_FUJI - ORANGE_TEMPLE)。再有就是常量int是编译时常量,被直接编译到使用他们的客户端中。如果与该常量关联的int发生了变化,客户端就必须重新编译。如果没有重新编译,程序还是可以执行,但是他们的行为将不确定。
下面我们来看一下Java 1.5 中提供的枚举的声明方式:
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
Java的枚举本质上是int值。Java枚举类型背后的基本想法非常简单,就是通过公有的静态final域为每个枚举常量导出实例的类,因为没有构造器,枚举类型是真正的final。因为客户端既不能创建枚举类型的实例,也不能对它进行扩展。换句话说,枚举类型是实例受控的,它们是的泛型化,本质上是单元素的枚举。
和“公有静态常量域字段”不同的是,如果函数的参数是枚举类型,如Apple,那么他的实际值只能来自于该枚举所声明的枚举值,即FUJI, PIPPIN, GRANNY_SMITH,且一定是三个中的一个。如果试图将Apple和Orange中的枚举值进行比较,将会导致编译错误。
你可以增加或者重新排列枚举类型中的常量,而无需重新编译它的客户端代码,因为导出学时的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式中。
和C/C++中提供的枚举不同的是,Java中允许在枚举中添加任意的方法和域,并实现任意的接口。它们提供了所有Object方法的高级实现,实现了Comparable和Serializable接口,并针对权举类型的可任意改变设计了序列化方式。下面先给出一个带有域方法和域字段的枚举声明:
public enum Planet {
MERCURY(3.302e+23,2.439e6),
VENUS(4.869e+24,6.052e6),
EARTH(5.975e+24,6.378e6),
MARS(6.419e+23,3.393e6),
JUPITER(1.899e+27,7.149e7),
SATURN(5.685e+26,6.027e7),
URANUS(8.683e+25,2.556e7),
NEPTUNE(1.024e+26,2.477e7);
private final double mass; //千克
private final double radius; //米
private final double surfaceGravity;
private static final double G = 6.67300E-11;
Planet(double mass,double radius) {
this.mass = mass;
this.radius = radius;
surfaceGravity = G * mass / (radius * radius);
}
public double mass() {
return mass;
}
public double radius() {
return radius;
}
public double surfaceGravity() {
return surfaceGravity;
}
public double surfaceWeight(double mass) {
return mass * surfaceGravity;
}
}
在上面的枚举示例代码中,已经将数据和枚举常量关联起来了,因此需要声明实例域字段,同时编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域字段都应该为final的,并最好将它们做成私有的并提供公有的访问方法。下面看一下该枚举的应用示例:
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight/Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
System.out.printf("Weight on %s is %f%n",p,p.surfaceWeight(mass));
}
}
程序输出:
Weight on MERCURY is 66.133672
Weight on VENUS is 158.383926
Weight on EARTH is 175.000000
Weight on MARS is 66.430699
Weight on JUPITER is 442.693902
Weight on SATURN is 186.464970
Weight on URANUS is 158.349709
Weight on NEPTUNE is 198.846116
枚举的静态方法values()将按照声明顺序返回他的值数组。枚举的toString方法返回每个枚举值的声明名称。就像其它类一样,除非迫不得已要将枚举方法导出至它的客户端,否则都应该将它声明为私有的,如有必要,则声明为包级私有有。
在实际的编程中,我们常常需要针对不同的枚举常量提供不同的数据行为,见如下代码:
public enum Operation {
PLUS,MINUS,TIMES,pIDE;
double apply(double x,double y) {
switch (this) {
case PLUS: return x + y;
case MINUS: return x - y;
case TIMES: return x * y;
case pIDE: return x / y;
}
throw new AssertionError("Unknown op: " + this);
}
}
上面的代码已经表达出这种根据不同的枚举值,执行不同的操作。如果没有throw语句它就不能进行编译。但是上面的代码在设计方面确实存在一定的缺陷,或者说漏洞,如果我们新增枚举值的时候,所有和apply类似的域函数,都需要进行相应的修改,如有遗漏将会导致异常的抛出。
幸运的是,Java的枚举提供了一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply方法,并在特定于常量的类主体中,用具体的方法覆盖每个常量的抽象apply方法,这种方法称作特定于常量的方法实现 。如下:
public enum Operation {
PLUS { double apply(double x,double y) { return x + y;} },
MINUS { double apply(double x,double y) { return x - y;} },
TIMES { double apply(double x,double y) { return x * y;} },
pIDE { double apply(double x,double y) { return x / y;} };
abstract double apply(double x, double y);
}
这样在添加新枚举常量时就不会轻易忘记提供相应的apply方法了,因为该方法就紧跟在每个常量之后,即使你真的忘了,编译器也会提醒你,因为枚举中的抽象方法必须被它所有常量中的具体方法所覆盖。我们在进一步看一下如何将枚举常量和特定的数据进行关联,如下覆盖toString方法示例:
public enum Operation {
PLUS("+") { double apply(double x,double y) { return x + y;} },
MINUS("-") { double apply(double x,double y) { return x - y;} },
TIMES("*") { double apply(double x,double y) { return x * y;} },
pIDE("/") { double apply(double x,double y) { return x / y;} };
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
abstract double apply(double x, double y);
}
下面给出以上代码的应用示例:
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n",x,op,y,op.apply(x,y));
}
输出如下:
2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000
枚举类型有一个自动产生的valueOf(String)方法,他将常量的名字转变为枚举常量本身,如果在枚举中覆盖了toString方法(如上例),就需要考虑编写一个fromString方法,将定制的字符串表示法变回相应的枚举,见如下代码:
public enum Operation {
PLUS("+") { double apply(double x,double y) { return x + y;} },
MINUS("-") { double apply(double x,double y) { return x - y;} },
TIMES("*") { double apply(double x,double y) { return x * y;} },
pIDE("/") { double apply(double x,double y) { return x / y;} };
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
abstract double apply(double x, double y);
// 新增代码
private static final Map<String,Operation> stringToEnum = new HashMap<String,Operation>();
static {
for (Operation op : values())
stringToEnum.put(op.toString(),op);
}
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
}
需要注意的是,我们无法在枚举常量构造的时候将自身放入到Map中。与此同时,枚举构造器不可以访问枚举的静态域,除了编译时的常量域之外。
特定于常量的方法有一个美中不足的地方,它们使得在枚举常量中共享代码变得更加困难了。为枚举常量添加一个公有的行为方法时,如果添加了一个新的元素到枚举,但是忘记给switch语句添加相应的case,这是非常危险的。幸运的时,有一种很好的方法可以实现这一点,将这种行为方法移到一个私有的嵌套枚举中,称之为策略枚举。如下:
enum PayrollDay {
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY),
WEDNESDAY(PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY),
FRIDAY(PayType.WEEKDAY),
SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;
PayrollDay(PayType payType) { this.payType = payType; }
double pay(double hoursWorked, double payRate) {
return payType.pay(hoursWorked, payRate);
}
// The strategy enum type
private enum PayType {
WEEKDAY {
double overtimePay(double hours, double payRate) {
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;
abstract double overtimePay(double hrs, double payRate);
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}
如上,如果多个枚举常量同时共享相同的行为,则考虑策略枚举。
2. 用实例域代替序数
Java中的枚举提供了ordinal()方法,它返回每个枚举常量在类型中的数字位置。你可以试着从序数中得到关联的int值:
public enum Color {
WHITE,RED,GREEN,BLUE,ORANGE,BLACK;
public int indexOfColor() {
return ordinal() + 1;
}
}
上面的枚举中提供了一个获取颜色索引的方法(indexOfColor),该方法将返回颜色值在枚举类型中的声明位置,如果我们的外部程序依赖了该顺序值,那么这将会是非常危险和脆弱的,因为一旦这些枚举值的位置出现变化,或者在已有枚举值的中间加入新的枚举值时,都将导致该索引值的变化。该条目推荐使用实例域的方式来代替枚举提供的序数值,见如下修改后的代码:
public enum Color {
WHITE(1),RED(2),GREEN(3),ORANGE(4),BLACK(5);
private final int indexOfColor;
Color(int index) {
this.indexOfColor = index;
}
public int indexOfColor() {
return indexOfColor;
}
}
Enum规范中谈到ordinal时这么写道:“大多数程序员都不需要这个方法。它是设计成用于像EnumSet和EnumMap这种基于枚举的通用数据结构的。”除非你在编写的是这种数据结构,否则最好避免使用ordinal()方法。
3. 用EnumSet代替位域
如果一个枚举类型的元素主要用在集合中,一般就使用int枚举模式,将2的不同倍数赋予每个常量:
下面的代码给出了位域的实现方式:
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public static final int STYLE_STRIKETHROUGH = 1 << 3;
public void applyStyles(int styles) { ... }
}
这种表示法让你用OR位运算将几个常量合并到一个集合中,称为位域。使用方式如下:
text.applyStyles(Text.STYLE_BOLD | Text.STYLE_ITALIC);、
位域表示法也允许利用位操作,有效地执行像union和intersection这样的集合操作。但位域有着int枚举常量的所有缺点,甚至更多。当位域以数字形式打印里,翻译位域比翻译简单的int枚举常量要困难得多。
Java.util包提供了EnumSet类来有效地表示从单个枚举类型中提取的多个值的多个集合,该类实现了Set接口,同时也提供了丰富的功能,类型安全性,以及可以从任何其他Set实现中得到的互用性。但是在内部具体实现上,每个EnumSet内容都表示为位矢量。如果底层的枚举类型有64个或者更少的元素,整个EnumSet就用单个long来表示,因此他的性能也是可以比肩位域的。与此同时,他提供了大量的操作方法,其实现也是基于位操作的,但是相比于手工位操作,由于EnumSet替我们承担了这部分的开发,从而也避免了一些容易出现的低级错误,代码的美观程度也会有所提升,见如下修改的代码:
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set<Style> styles) { ... }
}
applyStyles方法采用的是Set<Style>而非EnumSet<Style>。虽然看起来好像所有的客户端都可以将EnumSet传到这个方法,但是最好还是接受接口类型而非接受实现类型。
EnumSet提供了丰富的静态工厂来轻松创建集合,如下:
public class TestEnmu {
public static void main(String[] args) {
// 创建一个指定类型的空的集合
EnumSet set = EnumSet.noneOf(MyEnum.class);
set.add(MyEnum.RED);
set.add(MyEnum.GREEN);
set.add(MyEnum.BULE);
showSet(set);
// 创建一个指定类型的所有数据的集合
EnumSet set2 = EnumSet.allOf(MyEnum.class);
showSet(set2);
// 创建指定类型指定初始数据的集合
EnumSet<MyEnum> set3 = EnumSet.of(MyEnum.GREEN, MyEnum.RED, MyEnum.WHITE);
showSet(set3);
// 创建指定类型,指定范围的集合
// 包含边界数据
EnumSet<MyEnum> set4 = EnumSet.range(MyEnum.RED, MyEnum.YELLOW);
showSet(set4);
// 集合的用法和普通的没有区别
}
private static void showSet(Set set) {
System.out.println(Arrays.toString(set.toArray()));
}
}
enum MyEnum {
BLACK, WHITE, RED, BULE, GREEN, YELLOW
}
总而言之,正是因为枚举类型要用在集合中,所有没有理由用位域来表示它。
4. 用EnumMap代替序数索引
前面的条目已经给出了尽量不要直接使用枚举的ordinal()方法的原因,这里就不在做过多的赘述了。在这个条目中,只是再一次给出了ordinal()的典型用法,与此同时也再一次提供了一个更为合理的解决方案用于替换ordinal()方法,从而进一步证明我们在编码过程中应该尽可能减少对枚举中ordinal()函数的依赖。见如下代码表示一种烹饪用的香草:
public class Herb {
public enum Type { ANNUAL, PERENNIAL, BIENNIAL }
private final String name;
private final Type type;
Herb(String name, Type t1ype) {
this.name = name;
this.type = type;
}
@Override public String toString() {
return name;
}
}
现在假设有一个香草的数组,表示一座花园中的植物,它们被分成3类,分别是一年生(ANNUAL)、多年生(PERENNIAL)和两年生(BIENNIAL),正好对应着Herb.Type中的枚举值。现在我们需要做的是遍历花园中的每一个植物,并将这些植物分为3类,最后再将分类后的植物分类打印出来。
public static void main(String[] args) {
Herb[] garden = getAllHerbsFromGarden();
Set<Herb>[] herbsByType = (Set<Herb>[])new Set[Herb.Type.values().length];
for (int i = 0; i < herbsByType.length; ++i) {
herbsByType[i] = new HashSet<Herb>();
}
for (Herb h : garden) {
herbsByType[h.type.ordinal()].add(h);
}
for (int i = 0; i < herbsByType.length; ++i) {
System.out.printf("%s: %s%n",Herb.Type.values()[i],herbByType[i]);
}
}
这种方法确实可行,但是隐藏着许多问题。因为数组不能与泛型兼容,程序需要进行未受检的转换,并且不能正确无误地进行编译。因为数组不知道它的索引代表着什么,你必须手工标注这些索引的输出。但是这种方法最严重的问题在于,当你访问一个按照枚举的序数进行索引的数组时,使用正确的int值就是你的职责了,int不能提供枚举的类型安全。你如果使用了错误的值,程序就会悄悄地完成错误的工作,或者幸运的话,会抛出ArrayIndexOutBoundException异常。
幸运的是,有一种更好的方法可以达到同样的效果,数组实际上充当着从枚举到值的映射,因此可能还要用到Map。更具体地说,有一种非常快速的Map实现专门用于枚举键,称作java.util.EnumMap。如下:
public static void main(String[] args) {
Herb[] garden = getAllHerbsFromGarden();
Map<Herb.Type,Set<Herb>> herbsByType = new EnumMap<Herb.Type,Set<Herb>>(Herb.Type.class);
for (Herb.Type t : Herb.Type.values()) {
herbssByType.put(t,new HashSet<Herb>());
}
for (Herb h : garden) {
herbsByType.get(h.type).add(h);
}
System.out.println(herbsByType);
}
和之前的代码相比,这段代码更加简短、清楚,也更加安全,运行效率方面也是可以与使用ordinal()的方式想媲美的。它没有不安全的转换,不必手工标注这些索引的输出,因为映射键知道如何将自身翻译成可打印字符串的枚举,计算数组索引里也不可能出错。
注意EnumMap构造器采用键类型的Class对象,这是一个限制的类型令牌,它提供了运行里的泛型信息。
总而言之,最好不要用序数来索引数组,而要使用EnumMap。如果你所表示的这种关系是多维的,就使用EnumMap<... , Enum<...>>。
5. 用接口模拟可伸缩的枚举
枚举是无法被扩展(extends)的,这是一个无法回避的事实。如果我们的操作中存在一些基础操作,如计算器中的基本运算类型(加减乘除)。然而对于有些用户来讲,他们也可以使用更高级的操作,如求幂和求余等。针对这样的需求,该条目提出了一种非常巧妙的设计方案,即利用枚举可以实现接口这一事实,我们将API的参数定义为该接口,而不是具体的枚举类型,见如下代码:
public interface Operation {
double apply(double x,double y);
}
public enum BasicOperation implements Operation {
PLUS("+") {
public double apply(double x,double y) { return x + y; }
}
MINUS("-") {
public double apply(double x,double y) { return x - y; }
}
TIMES("*") {
public double apply(double x,double y) { return x * y; }
}
pIDE("/") {
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("^") {
public double apply(double x,double y) {
return Math.pow(x,y);
}
}
REMAINDER("%") {
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是被写成采用接口类型而非实现。通过以上的代码可以看出,在任何可以使用BasicOperation的地方,我们也同样可以使用ExtendedOperation,只要我们的API是基于Operation接口的,而非BasicOperation或ExtendedOperation。下面为以上代码的应用示例:
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));
}
}
注意,参数Class<T> opSet将推演出类型参数的实际类型,即上例中的ExtendedOperation。与此同时,test函数的参数类型限定确保了类型参数既是枚举类型又是Operation的实现类,这正是遍历元素和执行每个元素相关联的操作所必须的。
还有一种方法是使用Collection<? Extends Operation>,这是个有限制的通配符类型,作为opSet参数的类型:
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));
}
}
上面的代码允许调用者将多个实现类型的操作合并到一起,另一方面,也放弃了在指定操作上使用EnumSet和EnumMap的功能,因此除非需要灵活地合并多个实现类型的操作,否则可能最好使用有限制的类型令牌。
用接口模拟可伸缩枚举有个小小的不足,即无法将实现从一个枚举类型继承到另一个枚举类型。
6. 注解优先于命名模式
Java1.5发行版本之前,一般使用命名模式表明有些程序元素需要通过某种工具或者框架进行特殊处理。如JUnit测试框架原本要求它的用户一定要用test作为测试方法名称的开头。这种方法有几个很严重的缺点如下:
(1)文字拼写错误会导致失败,且没有任何提示。
(2)无法确保它们只用于相应的程序元素上。
(3)它们没有提供将参数值与程序元素关联起来的好方法。
注解很好地解决了上面所有这些问题,
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test{
@Test public void m1(){}
}
@Retention元注解表明,Test注解应该在运行里保留,如果没有保留,测试工具就无法知道Test注解。
@Target元注解表明,Test注解只在方法中才是合法的,它不能运用到类声明、域声明或者其他程序元素上。
@Test注解称作标记注解,因为它没有参数,只是标注被注解的元素。
测试运行工具在命令行上使用完全匹配的类名,并通过调用Method.invoke反射式地运行类中所有标注了Test的方法。isAnnotationPresent方法告知该工具要运行哪些方法。如果测试抛出异常,反射机制就会将它封装在InvocationTargetException中。该工具捕捉到了这个异常,并打印失败报告,包含测试方法抛出的原始异常,这些信息是通过getCause方法从InvocationTargetException中提取出来的。
如果尝试通过反射调用测试方法里抛出InvocationTargetException之外的任何异常,表明编译里没有捕捉到Test注解的无效用法。
总结,除了“工具铁匠”之外,大多数程序员都不必定义注解类型。但是所有的程序员都应该使用Java平台所提供的预定义的注解类型。还要考虑使用IDE或都静态分析工具所提供的任何注解。这种注解可以提升由这些工具所提供的诊断信息的质量。但是要注意这些注解还没有标准化,因此如果变换工具或者形成标准,就有很多工作要做了。
7. 坚持使用Override注解
Override注解只能用在方法声明中,表示被注解的方法声明覆盖了超类型中的一个声明。
首先,如果没有加Override注解,覆盖很容易被误写成重载,如下:
public boolean equals(Bigram o){
return b.first == o.first && b.second == second;
}
如果是为了覆盖Object.equals,必须定义一个参数为Object类型的equals方法。否则实例将使用从父类继承的equals方法。完整使用注解如下:
@Override
public boolean equals(Object o){
if(!(o instance of Bigram)){
return false;
}
Bigram b = (Bigram)o;
return b.first == first && b.second == second;
}
现在的IDE提供了坚持使用Override注解的另一理由,这种IDE具有自动检查功能,称作代码检验。如果启用相应的代码检验功能,当有一个方法没有Override注解,却覆盖了超类方法时,IDE就会产生一条警告。
总之,如果在你想要的每个方法声明中使用Override注解来覆盖超类声明,编译器就可以替你防止大量的错误,但有一个例外,在具体的类中,不必标注你确信覆盖了抽象方法声明的方法(虽然这么做也没有什么坏处)。
8. 用标记接口定义类型
标记接口是没有包含方法声明的接口,而只是指明一个类实现了具有某种属性的接口。如Serializable接口只是表明它的实例可以被写到ObjectOutputStream,或者说被序列化。
你可能听说过标记注解使得标记接口过时了。这种断言是不正确的。标记接口有两点用过标记注解,首先,也是最重要的一点是,标记接口定义的类型是由被标记类的实例实现的,标记注解则没有定义这样的类型。另一个优点是它们可以被更加精确地进行锁定。
总之,标记接口和标记注解都用处,如果想要定义一个任何新方法都不会与之关联的类型,标记接口就是最好的选择,如果想要定义类型一定要使用接口。如果想要标记程序元素而非类和接口,考虑到未来可能要给标记添加更多的信息,或者标记要适合于已经广泛使用了注解类型的框架,那么标记注解就是正确的选择。