[Java读书笔记] Effective Java(Third Edition) 第 6 章 枚举和注解
Java支持两种引用类型的特殊用途的系列:一种称为枚举类型(enum type)的类和一种称为注解类型(annotation type)的接口。
第34条:用enum代替int常量
枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星。
在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为int的常量,每个类型的成员都有一个常量。
int枚举模式的技术有许多缺点。不具有类型安全性,也没有描述性可言。
Java提供了一种避免int和String枚举模式的所有缺点的替代方法,并提供了许多额外的好处。它没有提供类型安全的方式,也没有提供任何表达力。
例如:
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH } public enum Orange { NAVEL, TEMPLE, BLOOD }
Java枚举类型背后的基本思想很简单:它们是通过公共静态final属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是final类。 由于客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第1条)。 它们是单例Singleton(第3条)的泛型化,基本上是单元素的枚举。
除了纠正int枚举的缺陷之外,枚举类型还允许添加任意方法和属性并实现任意接口。 它们提供了所有Object方法的高级实现(第3章),它们实现了Comparable(第 14条)和Serializable接口(第12章),并针对枚举类型的可任意改变性设计了序列化方式。
枚举中添加方法或域能将数据域常量联系起来。太阳系8大行星例子,每颗行星有质量和半径,通过这两个属性可以计算出表面重力。
// Enum type with data and behavior 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; // In kilograms private final double radius; // In meters private final double surfaceGravity; // In m / s^2 // Universal gravitational constant in m^3 / kg s^2 private static final double G = 6.67300E-11; // Constructor 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; // F = ma } }
编写一个丰富的枚举类型比如Planet很容易。 要将数据与枚举常量相关联,请声明实例属性并编写一个构造方法,构造方法带有数据并将数据保存在属性中。
虽然Planet枚举很简单,但它的功能非常强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在所有八个行星上的重量(以相同单位):
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)); } }
有时候需要不同的行为与每个常量关联起来,比如计算器四大基本操作,提供一个方法来执行每个常量所表示的算术运算。
// Enum type with constant-specific method implementations public enum 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;}}, DIVIDE{public double apply(double x, double y){return x / y;}}; public abstract double apply(double x, double y); }
特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是Operation的一个版本,它重写toString方法以返回通常与该操作关联的符号:
// Enum type with constant-specific class bodies and data public enum 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; } }, DIVIDE("/") { public 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; } public abstract double apply(double x, double y); }
显示的toString实现可以很容易地打印算术表达式:
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和4作为命令行参数运行此程序会生成以下输出:
2.000000 + 4.000000 = 6.000000 2.000000 - 4.000000 = -2.000000 2.000000 * 4.000000 = 8.000000 2.000000 / 4.000000 = 0.500000
枚举中的switch语句适合于给外部的枚举类型增加特定于常量的行为。
例如,假设Operation枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。
// Switch on an enum to simulate a missing method public static Operation inverse(Operation op) { switch(op) { case PLUS: return Operation.MINUS; case MINUS: return Operation.PLUS; case TIMES: return Operation.DIVIDE; case DIVIDE: return Operation.TIMES; default: throw new AssertionError("Unknown op: " + op); } }
什么时候应该使用枚举?每当需要一组固定常量,并且在编译时就知道其成员的时候,就应该使用枚举。例如行星、一周的天数等等。也包括在编译时就知道其所有可能值得其他集合,比如菜单的选项、操作代码已经命令行标识等。
总之,与int相比,枚举更具可读性,更安全,更强大。
第35 条:用实例域代替序数
许多枚举通常与单个int值关联。所有枚举都有一个ordinal方法,它返回每个枚举常量类型的数值位置。你可能想从序数中派生一个关联的int值:
// Abuse of ordinal to derive an associated value - DON'T DO THIS public enum Ensemble { SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET; public int numberOfMusicians() { return ordinal() + 1; } }
如果常量被重新排序,numberOfMusicians方法将会中断。 如果你想添加一个与你已经使用的int值相关的第二个枚举常量,则没有那么好运了。
永远不要从根据枚举的序数导出与它关联的值,而是要将它保存在一个实例域中:
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; } }
第36条:用EnumSet代替位域
如果一个枚举类型的元素主要用于集合中,一般就使用int枚举模式(第 34条),例如将2的不同倍数赋值给每个常量:
// Bit field enumeration constants - OBSOLETE! 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 // Parameter is bitwise OR of zero or more STYLE_ constants public void applyStyles(int styles) { ... } }
这种表示法让你使用OR位运算将几个常量合并到一个集合中,称为位域(bit field):
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
但是位域有int枚举常量等的所有缺点,甚至更多。翻译位域比int枚举困难,在编写时要预测最多需要多少位,还要选择对应类型(int或long),一旦确定,在不修改API的情况下,将不能超出其位宽度(如32位或64位)。
下面是前一个范例改成用枚举代替位域之后的代码:
// EnumSet - a modern replacement for bit fields public class Text { public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH } // Any Set could be passed in, but EnumSet is clearly best public void applyStyles(Set<Style> styles) { ... } }
下面将EnumSet实例传递给applyStyle方法,如下:
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
总之,因为枚举类型要用在集合中,所有没有理由用位域来表示它。EnumSet类集位域的简洁和性能优势及第34条所述枚举类型的所有优点。
第37条:用EnumMap代替序数索引
有时可能会看到使用ordinal方法(条目 35)来索引到数组或列表的代码。例如下面表示一种香草:
class Plant { enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL } final String name; final LifeCycle lifeCycle; Plant(String name, LifeCycle lifeCycle) { this.name = name; this.lifeCycle = lifeCycle; } @Override public String toString() { return name; } }
现在假设有一个香草的数组,表示一座花园中的植物,想要列出这些由生命周期组织的植物(一年生,多年生,或双年生)。需要构建三个集合,每种类型一个,并遍历整个花园,将每种香草放到相应的集合中。有些人会将这些集合放到一个按照类型的序数进行索引的数组中来实现这一点:
// Using ordinal() to index into an array - DON'T DO THIS! Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length]; for (int i = 0; i < plantsByLifeCycle.length; i++) plantsByLifeCycle[i] = new HashSet<>(); for (Plant p : garden) plantsByLifeCycle[p.lifeCycle.ordinal()].add(p); // Print the results for (int i = 0; i < plantsByLifeCycle.length; i++) { System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]); }
这种方法可行,但有许多问题。因为数组不能与泛型兼容(第28条),程序需要进行未受检的转换,并且不能正确无误地进行编译。因为数组不知道它的索引代表什么,你必须手工标注这些索引的输出。
更好的办法是使用map,有一种快速map专门用于枚举键,称为java.util.EnumMap。
修改后的程序:
// Using an EnumMap to associate data with an enum Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class); for (Plant.LifeCycle lc : Plant.LifeCycle.values()) plantsByLifeCycle.put(lc, new HashSet<>()); for (Plant p : garden) plantsByLifeCycle.get(p.lifeCycle).add(p); System.out.println(plantsByLifeCycle);
这段程序更简短,更清晰,更安全,运行速度与原始版本相当。 没有不安全的转换; 无需手动标记输出,因为map键是知道如何将自己转换为可打印字符串的枚举; 并且不可能在计算数组索引时出错。 EnumMap与序数索引数组的速度相当,其原因是EnumMap内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节,将Map的丰富性和类型安全性与数组的速度相结合。
总之,最好不要用序数来索引数组,而要使用EnumMap。 如果你所代表的关系是多维的,请使用EnumMap <...,EnumMap <... >>。
第38条:用接口模拟可扩展的枚举
大多数情况下,枚举的可扩展性是一个糟糕的主意。对于可伸缩的枚举类型,有一种具有说服力,即操作码(operation code)。如第34条的Operation类型,表示计算器。有时候要尽可能地让API的用户提供它们自己的操作,这样可以有效地扩展API所提供的操作集。
// Emulated extensible enum using an interface 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; } }, DIVIDE("/") { 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; } }
假设想要定义前面所示的操作类型的扩展,包括指数运算和余数运算。 你所要做的就是编写一个实现Operation接口的枚举类型:
// Emulated extension enum 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编写为接口类型(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> opEnumType, double x, double y) { for (Operation op : opEnumType.getEnumConstants()) System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y)); }
扩展过的操作类型的类的字面文字(ExtendedOperation.class)从main方法里传递给了test方法,用来描述被扩展操作的集合。这个类的字面文字充当有限制的类型令牌(第33条)。opEnumType参数中复杂的声明(<T extends Enum<T> & Operation> Class<T>)确保了Class对象既是枚举又是Operation的子类,这正是遍历元素和执行每个元素相关联的操作时所需要的。
第二种方式是传递一个Collection<? extends Operation>,这是一个限定通配符类型(第31条),而不是传递了一个类对象:
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)); }
上面的两个程序在运行命令行输入参数4和2时生成以下输出:
4.000000 ^ 2.000000 = 16.000000 4.000000 % 2.000000 = 0.000000
总之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型来对它进行模拟。允许客户端谢自己的枚举来实现接口。
第 39 条:注解优于命名模式
一般使用命名模式(naming parttern)表明有些程序元素需要通过某种工具或者框架进行特殊处理。
如Junit4之前,用户必须以test作为测试方法的开头。
这种方法有几个缺点:1.文字拼写错误会导致失败,且没有提示。 2. 无法确保它们只用于相应的程序元素上。3. 它们没有提供将参数值与程序元素关联起来的好办法。
Junit4解决了以上问题。以下是名为Test的这种注解类型的定义:
// Marker annotation type declaration import java.lang.annotation.*; /** * Indicates that the annotated method is a test method. * Use only on parameterless static methods. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Test { }
Test注解类型的声明本身使用Retention和Target注解进行标记。 注解类型声明上的这种注解称为元注解。 @Retention(RetentionPolicy.RUNTIME)元注解指示Test注解应该在运行时保留。 没有它,测试工具就不会看到Test注解。@Target.get(ElementType.METHOD)元注解表明Test注解只对方法声明合法:它不能应用于类声明,属性声明或其他程序元素。
注解不会改变注解代码的语义,但可以通过诸如这个简单的测试运行器等工具对其进行特殊处理:
// Program to process marker annotations import java.lang.reflect.*; public class RunTests { public static void main(String[] args) throws Exception { int tests = 0; int passed = 0; Class<?> testClass = Class.forName(args[0]); for (Method m : testClass.getDeclaredMethods()) { if (m.isAnnotationPresent(Test.class)) { tests++; try { m.invoke(null); passed++; } catch (InvocationTargetException wrappedExc) { Throwable exc = wrappedExc.getCause(); System.out.println(m + " failed: " + exc); } catch (Exception exc) { System.out.println("Invalid @Test: " + m); } } } System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed); } }
测试运行器工具在命令行上接受完全限定的类名,并通过调用Method.invoke来反射地运行所有类标记有Test注解的方法。 isAnnotationPresent方法告诉工具要运行哪些方法。 如果测试方法引发异常,则反射机制将其封装在InvocationTargetException中。 该工具捕获此异常并打印包含由test方法抛出的原始异常的故障报告,该方法是使用getCause方法从InvocationTargetException中提取的。
让我们添加对仅在抛出特定异常时才成功的测试的支持。 我们需要为此添加一个新的注解类型:
// Annotation type with a parameter import java.lang.annotation.*; /** * Indicates that the annotated method is a test method that * must throw the designated exception to succeed. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ExceptionTest { Class<? extends Throwable> value(); }
此注解的参数类型是Class<? extends Throwable>。 在英文中,它表示“某个扩展Throwable的类的Class对象”,它允许注解的用户指定任何异常(或错误)类型。 这个用法是一个限定类型标记的例子(第 33条)。
修改测试运行器工具来处理新的注解:
if (m.isAnnotationPresent(ExceptionTest.class)) { tests++; try { m.invoke(null); System.out.printf("Test %s failed: no exception%n", m); } catch (InvocationTargetException wrappedEx) { Throwable exc = wrappedEx.getCause(); Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value(); if (excType.isInstance(exc)) { passed++; } else { System.out.printf( "Test %s failed: expected %s, got %s%n", m, excType.getName(), exc); } } catch (Exception exc) { System.out.println("Invalid @Test: " + m); } }
总之,除了工具铁匠(toolsmiths,即平台框架程序员)之外,不必自定义注解。应该尽量使用Java平台所提供的预定义注解类型。还要考虑IDE或静态分析工具所提供的任何注解。
第40 条:坚持使用Override注解
Override它表示被注解的方法声明覆盖了超类型中的一个方法声明。如果使用这个注解,将避免大量非法错误。例如:
// Can you spot the bug? public class Bigram { private final char first; private final char second; public Bigram(char first, char second) { this.first = first; this.second = second; } public boolean equals(Bigram b) { return b.first == first && b.second == second; } public int hashCode() { return 31 * first + second; } public static void main(String[] args) { Set<Bigram> s = new HashSet<>(); for (int i = 0; i < 10; i++) for (char ch = 'a'; ch <= 'z'; ch++) s.add(new Bigram(ch, ch)); System.out.println(s.size()); } }
主程序反复添加26个双字母组合到集合中,每个双字母组合由两个相同的小写字母组成。 然后它会打印集合的大小。 你可能希望程序打印26,因为集合不能包含重复项。 如果你尝试运行程序,你会发现它打印的不是26,而是260。哪里出错了?
Bigram的创作者原本想要覆盖equals方法(第10条),同时还记得覆盖hashCode(第11条)。遗憾的是程序并没有覆盖(overrride)equals方法,而是重载(overload)(第52条)了equals方法。
要重写Object.equals,必须定义一个equals方法,其参数的类型为Object,但Bigram的equals方法的参数不是Object类型的,因此Bigram继承Object的equals方法。
幸运的是编译器可以帮你发现这个错误,要用@Override标注Bigram.equals。
Override注解可以用在方法声明中,覆盖来做接口以及类的声明。由于缺省方法(default)的出现,在接口方法的具体实现上使用Override,可以确保签名正确。如果知道接口没有缺省方法,可以选择省略接口方法的具体实现上的Override注解,以减少混乱。
总之,如果在你想要的每个方法声明中使用Override注解来覆盖超类声明,编译器就可以替你防止大量错误。
第41 条:用标记接口定义类型
标记接口(marker interface)是不包含方法声明的接口,只是指明一个类实现了具有某种属性的接口。例如,考虑Serializable接口(第12条)。 通过实现这个接口,一个类表明它的实例可以写入ObjectOutputStream(或“序列化”)。
标记接口定义的类型是由被标记类的实例实现的,标记注解则没有定义这样的类型。标记接口类型的存在,允许你在编译时就能捕捉到在使用标记注解的情况下要到运行时才能捕捉到的错误。
标记接口胜过标记注解的另一个优点是,他们可以被更加精确地进行锁定。
标记注解胜过标记接口的最大优点是,他们是更大的注解机制的一部分。
什么时候使用标记注解,什么时候使用标记接口?
如果标记适用于除类或接口以外的任何程序元素,则必须使用注解,因为只能使用类和接口来实现或扩展接口。如果标记仅适用于类和接口,那么问自己问题:“可能我想编写一个或多个只接受具有此标记的对象的方法呢?”如果是这样,则应该优先使用标记接口而不是注解。
总之,标记接口和标记注释都有其用处。 如果你想定义一个没有任何关联的新方法的类型,一个标记接口是一种可行的方法。 如果要标记除类和接口以外的程序元素,或者将标记符合到已经大量使用注解类型的框架中,那么标记注解是正确的选择。 如果发现自己正在编写目标为ElementType.TYPE的标记注解类型,请花点时间确定它是否应该是注释类型,是不是标记接口是否更合适。