36 使用 EnumSet 替代位属性
EnumSet 类集位属性的简介和性能优势及枚举类型的所有优点于一身。
如果枚举类型的元素主要用于集合中,一般就使用int枚举模式,例如将2的不同倍数设置成常量:
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) { ... } }
这种int型表示方式允许使用按位或(or)运算,将几个常量合并到一个称为位属性(bit field)的集合中:
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
除了使用 OR 运算符之外,位属性表示法还可以支持求交集、异或求加法等很方便的操作,但是它本质上还是int型枚举常量,所以继承了int枚举常量的所有缺点:
1. 打印位属性时,翻译位域要难得多
就好比让你直接用二进制编程,酸爽程度可想而知
2. 没有一个好的方法可以遍历所有位属性表示的元素
3. 写API之前就要确定好需要多少位,选择相应的类型(int、long)
java.util包提供了 EnumSet 类来有效地表示从单个枚举类型中提取的值集合。
在内部具体的实现上,每个EnumSet表示为位矢量。
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
提供了丰富的静态工厂,可以轻松创建集合:
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
因为枚举类型要用在集合中,所以不推荐用位域来表示它,推荐使用EnumSet
37 使用EnumMap 替代序数索引
不要用ordinal来索引数组,要使用EnumMap
有时可能会用到ordinal方法来作为数组的索引,例如创建一个植物类:
public 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; } }
如果有一个植物数组plantsByLifeCycle
,按照不同的生长周期(一年生,多年生,或双年生)将花园里的植物garden
放入不同的位置:
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]); }
上面的代码有很多问题:
- 数组不能与泛型兼容,编译会报错
- 数组不知道它的索引代表什么,比如添加额外的注释来标注这些索引的输出
- 当使用以索引顺序为索引的数组时,必须人工保证使用的int值不出差错
使用java.util.EnumMap可以更好地达到上面想要的效果,并规避风险:
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);
EnumMap 与序数索引数组的速度相当,其原因正是 EnumMap 内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节
注意EnumMap 构造方法传入一个Class对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息
通过使用stream可以进一步缩短程序:
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle)));
这个代码的问题在于它选择了自己的 Map 实现,实际上并不是EnumMap
,使用Collectors.groupingBy
的三个参数形式的方法,它允许调用者使用mapFactory参数指定map的实现:
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet())));
还有按照序数进行二维数组索引的情况,例如下面代码表示了物理学中物质的状态变化过程(如液体到固体是凝固,液体到气体是沸腾):
public enum Phase { SOLID, LIQUID, GAS; public enum Transition { MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT; // Rows indexed by from-ordinal, cols by to-ordinal private static final Transition[][] TRANSITIONS = { {null, MELT, SUBLIME}, {FREEZE, null, BOIL}, {DEPOSIT, CONDENSE, null} }; // Returns the phase transition from one phase to another public static Transition from(Phase from, Phase to) { return TRANSITIONS[from.ordinal()][to.ordinal()]; } } }
程序看起来很优雅,但是和上面示例一样,有几个缺陷:
编译器不知道序数和数组索引之间的关系
如果在转换表中出错或者在修改Phase 或Phase.Transition枚举类型时忘记更新它,就会报错
按照上面的思路,可以用EnumMap修改,使用Map(起始状态, Map(目标状态, 过渡方式))这种存储格式:
public enum Phase { SOLID, LIQUID, GAS; public enum Transition { MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID); private final Phase from; private final Phase to; Transition(Phase from, Phase to) { this.from = from; this.to = to; } // Initialize the phase transition map private static final Map<Phase, Map<Phase, Transition>> m = Stream.of(values()) .collect(groupingBy(t -> t.from, () -> new EnumMap<>(Phase.class), toMap(t -> t.to, t -> t, (x, y) -> y, () -> new EnumMap<>(Phase.class)))); public static Transition from(Phase from, Phase to) { return m.get(from).get(to); } } }
映射的类型是Map<Phase, Map<Phase, Transition>>,例如Map<液体, Map<固体, 凝固过程>>
第一个集合Phase对Transition进行分组,第二个集合使用从Phase到Transition的映射创建一个EnumMap。第二个收集器((x, y)-> y)) 中的merge方法未使用,只有在我们因为我们想要获得一个EnumMap而定义映射工厂时才需要用到
现在假设想为系统添加一个新阶段:plasma(离子)或电离气体。只有两个Transition与之关联:电离化(ionization),将气体转为离子;和去离子;消电离化(deionization)将离子体转为气体。
要更新层序时,只需将PLASMA添加到Phase中,并将IONIZE(GAS, PLASMA)和DEIONIZE(PLASMA, GAS)添加到Transition中:
public enum Phase { SOLID, LIQUID, GAS, PLASMA; public enum Transition { MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID), IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS); ... // Remainder unchanged } }
很方便,也很安全!
38 用接口实现可继承的枚举
对于“可继承”的枚举来说,操作码(operation codes, opcodes)是一个经典的例子,操作码是枚举类型,其元素表示某些机器上的操作,例如34中的 Operation 类型,它表示简单计算器上的功能。
有时需要让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; } }, 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; } }
虽然枚举类BasicOperation
无法被继承,但接口Operation
是可以被继承的。假设想要扩展前面的操作类型,包括指数运算和余数运算。要做的就是编写一个实现 Operation
接口的枚举类型:
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; } }
测试程序如下:
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类的字面文字(ExtendedOperation.class)被传递给了test方法,<T extends Enum<T> & Operation> Class<T> 确保了Class 对象既是枚举又是Operation 的子类,这正是遍历元素和执行每个元素相关联的操作时所需要的
另一种方法是传入一个Collection<? extends Operation>,和上面的差异在于这是一个限定通配符类型(⻅第31条),而不是传递了一个class 对象:
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)); }
上面两个main函数的执行结果全都如下:
4.000000 ^ 2.000000 = 16.000000 4.000000 % 2.000000 = 0.000000
使用接口来实现可扩展枚举的一个小缺点是,无法实现一个枚举类继承另一个枚举类。
Java 平台也借鉴了这种方式来实现java.nio.file.LinkOption枚举类型,它同时实现了 CopyOption 和 OpenOption 接口。
39 注解优先于命名模式
所有的程序员都应该使用Java平台所提供的预定义的注解类型,既然有了注解,就不要使用命名模式了
使用命名模式(naming patterns)来指示某些程序元素需要通过工具或框架进行特殊处理。例如在 JUnit 4 发布之前,要求程序员必须以test作为测试方法名的开头,这有几个严重缺点:
1. 英语拼写错误会导致运行失败,且没有任何提示
2. 无法实现将测试用于某个程序元素上
意思是,假如将TestSafetyMechanisms类,希望JUnit 3 能够自动测试其所有方法,实际上并不会测试
3. 没有提供将参数值与程序元素相关联的好的方法
例如无法测试只有抛出异常才算成功的代码
注解很好地解决了所有这些问题,JUnit从版本4开始,使用注解来指定简单的测试:
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这个注解在运行时有效,@Target.get(ElementType.METHOD)元注解表明Test 注解只能修饰方法。
在Test 的注释说:“Use only on parameterless static method”(只用于无参的静态方法),实际上编译器并未强制限定这一条。
public class Sample { @Test public static void ml() { } // Test should pass public static void m2() { } @Test public static void m3() { // Test should fail throw new RuntimeException ("Boom"); } public static void m4() { } @Test public void m5() { } // INVALID USE: nonstatic method public static void m6() { } @Test public static void m7() { // Test should fail throw new RuntimeException("Crash"); } public static void m8() { } }
Sample 类有七个静态方法,其中四个被标注为Test。其中两个,m3 和m7 引发异常,两个m1 和m5 不引发异常。但是没有引发异常的注解方法之一是实例方法,因此它不是注释的有效用法。总之,Sample 包含四个测试:一个会通过,两个会失败,一个是无效的。未使用Test 注解标注的四种方法将被测试工具忽略。
注解永远不会改变被注解代码的语义,但是它可以通过工具进行特殊处理:
import java.lang.reflect.*; import org.junit.Test; 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 注解的方法,运行结果:
现在添加对 仅在特定异常时 才算成功的测试的支持,添加一个新的注解类型:
import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ExceptionTest { Class<? extends Throwable> value(); }
此注解的参数类型是Class<? extends Throwable>
,意思是:某个继承了Throwable
类的Class对象,它允许注解的用户指定任何异常(或错误)类型。
public class Sample2 { @ExceptionTest(ArithmeticException.class) public static void m1() { // Test should pass int i = 0; i = i / i; } @ExceptionTest(ArithmeticException.class) public static void m2() { // Should fail (wrong exception type) int[] a = new int[0]; int i = a[1]; } @ExceptionTest(ArithmeticException.class) public static void m3() { } // Should fail (no exception) }
现在要修改一下测试运行工具来处理新的注解:
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); } }
上面的代码提取注解参数的值,并用它检查代码抛出的异常是否是注解指定的类型。
如果需求更进一步:测试在抛出多个指定异常时都算成功。注解机制有一个支持这种用法的工具。将ExceptionTest 注解的参数类型更改为Class对象数组:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ExceptionTest { Class<? extends Throwable>[] value(); }
最重要的是,这种语法十分灵活,使用了最新版的ExceptionTest
之后,所有以前的ExceptionTest
注解仍然有用。如果要指定多个异常,需要用花括号将其包裹起来:
// Code containing an annotation with an array parameter @ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class }) public static void doublyBad() { List<String> list = new ArrayList<>(); // The spec permits this method to throw either // IndexOutOfBoundsException or NullPointerException list.addAll(5, null); }
修改测试运行工具类:
if (m.isAnnotationPresent(ExceptionTest.class)) { tests++; try { m.invoke(null); System.out.printf("Test %s failed: no exception%n", m); } catch (Throwable wrappedExc) { Throwable exc = wrappedExc.getCause(); int oldPassed = passed; Class<? extends Exception>[] excTypes = m.getAnnotation(ExceptionTest.class).value(); for (Class<? extends Exception> excType : excTypes) { if (excType.isInstance(exc)) { passed++; break; } } if (passed == oldPassed) System.out.printf("Test %s failed: %s %n", m, exc); } }
从Java 8开始,还有另一种方法来执行多值注解。用 @Repeatable 元注解对注解的声明进行注解,表示该注解可以被重复地应用给单个元素。元注解只有1个参数,就是包含注解类型(containing annotation type)的类对象(下面代码里的ExceptionTestContainer.class),它的唯一参数是一个注解类型数组:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Repeatable(ExceptionTestContainer.class) public @interface ExceptionTest { Class<? extends Exception> value(); } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ExceptionTestContainer { ExceptionTest[] value(); }
下面是我们的doublyBad 测试用一个重复的注解代替基于数组值注解的方式:
@ExceptionTest(IndexOutOfBoundsException.class) @ExceptionTest(NullPointerException.class) public static void doublyBad() { ... }
处理可重复的注解要非常小心。
要使用isAnnotationPresent检测重复和非重复的注解,需要检查注解类型及其包含的注解类型:
if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) { tests++; try { m.invoke(null); System.out.printf("Test %s failed: no exception%n", m); } catch (Throwable wrappedExc) { Throwable exc = wrappedExc.getCause(); int oldPassed = passed; ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class); for (ExceptionTest excTest : excTests) { if (excTest.value().isInstance(exc)) { passed++; break; } } if (passed == oldPassed) System.out.printf("Test %s failed: %s %n", m, exc); } }
如果你觉得这样写增强了代码的可读性就这样写,但在声明和处理可重复注解时存在更多的模板代码,并且处理可重复的注解很容易出错。
40 坚持使用Override注解
阿里巴巴开发手册这样规定:
这个注解只能在方法声明上使用,它表明带此注解的方法声明重写了父类的声明。坚持使用这个注解,将避免产生大量的bug。
下面代码中,类Bigram 表示双字⺟组合:
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()); } }
主程序重复添加二十六个双字⺟组合到集合中,然后它会打印集合的大小。如果运行程序,会发现它打印的是260。
原因在于:
程序员没有覆盖equals,而是把它重载了,为什么没有覆盖呢?原因是为了覆盖Object的equals方法,必须定义一个入参为Object的equals方法,上面代码中的入参是Bigram,所以main方法里实际调用的还是Objecct的equals方法,这个方法可以简单理解为比较两个对象实例的地址,每个bigram的10个备份中,每个都与其余的9个不一样,所以上面打印出260。
如果加上@Override注解,编译器就能帮助我们发现这个错误:
@Override public boolean equals(Bigram b) { return b.first == first && b.second == second; }
此时就会报错:
这样我们就能反应过来要如何修改了:
@Override public boolean equals(Object o) { if (!(o instanceof Bigram)) return false; Bigram b = (Bigram) o; return b.first == first && b.second == second; }
要在每个覆盖父类的方法上声明@Override注解,如果你在编写一个继承了抽象类的类,就不用在方法上声明@Override注解
子类如果没有覆盖抽象类的方法,编译器会报错
不过话说回来,作者还是建议无论是对于抽象类还是接口,还是要用@Override标注所有要覆盖父类或接口的方法。例如Set接口没有给Collection接口添加新方法,他应该在所有的方法上用@Override标注,以确保它不会意外给Collection接口添加任何新方法。
41 用标记接口定义类型
标记接口(marker interface)指没有任何方法的接口,例如Serializable接口,实现了这个接口的类可以被写到ObjectOutputStream中,也就是经常说的可以被序列化。
标记接口有两条优于标记注解:
1. 标记接口定义的类型由被标记类的实例实现
所以标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。
比较遗憾的一点是:Object.OutputStream.writeObject API没有利用 Serializable 接口的优势:它的参数被声明为Object 类型而不是Serializable,所以尝试序列化一个不可序列化的对象,直到运行时才会失败。
2. 标记接口可以更精确地定位目标
如果注解类型用 ElementType.TYPE 声明,它就可以被应用于任何类或接口。但如果有一个注解只适用于特定接口,就需要将它定义成一个标记接口,可以扩展它使用的唯一接口,确保所有被注解的类型也都是该唯一接口的子类型。
Set 接口就是这样一个受限的标记接口(restricted marker interface)。它仅适用于 Collection 子类型
标记注解优于标记接口的主要优点是它们是更大的注解工具的一部分,标记注解在基于注解的框架中保持一致性。
所以什么时候应该使用「标记注解」,什么时候应该使用「标记接口」呢?
如果标记是应用于除类或接口以外的任何地方,则必须使用注解
如果标记仅适用于类和接口,可以问自己一个问题:「可能我想编写一个或多个只接受具有此标记的对象的方法呢?」,如果是这样,则用「标记接口」。这样就可以用接口作为相关方法的参数类型,这将带来编译时类型检查的好处
如果永远不会写一个只接收只标记对象的方法(类似ObjectOutputStream这种),则用「标记注解」
如果标记是大量使用注解的框架的一部分,则用「标记注解」