Effective Java 第六章 枚举和注解

148 阅读42分钟

欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

Java支持两种引用类型的特殊用途的系列:一种称为枚举类型的类和一种称为注解类型的接口。 本章讨论使用这些类型系列的最佳实践。

34. 使用枚举类型替代整型常量

枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星或一副扑克牌中的套装。 在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为int的常量,每个类型的成员都有一个常量:

// The int enum pattern - severely deficient!
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枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 如果你将一个Apple传递给一个需要Orange的方法,那么编译器不会出现警告,还会用==运算符比较Apple与Orange,或者更糟糕的是:

// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

请注意,每个Apple常量的名称前缀为APPLE_,每个Orange常量的名称前缀为ORANGE_。 这是因为Java不为int枚举组提供名称空间。 当两个int枚举组具有相同的命名常量时,前缀可以防止名称冲突,例如在ELEMENT_MERCURYPLANET_MERCURY之间。

使用int枚举的程序很脆弱。 因为int枚举是编译时常量[JLS,4.12.4],所以它们的int值被编译到使用它们的客户端中[JLS,13.1]。 如果与int枚举关联的值发生更改,则必须重新编译其客户端。 如果没有,客户仍然会运行,但他们的行为将是不正确的。

没有简单的方法将int枚举常量转换为可打印的字符串。 如果你打印这样一个常量或者从调试器中显示出来,你看到的只是一个数字,这不是很有用。 没有可靠的方法来迭代组中的所有int枚举常量,甚至无法获得int枚举组的大小。

你可能会遇到这种模式的变体,其中使用了字符串常量来代替int常量。 这种称为字符串枚举模式的变体更不理想。 尽管它为常量提供了可打印的字符串,但它可以导致初级用户将字符串常量硬编码为客户端代码,而不是使用属性名称。 如果这种硬编码的字符串常量包含书写错误,它将在编译时逃脱检测并导致运行时出现错误。 此外,它可能会导致性能问题,因为它依赖于字符串比较。

幸运的是,Java提供了一种避免int和String枚举模式的所有缺点的替代方法,并提供了许多额外的好处。 它是枚举类型[JLS,8.9]。 以下是它最简单的形式:

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

从表面上看,这些枚举类型可能看起来与其他语言类似,比如C,C ++和C#,但事实并非如此。 Java的枚举类型是完整的类,比其他语言中的其他语言更强大,其枚举本质本上是int值。

Java枚举类型背后的基本思想很简单:它们是通过公共静态final属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是final的。 由于客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第6页)。 它们是单例(条目 3)的泛型化,基本上是单元素的枚举。

枚举提供了编译时类型的安全性。 如果声明一个参数为Apple类型,则可以保证传递给该参数的任何非空对象引用是三个有效Apple值中的一个。 尝试传递错误类型的值将导致编译时错误,因为会尝试将一个枚举类型的表达式分配给另一个类型的变量,或者使用==运算符来比较不同枚举类型的值。

具有相同名称常量的枚举类型可以和平共存,因为每种类型都有其自己的名称空间。 可以在枚举类型中添加或重新排序常量,而无需重新编译其客户端,因为导出常量的属性在枚举类型与其客户端之间提供了一层隔离:常量值不会编译到客户端,因为它们位于int枚举模式中。 最后,可以通过调用其toString方法将枚举转换为可打印的字符串。

除了纠正int枚举的缺陷之外,枚举类型还允许添加任意方法和属性并实现任意接口。 它们提供了所有Object方法的高质量实现(第3章),它们实现了Comparable(条目 14)和Serializable(第12章),并针对枚举类型的可任意改变性设计了序列化方式。

那么,为什么你要添加方法或属性到一个枚举类型? 对于初学者,可能想要将数据与其常量关联起来。 例如,我们的Apple和Orange类型可能会从返回水果颜色的方法或返回水果图像的方法中受益。 还可以使用任何看起来合适的方法来增强枚举类型。 枚举类型可以作为枚举常量的简单集合,并随着时间的推移而演变为全功能抽象。

对于丰富的枚举类型的一个很好的例子,考虑我们太阳系的八颗行星。 每个行星都有质量和半径,从这两个属性可以计算出它的表面重力。 从而在给定物体的质量下,计算出一个物体在行星表面上的重量。 下面是这个枚举类型。 每个枚举常量之后的括号中的数字是传递给其构造方法的参数。 在这种情况下,它们是地球的质量和半径:

// 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很容易。 要将数据与枚举常量相关联,请声明实例属性并编写一个构造方法,构造方法带有数据并将数据保存在属性中。 枚举本质上是不变的,所以所有的属性都应该是final的(条目 17)。 属性可以是公开的,但最好将它们设置为私有并提供公共访问方法(条目16)。 在Planet的情况下,构造方法还计算和存储表面重力,但这只是一种优化。 每当重力被SurfaceWeight方法使用时,它可以从质量和半径重新计算出来,该方法返回它在由常数表示的行星上的重量。

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

请注意,Planet和所有枚举一样,都有一个静态values方法,该方法以声明的顺序返回其值的数组。 另请注意,toString方法返回每个枚举值的声明名称,使printlnprintf可以轻松打印。 如果你对此字符串表示形式不满意,可以通过重写toString方法来更改它。 这是使用命令行参数185运行WeightTable程序(不重写toString)的结果:

Weight on MERCURY is 69.912739
Weight on VENUS is 167.434436
Weight on EARTH is 185.000000
Weight on MARS is 70.226739
Weight on JUPITER is 467.990696
Weight on SATURN is 197.120111
Weight on URANUS is 167.398264
Weight on NEPTUNE is 210.208751

直到2006年,在Java中加入枚举两年之后,冥王星不再是一颗行星。 这引发了一个问题:“当你从枚举类型中移除一个元素时会发生什么?”答案是,任何不引用移除元素的客户端程序都将继续正常工作。 所以,举例来说,我们的WeightTable程序只需要打印一行少一行的表格。 什么是客户端程序引用删除的元素(在这种情况下,Planet.Pluto)? 如果重新编译客户端程序,编译将会失败并在引用前一个星球的行处提供有用的错误消息; 如果无法重新编译客户端,它将在运行时从此行中引发有用的异常。 这是你所希望的最好的行为,远远好于你用int枚举模式得到的结果。

一些与枚举常量相关的行为只需要在定义枚举的类或包中使用。 这些行为最好以私有或包级私有方式实现。 然后每个常量携带一个隐藏的行为集合,允许包含枚举的类或包在与常量一起呈现时作出适当的反应。 与其他类一样,除非你有一个令人信服的理由将枚举方法暴露给它的客户端,否则将其声明为私有的,如果需要的话将其声明为包级私有(条目 15)。

如果一个枚举是广泛使用的,它应该是一个顶级类; 如果它的使用与特定的顶级类绑定,它应该是该顶级类的成员类(条目 24)。 例如,java.math.RoundingMode枚举表示小数部分的舍入模式。 BigDecimal类使用了这些舍入模式,但它们提供了一种有用的抽象,它并不与BigDecimal有根本的联系。 通过将RoundingMode设置为顶层枚举,类库设计人员鼓励任何需要舍入模式的程序员重用此枚举,从而提高跨API的一致性。

// Enum type that switches on its own value - questionable
public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;

    // Do the arithmetic operation represented by this constant
    public double apply(double x, double y) {
        switch(this) {
            case PLUS:   return x + y;
            case MINUS:  return x - y;
            case TIMES:  return x * y;
            case DIVIDE: return x / y;
        }
        throw new AssertionError("Unknown op: " + this);
    }
}

此代码有效,但不是很漂亮。 如果没有throw语句,就不能编译,因为该方法的结束在技术上是可达到的,尽管它永远不会被达到[JLS,14.21]。 更糟的是,代码很脆弱。 如果添加新的枚举常量,但忘记向switch语句添加相应的条件,枚举仍然会编译,但在尝试应用新操作时,它将在运行时失败。

幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply方法,并用常量特定的类主体中的每个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的方法实现:

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

如果向第二个版本的操作添加新的常量,则不太可能会忘记提供apply方法,因为该方法紧跟在每个常量声明之后。 万一忘记了,编译器会提醒你,因为枚举类型中的抽象方法必须被所有常量中的具体方法重写。

特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是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

枚举类型具有自动生成的valueOf(String)方法,该方法将常量名称转换为常量本身。 如果在枚举类型中重写toString方法,请考虑编写fromString方法将自定义字符串表示法转换回相应的枚举类型。 下面的代码(类型名称被适当地改变)将对任何枚举都有效,只要每个常量具有唯一的字符串表示形式:

// Implementing a fromString method on an enum type
private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(
            toMap(Object::toString, e -> e));

// Returns Operation for string, if any
public static Optional<Operation> fromString(String symbol) {
    return Optional.ofNullable(stringToEnum.get(symbol));
}

请注意,Operation枚举常量被放在stringToEnum的map中,它来自于创建枚举常量后运行的静态属性初始化。前面的代码在values()方法返回的数组上使用流(第7章);在Java 8之前,我们创建一个空的hashMap并遍历值数组,将字符串到枚举映射插入到map中,如果愿意,仍然可以这样做。但请注意,尝试让每个常量都将自己放入来自其构造方法的map中不起作用。这会导致编译错误,这是好事,因为如果它是合法的,它会在运行时导致NullPointerException。除了编译时常量属性(条目 34)之外,枚举构造方法不允许访问枚举的静态属性。此限制是必需的,因为静态属性在枚举构造方法运行时尚未初始化。这种限制的一个特例是枚举常量不能从构造方法中相互访问。

另请注意,fromString方法返回一个Optional<String>。 这允许该方法指示传入的字符串不代表有效的操作,并且强制客户端面对这种可能性(条目 55)。

特定于常量的方法实现的一个缺点是它们使得难以在枚举常量之间共享代码。 例如,考虑一个代表工资包中的工作天数的枚举。 该枚举有一个方法,根据工人的基本工资(每小时)和当天工作的分钟数计算当天工人的工资。 在五个工作日内,任何超过正常工作时间的工作都会产生加班费; 在两个周末的日子里,所有工作都会产生加班费。 使用switch语句,通过将多个case标签应用于两个代码片段中的每一个,可以轻松完成此计算:

// Enum that switches on its value to share code - questionable
enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY, SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minutesWorked, int payRate) {
        int basePay = minutesWorked * payRate;

        int overtimePay;
        switch(this) {
          case SATURDAY: case SUNDAY: // Weekend
            overtimePay = basePay / 2;
            break;
          default: // Weekday
            overtimePay = minutesWorked <= MINS_PER_SHIFT ?
              0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }

        return basePay + overtimePay;
    }
}

这段代码无可否认是简洁的,但从维护的角度来看是危险的。 假设你给枚举添加了一个元素,可能是一个特殊的值来表示一个假期,但忘记在switch语句中添加一个相应的case条件。 该程序仍然会编译,但付费方法会默默地为工作日支付相同数量的休假日,与普通工作日相同。

要使用特定于常量的方法实现安全地执行工资计算,必须为每个常量重复加班工资计算,或将计算移至两个辅助方法,一个用于工作日,另一个用于周末,并调用适当的辅助方法来自每个常量。 这两种方法都会产生相当数量的样板代码,大大降低了可读性并增加了出错机会。

通过使用执行加班计算的具体方法替换PayrollDay上的抽象overtimePay方法,可以减少样板。 那么只有周末的日子必须重写该方法。 但是,这与switch语句具有相同的缺点:如果在不重写overtimePay方法的情况下添加另一天,则会默默继承周日计算方式。

你真正想要的是每次添加枚举常量时被迫选择加班费策略。 幸运的是,有一个很好的方法来实现这一点。 这个想法是将加班费计算移入私有嵌套枚举中,并将此策略枚举的实例传递给PayrollDay枚举的构造方法。 然后,PayrollDay枚举将加班工资计算委托给策略枚举,从而无需在PayrollDay中实现switch语句或特定于常量的方法实现。 虽然这种模式不如switch语句简洁,但它更安全,更灵活:

// The strategy enum pattern
enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);


    private final PayType payType;


    PayrollDay(PayType payType) { this.payType = payType; }
    PayrollDay() { this(PayType.WEEKDAY); }  // Default


    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate);
    }


    // The strategy enum type
    private enum PayType {
        WEEKDAY {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 :
                  (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };


        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;


        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}

如果对枚举的switch语句不是实现常量特定行为的好选择,那么它们有什么好处呢?枚举类型的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常数相当。 枚举的一个小小的性能缺点是加载和初始化枚举类型存在空间和时间成本,但在实践中不太可能引人注意。

那么你应该什么时候使用枚举呢? 任何时候使用枚举都需要一组常量,这些常量的成员在编译时已知。 当然,这包括“天然枚举类型”,如行星,星期几和棋子。 但是它也包含了其它你已经知道编译时所有可能值的集合,例如菜单上的选项,操作代码和命令行标志。** 一个枚举类型中的常量集不需要一直保持不变**。 枚举功能是专门设计用于允许二进制兼容的枚举类型的演变。

总之,枚举类型优于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值相关的第二个枚举常量,则没有那么好运了。 例如,为双四重奏(double quartet)添加一个常量可能会很好,它就像八重奏一样,由8位演奏家组成,但是没有办法做到这一点。

此外,如果没有给所有这些int值添加常量,也不能为某个int值添加一个常量。例如,假设你想要添加一个常量,表示一个由12位演奏家组成的三重四重奏(triple quartet)。对于由11个演奏家组成的合奏曲,并没有标准的术语,因此你不得不为未使用的int值(11)添加一个虚拟常量(dummy constant)。最多看起来就是有些不好看。如果许多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; }

}

枚举规范对此ordinal方法说道:“大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如EnumSetEnumMap。“除非你在编写这样数据结构的代码,否则最好避免使用ordinal方法。

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枚举常量更难理解。 没有简单的方法遍历所有由位属性表示的元素。 最后,必须预测在编写API时需要的最大位数,并相应地为位属性(通常为int或long)选择一种类型。 一旦你选择了一个类型,你就不能超过它的宽度(32或64位)而不改变API。

一些程序员使用枚举优于int常量,当他们需要传递常量集合时仍然使用位属性。 没有理由这样做,因为存在更好的选择。 java.util包提供了EnumSet类来有效地表示从单个枚举类型中提取的值集合。 这个类实现了Set接口,提供了所有其他Set实现的丰富性,类型安全性和互操作性。 但是在内部,每个EnumSet都表示为一个位矢量(bit vector)。 如果底层的枚举类型有64个或更少的元素,并且大多数情况下,整个EnumSet用单个long表示,所以它的性能与位属性的性能相当。 批量操作(如removeAll和retainAll)是使用按位算术实现的,就像你为位属性手动操作一样。 但是完全避免了手动位混乱的丑陋和错误倾向:EnumSet为你做了很大的努力。

下面是前一个使用枚举和枚举集合替代位属性的示例。 它更短,更清晰,更安全:

// 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实例传递给applyStyles方法的客户端代码。 EnumSet类提供了一组丰富的静态工厂,可以轻松创建集合,其中一个代码如下所示:

text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

请注意,applyStyles方法采用Set<Style>而不是EnumSet<Style>参数。 尽管所有客户端都可能会将EnumSet传递给该方法,但接受接口类型而不是实现类型通常是很好的做法(条目 64)。 这允许一个不寻常的客户端通过其他Set实现的可能性。

总之,仅仅因为枚举类型将被用于集合中,所以没有理由用位属性来表示它EnumSet类将位属性的简洁性和性能与条目 34中所述的枚举类型的所有优点相结合。EnumSet的一个真正缺点是,它不像Java 9那样创建一个不可变的EnumSet,但是在即将发布的版本中可能会得到补救。 同时,你可以用Collections.unmodifiableSet封装一个EnumSet,但是简洁性和性能会受到影响。

37. 使用EnumMap替代序数索引

有时可能会看到使用ordinal方法(条目 35)来索引到数组或列表的代码。 例如,考虑一下这个简单的类来代表一种植物:

class Plant {
    enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
    final String name;
    final LifeCycle lifeCycle;

    Plant(String name, LifeCycle lifeCycle) {
        [this.name](http://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),程序需要一个未经检查的转换,并且不会干净地编译。 由于该数组不知道索引代表什么,因此必须手动标记索引输出。 但是这种技术最严重的问题是,当你访问一个由枚举序数索引的数组时,你有责任使用正确的int值; int不提供枚举的类型安全性。 如果你使用了错误的值,程序会默默地做错误的事情,如果你幸运的话,抛出一个ArrayIndexOutOfBoundsException异常。

有一个更好的方法来达到同样的效果。 该数组有效地用作从枚举到值的映射,因此不妨使用Map。 更具体地说,有一个非常快速的Map实现,设计用于枚举键,称为java.util.EnumMap。 下面是当程序重写为使用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构造方法接受键类型的Class对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息(条目 33)。

通过使用stream(条目 45)来管理Map,可以进一步缩短以前的程序。 以下是最简单的基于stream的代码,它们在很大程度上重复了前面示例的行为:

// Naive stream-based approach - unlikely to produce an EnumMap!

System.out.println(Arrays.stream(garden)

        .collect(groupingBy(p -> p.lifeCycle)));

这个代码的问题在于它选择了自己的Map实现,实际上它不是EnumMap,所以它不会与显式EnumMap的版本的空间和时间性能相匹配。 为了解决这个问题,使用Collectors.groupingBy的三个参数形式的方法,它允许调用者使用mapFactory参数指定map的实现:

// Using a stream and an EnumMap to associate data with an enum

System.out.println(Arrays.stream(garden)

        .collect(groupingBy(p -> p.lifeCycle,

() -> new EnumMap<>(LifeCycle.class), toSet()))); 

这样的优化在像这样的示例程序中是不值得的,但是在大量使用Map的程序中可能是至关重要的。

基于stream版本的行为与EmumMap版本的行为略有不同。 EnumMap版本总是为每个工厂生命周期生成一个嵌套map类,而如果花园包含一个或多个具有该生命周期的植物时,则基于流的版本才会生成嵌套map类。 因此,例如,如果花园包含一年生和多年生植物但没有两年生的植物,plantByLifeCycle的大小在EnumMap版本中为三个,在两个基于流的版本中为两个。

你可能会看到数组索引(两次)的数组,用序数来表示从两个枚举值的映射。例如,这个程序使用这样一个数组来映射两个阶段到一个阶段转换(phase transition)(液体到固体表示凝固,液体到气体表示沸腾等等):

// Using ordinal() to index array of arrays - DON'T DO THIS!

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()];

        }

    }

}

这段程序可以运行,甚至可能显得优雅,但外观可能是骗人的。 就像前面显示的简单的花园示例一样,编译器无法知道序数和数组索引之间的关系。 如果在转换表中出错或者在修改PhasePhase.Transition枚举类型时忘记更新它,则程序在运行时将失败。 失败可能是ArrayIndexOutOfBoundsExceptionNullPointerException或(更糟糕的)沉默无提示的错误行为。 即使非空条目的数量较小,表格的大小也是phase的个数的平方。

同样,可以用EnumMap做得更好。 因为每个阶段转换都由一对阶段枚举来索引,所以最好将关系表示为从一个枚举(from 阶段)到第二个枚举(to阶段)到结果(阶段转换)的map。 与阶段转换相关的两个阶段最好通过将它们与阶段转换枚举相关联来捕获,然后可以用它来初始化嵌套的EnumMap

// Using a nested EnumMap to associate data with enum pairs

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](http://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](http://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的代码有点复杂。map的类型是Map<Phase, Map<Phase, Transition>>,意思是“从(源)阶段映射到从(目标)阶段到阶段转换映射。”这个map的map使用两个收集器的级联序列进行初始化。 第一个收集器按源阶段对转换进行分组,第二个收集器使用从目标阶段到转换的映射创建一个EnumMap。 第二个收集器((x, y) -> y))中的合并方法未使用;仅仅因为我们需要指定一个map工厂才能获得一个EnumMap,并且Collectors提供伸缩式工厂,这是必需的。 本书的前一版使用显式迭代来初始化阶段转换map。 代码更详细,但可以更容易理解。

现在假设想为系统添加一个新阶段:等离子体或电离气体。 这个阶段只有两个转变:电离,将气体转化为等离子体; 和去离子,将等离子体转化为气体。 要更新基于数组的程序,必须将一个新的常量添加到Phase,将两个两次添加到Phase.Transition,并用新的十六个元素版本替换原始的九元素阵列数组。 如果向数组中添加太多或太少的元素或者将元素乱序放置,那么如果运气不佳:程序将会编译,但在运行时会失败。 要更新基于EnumMap的版本,只需将PLASMA添加到阶段列表中,并将IONIZE(GAS, PLASMA)DEIONIZE(PLASMA, GAS)添加到阶段转换列表中:

// Adding a new phase using the nested EnumMap implementation

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

    }

}

该程序会处理所有其他事情,并且几乎不会出现错误。 在内部,map的map是通过数组的数组实现的,因此在空间或时间上花费很少,以增加清晰度,安全性和易于维护。

为了简便起见,上面的示例使用null来表示状态更改的缺失(其从目标到源都是相同的)。这不是很好的实践,很可能在运行时导致NullPointerException。为这个问题设计一个干净、优雅的解决方案是非常棘手的,而且结果程序足够长,以至于它们会偏离这个条目的主要内容。

总之,使用序数来索引数组很不合适:改用EnumMap。 如果你所代表的关系是多维的,请使用EnumMap <...,EnumMap <... >>。 应用程序员应该很少使用Enum.ordinal(条目 35),如果使用了,也是一般原则的特例。

38. 使用接口模拟可扩展的枚举

在几乎所有方面,枚举类型都优于本书第一版中描述的类型安全模式[Bloch01]。 从表面上看,一个例外涉及可扩展性,这在原始模式下是可能的,但不受语言结构支持。 换句话说,使用该模式,有可能使一个枚举类型扩展为另一个; 使用语言功能特性,它不能这样做。 这不是偶然的。 大多数情况下,枚举的可扩展性是一个糟糕的主意。 令人困惑的是,扩展类型的元素是基类型的实例,反之亦然。 枚举基本类型及其扩展的所有元素没有好的方法。 最后,可扩展性会使设计和实现的很多方面复杂化。

也就是说,对于可扩展枚举类型至少有一个有说服力的用例,这就是操作码( operation codes),也称为opcodes。 操作码是枚举类型,其元素表示某些机器上的操作,例如条目 34中的Operation类型,它表示简单计算器上的功能。 有时需要让API的用户提供他们自己的操作,从而有效地扩展API提供的操作集。

幸运的是,使用枚举类型有一个很好的方法来实现这种效果。基本思想是利用枚举类型可以通过为opcode类型定义一个接口,并实现任意接口。例如,这里是来自条目 34的Operation类型的可扩展版本:

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

虽然枚举类型(BasicOperation)不可扩展,但接口类型(Operation)是可以扩展的,并且它是用于表示API中的操作的接口类型。 你可以定义另一个实现此接口的枚举类型,并使用此新类型的实例来代替基本类型。 例如,假设想要定义前面所示的操作类型的扩展,包括指数运算和余数运算。 你所要做的就是编写一个实现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),现在就可以在任何可以使用基本操作的地方使用新操作。请注意,不必在枚举中声明apply抽象方法,就像您在具有实例特定方法实现的非扩展枚举中所做的那样(第162页)。 这是因为抽象方法(apply)是接口(Operation)的成员。

不仅可以在任何需要“基本枚举”的地方传递“扩展枚举”的单个实例,而且还可以传入整个扩展枚举类型,并使用其元素。 例如,这里是第163页上的一个测试程序版本,它执行之前定义的所有扩展操作:

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),而不是传递了一个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));
}

生成的代码稍微不那么复杂,test方法灵活一点:它允许调用者将多个实现类型的操作组合在一起。另一方面,也放弃了在指定操作上使用EnumSet(条目 36)和EnumMap(条目 37)的能力。

上面的两个程序在运行命令行输入参数4和2时生成以下输出:

4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000

使用接口来模拟可扩展枚举的一个小缺点是,实现不能从一个枚举类型继承到另一个枚举类型。如果实现代码不依赖于任何状态,则可以使用默认实现(条目 20)将其放置在接口中。在我们的Operation示例中,存储和检索与操作关联的符号的逻辑必须在BasicOperationExtendedOperation中重复。在这种情况下,这并不重要,因为很少的代码是冗余的。如果有更多的共享功能,可以将其封装在辅助类或静态辅助方法中,以消除代码冗余。

该条目中描述的模式在Java类库中有所使用。例如,java.nio.file.LinkOption枚举类型实现了CopyOptionOpenOption接口。

总之,虽然不能编写可扩展的枚举类型,但是你可以编写一个接口来配合实现接口的基本的枚举类型,来对它进行模拟。这允许客户端编写自己的枚举(或其它类型)来实现接口。如果API是根据接口编写的,那么在任何使用基本枚举类型实例的地方,都可以使用这些枚举类型实例。

39. 注解优于命名模式

过去,通常使用命名模式( naming patterns)来指示某些程序元素需要通过工具或框架进行特殊处理。 例如,在第4版之前,JUnit测试框架要求其用户通过以test[Beck04]开始名称来指定测试方法。 这种技术是有效的,但它有几个很大的缺点。 首先,拼写错误导致失败,但不会提示。 例如,假设意外地命名了测试方法tsetSafetyOverride而不是testSafetyOverride。 JUnit 3不会报错,但它也不会执行测试,导致错误的安全感。

命名模式的第二个缺点是无法确保它们仅用于适当的程序元素。 例如,假设调用了TestSafetyMechanisms类,希望JUnit 3能够自动测试其所有方法,而不管它们的名称如何。 同样,JUnit 3也不会出错,但它也不会执行测试。

命名模式的第三个缺点是它们没有提供将参数值与程序元素相关联的好的方法。 例如,假设想支持只有在抛出特定异常时才能成功的测试类别。 异常类型基本上是测试的一个参数。 你可以使用一些精心设计的命名模式将异常类型名称编码到测试方法名称中,但这会变得丑陋和脆弱(条目 62)。 编译器无法知道要检查应该命名为异常的字符串是否确实存在。 如果命名的类不存在或者不是异常,那么直到尝试运行测试时才会发现。

注解[JLS,9.7]很好地解决了所有这些问题,JUnit从第4版开始采用它们。在这个项目中,我们将编写我们自己的测试框架来显示注解的工作方式。 假设你想定义一个注解类型来指定自动运行的简单测试,并且如果它们抛出一个异常就会失败。 以下是名为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注解类型的声明本身使用RetentionTarget注解进行标记。 注解类型声明上的这种注解称为元注解。 @Retention(RetentionPolicy.RUNTIME)元注解指示Test注解应该在运行时保留。 没有它,测试工具就不会看到Test注解。@Target.get(ElementType.METHOD)元注解表明Test注解只对方法声明合法:它不能应用于类声明,属性声明或其他程序元素。

在Test注解声明之前的注释说:“仅在无参静态方法中使用”。如果编译器可以强制执行此操作是最好的,但它不能,除非编写注解处理器来执行此操作。 有关此主题的更多信息,请参阅javax.annotation.processing文档。 在缺少这种注解处理器的情况下,如果将Test注解放在实例方法声明或带有一个或多个参数的方法上,那么测试程序仍然会编译,并将其留给测试工具在运行时来处理这个问题 。

以下是Test注解在实践中的应用。 它被称为标记注解,因为它没有参数,只是“标记”注解元素。 如果程序员错拼Test或将Test注解应用于程序元素而不是方法声明,则该程序将无法编译。

// Program containing marker annotations

public class Sample {

    @Test public static void m1() { }  // 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注解标注的四种方法将被测试工具忽略。

Test注解对Sample类的语义没有直接影响。 他们只提供信息供相关程序使用。 更一般地说,注解不会改变注解代码的语义,但可以通过诸如这个简单的测试运行器等工具对其进行特殊处理:

// 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中提取的。

如果尝试通过反射调用测试方法会抛出除InvocationTargetException之外的任何异常,则表示编译时未捕获到没有使用的Test注解。 这些用法包括注解实例方法,具有一个或多个参数的方法或不可访问的方法。 测试运行器中的第二个catch块会捕获这些Test使用错误并显示相应的错误消息。 这是在RunTestsSample上运行时打印的输出:

public static void Sample.m3() failed: RuntimeException: Boom

Invalid @Test: public void Sample.m5()

public static void Sample.m7() failed: RuntimeException: Crash

Passed: 1, Failed: 3

现在,让我们添加对仅在抛出特定异常时才成功的测试的支持。 我们需要为此添加一个新的注解类型:

// 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)。 以下是注解在实践中的例子。 请注意,类名字被用作注解参数值:

// Program containing annotations with a parameter

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)

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

    }

}

此代码与我们用于处理Test注解的代码类似,只有一个例外:此代码提取注解参数的值并使用它来检查测试引发的异常是否属于正确的类型。 没有明确的转换,因此没有ClassCastException的危险。 测试程序编译的事实保证其注解参数代表有效的异常类型,但有一点需要注意:如果注解参数在编译时有效,但代表指定异常类型的类文件在运行时不再存在,则测试运行器将抛出TypeNotPresentException异常。

将我们的异常测试示例进一步推进,可以设想一个测试,如果它抛出几个指定的异常中的任何一个,就会通过测试。 注解机制有一个便于支持这种用法的工具。 假设我们将ExceptionTest注解的参数类型更改为Class对象数组:

// Annotation type with an array parameter

@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.METHOD)

public @interface ExceptionTest {

    Class<? extends Exception>[] 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);

}

修改测试运行器工具以处理新版本的ExceptionTest是相当简单的。 此代码替换原始版本:

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元注解来标示注解的声明,而不用使用数组参数声明注解类型,以指示注解可以重复应用于单个元素。 该元注解采用单个参数,该参数是包含注解类型的类对象,其唯一参数是注解类型[JLS,9.6.3]的数组。 如果我们使用ExceptionTest注解采用这种方法,下面是注解的声明。 请注意,包含注解类型必须使用适当的保留策略和目标进行注解,否则声明将无法编译:

// Repeatable annotation type

@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测试用一个重复的注解代替基于数组值注解的方式:

// Code containing a repeated annotation

@ExceptionTest(IndexOutOfBoundsException.class)

@ExceptionTest(NullPointerException.class)

public static void doublyBad() { ... }

处理可重复的注解需要注意。重复注解会生成包含注解类型的合成注解。 getAnnotationsByType方法掩盖了这一事实,可用于访问可重复注解类型和非重复注解。但isAnnotationPresent明确指出重复注解不是注解类型,而是包含注解类型。如果某个元素具有某种类型的重复注解,并且使用isAnnotationPresent方法检查元素是否具有该类型的注释,则会发现它没有。使用此方法检查注解类型的存在会因此导致程序默默忽略重复的注解。同样,使用此方法检查包含的注解类型将导致程序默默忽略不重复的注释。要使用isAnnotationPresent检测重复和非重复的注解,需要检查注解类型及其包含的注解类型。以下是RunTests程序的相关部分在修改为使用ExceptionTest注解的可重复版本时的例子:

// Processing repeatable annotations

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

    }

}

添加了可重复的注解以提高源代码的可读性,从逻辑上将相同注解类型的多个实例应用于给定程序元素。 如果觉得它们增强了源代码的可读性,请使用它们,但请记住,在声明和处理可重复注解时存在更多的样板,并且处理可重复的注解很容易出错。

这个项目中的测试框架只是一个演示,但它清楚地表明了注解相对于命名模式的优越性,而且它仅仅描绘了你可以用它们做什么的外观。 如果编写的工具要求程序员将信息添加到源代码中,请定义适当的注解类型。当可以使用注解代替时,没有理由使用命名模式

这就是说,除了特定的开发者(toolsmith)之外,大多数程序员都不需要定义注解类型。 但所有程序员都应该使用Java提供的预定义注解类型(条目40,27)。 另外,请考虑使用IDE或静态分析工具提供的注解。 这些注解可以提高这些工具提供的诊断信息的质量。 但请注意,这些注解尚未标准化,因此如果切换工具或标准出现,可能额外需要做一些工作。

40. 始终使用Override注解

Java类库包含几个注解类型。对于典型的程序员来说,最重要的是@Override。此注解只能在方法声明上使用,它表明带此注解的方法声明重写了父类的声明。如果始终使用这个注解,它将避免产生大量的恶意bug。考虑这个程序,在这个程序中,类Bigram表示双字母组合,或者是有序的一对字母:

// 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,而是260。它有什么问题?

显然,Bigram类的作者打算重写equals方法(条目 10),甚至记得重写hashCode(条目 11)。 不幸的是,我们倒霉的程序员没有重写equals,而是重载它(条目 52)。 要重写Object.equals,必须定义一个equals方法,其参数的类型为Object,但Bigram的equals方法的参数不是Object类型的,因此Bigram继承Object的equals方法,这个equals方法测试对象的引用是否是同一个,就像==运算符一样。 每个祖母组合的10个副本中的每一个都与其他9个副本不同,所以它们被Object.equals视为不相等,这就解释了程序打印260的原因。

幸运的是,编译器可以帮助你找到这个错误,但只有当你通过告诉它你打算重写Object.equals来帮助你。 要做到这一点,用@Override注解Bigram.equals方法,如下所示:

@Override public boolean equals(Bigram b) {

    return b.first == first && b.second == second;

}

如果插入此注解并尝试重新编译该程序,编译器将生成如下错误消息:

Bigram.java:10: method does not override or implement a method

from a supertype

    @Override public boolean equals(Bigram b) {

    ^

你会立刻意识到你做错了什么,在额头上狠狠地打了一下,用一个正确的(条目 10)来替换出错的equals实现:

@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注解放在该方法上。 在没有声明为抽象的类中,如果无法重写抽象父类方法,编译器将发出错误消息。 但是,你可能希望关注类中所有重写父类方法的方法,在这种情况下,也应该随时注解这些方法。 大多数IDE可以设置为在选择重写方法时自动插入Override注解。

大多数IDE提供了是种使用Override注解的另一个理由。 如果启用适当的检查功能,如果有一个方法没有Override注解但是重写父类方法,则IDE将生成一个警告。 如果始终使用Override注解,这些警告将提醒你无意识的重写。 它们补充了编译器的错误消息,这些消息会提醒你无意识重写失败。 IDE和编译器,可以确保你在任何你想要的地方和其他地方重写方法,万无一失。

Override注解可用于重写来自接口和类的方法声明。 随着default默认方法的出现,在接口方法的具体实现上使用Override以确保签名是正确的是一个好习惯。 如果知道某个接口没有默认方法,可以选择忽略接口方法的具体实现上的Override注解以减少混乱。

然而,在一个抽象类或接口中,值得标记的是你认为重写父类或父接口方法的所有方法,无论是具体的还是抽象的。 例如,Set接口不会向Collection接口添加新方法,因此它应该在其所有方法声明中包含Override注解以确保它不会意外地向Collection接口添加任何新方法。

总之,如果在每个方法声明中使用Override注解,并且认为要重写父类声明,那么编译器可以保护免受很多错误的影响,但有一个例外。 在具体的类中,不需要注解标记你确信可以重写抽象方法声明的方法(尽管这样做也没有坏处)。

41.使用标记接口定义类型

标记接口(marker interface),不包含方法声明,只是指定(或“标记”)一个类实现了具有某些属性的接口。 例如,考虑Serializable接口(第12章)。 通过实现这个接口,一个类表明它的实例可以写入ObjectOutputStream(或“序列化”)。

你可能会听说过标记注解(条目 39)标记一个接口是废弃过时的。 这个断言是不正确的。 标记接口与标记注解相比具有两个优点。 首先,标记接口定义了一个由标记类实例实现的类型;标记注解则不会。 标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。

Java的序列化机制(第6章)使用Serializable标记接口来指示某个类型是可序列化的。 对传递给它的对象进行序列化的ObjectOutputStream.writeObject方法要求其参数可序列化。 如果此方法的参数是Serializable类型,则在编译时会检测到序列化不适当对象的尝试(通过类型检查)。 编译时错误检测是标记接口的意图,但不幸的是,ObjectOutputStream.write API没有利用Serializable接口:它的参数被声明为Object类型,所以尝试序列化一个不可序列化的对象直到运行时才会失败。

标记接口对于标记注解的另一个优点是可以更精确地定位目标。 如果使用目标ElementType.TYPE声明注解类型,它可以应用于任何类或接口。 假设有一个标记仅适用于特定接口的实现。 如果将其定义为标记接口,则可以扩展它适用的唯一接口,保证所有标记类型也是适用的唯一接口的子类型。

可以说,Set接口就是这样一个受限的标记接口。 它仅适用于Collection子类型,但不会添加超出Collection定义的方法。 它通常不被认为是标记接口,因为它改进了几个Collection方法的契约,包括add,equals和hashCode。 但很容易想象一个标记接口,它仅适用于某些特定接口的子类型,并且不会改进任何接口方法的契约。 这样的标记接口可以描述整个对象的一些约束条件(invariant),或者说明实例有资格被某个其他类的方法处理(就像Serializable接口指示实例有资格被ObjectOutputStream处理的方式)。

标记注解优于标记接口的主要优点是它们是较大的注解工具的一部分。因此,标记注解允许在基于注解的框架中保持一致性。

所以什么时候应该使用标记注解,什么时候应该使用标记接口?显然,如果标记适用于除类或接口以外的任何程序元素,则必须使用注解,因为只能使用类和接口来实现或扩展接口。如果标记仅适用于类和接口,那么问自己问题:“可能我想编写一个或多个只接受具有此标记的对象的方法呢?”如果是这样,则应该优先使用标记接口而不是注解。这将使你可以将接口用作所讨论方法的参数类型,这将带来编译时类型检查的好处。如果你能说服自己,永远不会想写一个只接受带有标记的对象的方法,那么最好使用标记注解。另外,如果标记是大量使用注解的框架的一部分,则标记注解是明确的选择。

总之,标记接口和标记注释都有其用处。 如果你想定义一个没有任何关联的新方法的类型,一个标记接口是一种可行的方法。 如果要标记除类和接口以外的程序元素,或者将标记符合到已经大量使用注解类型的框架中,那么标记注解是正确的选择。 如果发现自己正在编写目标为ElementType.TYPE的标记注解类型,请花点时间确定它是否应该是注释类型,是不是标记接口是否更合适

从某种意义来说,本条目与条目22的的意思正好相反,条目22的意思是:“如果你不想定义一个类型,不要使用接口”。本条目的意思是:如果想定义一个类型,一定要使用接口。

转自:www.jianshu.com/p/f3ec41f47…