首页 > 代码库 > Effective Java - 枚举与注解

Effective Java - 枚举与注解

Enumeration

于Java 1.5增加的enum type...
enum type是由一组固定的常量组成的类型,比如四个季节、扑克花色。
在出现enum type之前,通常用一组int常量表示枚举类型。
比如这样:

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;


如果只是想用作枚举,感觉这样也没什么。
但如果把上面的苹果和橘子互作比较,或者写成....

int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

虽合法但诧异,这是在做果汁吗?


而且,这种常量是compile-time常量,编译后一切都结束了,使用这个常量的地方都被替换为该常量的值。
如果该常量值需要改变,所有使用该常量的代码都必须重新编译。
更糟糕的情况是,不重新编译也可以正常运行,只不过会得到无法预测的结果。
(ps:我觉得更遭的是有人直接把常量值写到代码里...)


另外,比如上面的APPLE_FUJI,我想打印它的名字,不是它的值。
不仅如此,我还想打印所有苹果,我想打印苹果一共有多少种类。
当然,如果想打印也可以,只是相比直接使用enum,无论怎么做都很麻烦。


如果使用enum,比如:

public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH } public enum Orange { NAVEL, TEMPLE, BLOOD }


看起来就是一堆常量,但是enum没有实例,也没有可访问的构造器,无法对其进行扩展。
enum本身就是final,所以很多时候也直接用enum实现singleton。
enum在编译时是类型安全的,比如有地方声明了上面代码中的Apple类型的参数,那么被传到该参数的引用肯定是三种苹果之一。
而且enum本身就是一个类型,可以有自己的方法和field,而且可以实现接口。


附上书中太阳系enum,很难想象如果有类似需求时用普通常量来实现。
也许我可以声明一个Planet类,再给它加上field的方法,然后在一个constant类中声明为final但这样却无法保证Planet类仅用作常量,所以还是用enum吧:

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 enum,无论是值还是名字,使用起来都很自然:

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));    }}


其实像Planet这样的方式对多数使用枚举的场景而言足够了。
也就是说每个Planet常量表达的是不同的数据,但也有例外。
比如,我们要为enum中的每一个常量赋予不同的行为。
下面是书中用enum表达计算的例子:

import java.util.HashMap;import java.util.Map;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;        }    },    DIVIDE("/") {        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);    }    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));    }}


对不同的枚举常量进行switch..case..其实也能表达出我们想要的效果。
如果以后增加了新的常量则需要再对应加上一个case,当然,不加也不会有任何提示,然后最坏的情况就是运行时出了问题。
如上面的代码是常量行为的正确使用方法,即constant-specific method implementation。
为行为提供一个抽象,并为每一个常量提供一个实现,即一个枚举常量也是constant-specific class body。
采用这种方式时,如果新增一个常量,则必须提供一个方法实现,否则编译器会给出提示,这就多了一层保障。


遗憾的是,这种方式也有缺陷。
比如我们有这样一个需求,计算某一天的薪水,这个某一天可以是一周中的某一天,也可能是某个节日,比如周一到周五使用相同的运算方式,周末另算,某节日另算。
也就是说我需要在枚举中声明代表周一到周日的常量,如果我继续使用之前的方式去声明一个抽象方法,如果周一到周五采用完全一样的计算,则会出现五段完全相同的代码。
但即使这样我们也不能用回switch..case..方式,增加一个常量时强制选择其选择一种行为实现是必须的。
于是我们有一种叫strategy enum的方式,即枚举中声明另外一个枚举的field,该field则代表策略,并提供策略相关的行为。
下面是书中代码:

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);    }    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);        }    }}

 

Annotation

在Java 1.5之前时常有这样的情况,通过为程序元素进行特殊的命名以提供特殊的功能,比如JUnit中测试方法必须为test开头。
当然,这种方式在某种程度上确实可行,但不够优雅。
比如:

  • 错误的文字拼写并不会有任何提示,直到运行时才会发现出了问题。
  • 其次,这种方式无法特指某个程序元素,比如用户将某个类名的开头做了特殊命名,希望作用于类中所有的方法,结果可能没有提示、没有效果、没有意义。
  • 而且,这种方式太单调,比如我想和某个方法的参数或者和声明抛出的异常进行交互。当然,反射也可以,但问题是我如何在不知道用户行为的情况下提供反射方法。


平时工作很少提供过注解,大多数情况都是使用别人提供的注解。
没想过没有注解会是什么样子,但和naming pattern一比较发现确实太重要了。
比如在下面的例子,声明一个注解用于表示测试方法:

import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)public @interface Test {    //..}


对于代码中的retention和target,我们有专门的术语叫做"元注解(meta-annotation)"。
而对于这种没有参数,仅仅标注程序元素的注解,我们称作"标记注解(marker annotation)"。


如果需要给注解声明参数并不复杂,只是相当于给一个类添加实例field。
如下代码,表示测试时发生异常数组中的异常时进行通过:

import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)public @interface ExceptionTest {    Class<? extends Exception>[] value();}


当然,注解本身对程序元素并没有直接的影响,它无法改变代码本身的语义。
我们需要依赖于特定的注解处理类。
当然,并不是一个注解就对应一个处理类,一个处理类也可以处理很多种注解。
比如下面的代码为Test和ExceptionTest提供了处理:

import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;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);                }            }            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();                    Class<? extends Exception>[] excTypes = m.getAnnotation(                            ExceptionTest.class).value();                    int oldPassed = passed;                    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);                }            }        }        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);    }}


代码就不多做解释了,主要是通过反射判断注解和获取异常。


其实标记注解非常常见,但说到标记注解就不得不说标记接口,比如Serializable什么的仅仅是作为注明。
相比接口只能在类名后面加上implements,注解可以作用于更多的程序元素。于是便得出结论,标记接口可以淘汰了?
但这样过于片面。


首先,被接口标记的类提供该接口的实现,而这一点是注解无法做到的,就算有处理类进行补助也无法成为一种约束。
就Serializable而言,如果被标记的类没有提供实现,ObjectOutputStream.write(Object)则毫无意义。
另外,这个接口有点特殊,它确实是一种约束,但在编译期没给出警告。
我之前以为write方法没有定义在Serializable中可能有什么特殊意义,但作者原话是:

Inexplicably, the authors of the ObjectOutputStream API did not take advantage of the Serializable interface in declaring the write method.

可见他也不知道其中的意义,既然如此,我们也不仿效这种作法了吧。


第二点是接口标记地更加精确。
乍一看似乎有些矛盾,相比接口只能作用于类元素,注解可以作用于多种元素不是注解的优点吗?
其实作者表达的并不是这个观点,就一个接口和Target为ElementType.Type的注解而言,后者可以作用于任何类和接口。


作者用Set接口进行了说明,Set这种情况有些特殊,Set继承了Collection接口。
乍一看,Set似乎不是一个标记接口,它声明了太多方法。
参考:

The Set interface places additional stipulations, beyond those inherited from the Collection interface, on the contracts of all constructors and on the contracts of the add, equals and hashCode methods. Declarations for other inherited methods are also included here for convenience. (The specifications accompanying these declarations have been tailored to the Set interface, but they do not contain any additional stipulations.)

但作者将其描述为"a restricted marker interface",它声明的方法与Collection接口是相同的。
Set并没有改进Collection的契约,只是为实现类多提供了一种抽象描述。


但即便如此,也不能把注解设计成至少有一个参数的形式。
首先不得不承认,能标记的类型比接口更多,这个确实是一个优势。
另外,在一个类中,同一种标记注解可以出现多次,这一点也是其优势。
而最重要的,相比接口这种约定(即,声明后被一些类提供了实现,在后期版本中很难修改这个接口),注解则可以在后期变得更丰富。

Effective Java - 枚举与注解