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

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

      这种被称为 int 枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 如果你将一个 Apple 传递给一个需要 Orange 的方法,那么编译器不会出现警告,还会用 == 运算符比较 AppleOrange,或者更糟糕的是:

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

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

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

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

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

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

    1. public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
    2. 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 章),并针对枚举类型的可任意改变性设计了序列化方式。

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

    1. // Enum type with data and behavior
    2. public enum Planet {
    3. MERCURY(3.302e+23, 2.439e6),
    4. VENUS (4.869e+24, 6.052e6),
    5. EARTH (5.975e+24, 6.378e6),
    6. MARS (6.419e+23, 3.393e6),
    7. JUPITER(1.899e+27, 7.149e7),
    8. SATURN (5.685e+26, 6.027e7),
    9. URANUS (8.683e+25, 2.556e7),
    10. NEPTUNE(1.024e+26, 2.477e7);
    11. private final double mass; // In kilograms
    12. private final double radius; // In meters
    13. private final double surfaceGravity; // In m / s^2
    14. // Universal gravitational constant in m^3 / kg s^2
    15. private static final double G = 6.67300E-11;
    16. // Constructor
    17. Planet(double mass, double radius) {
    18. this.mass = mass;
    19. this.radius = radius;
    20. surfaceGravity = G * mass / (radius * radius);
    21. }
    22. public double mass() { return mass; }
    23. public double radius() { return radius; }
    24. public double surfaceGravity() { return surfaceGravity; }
    25. public double surfaceWeight(double mass) {
    26. return mass * surfaceGravity; // F = ma
    27. }
    28. }

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

      虽然 Planet 枚举很简单,但它的功能非常强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在所有八个行星上的重量(以相同单位):

    1. public class WeightTable {
    2. public static void main(String[] args) {
    3. double earthWeight = Double.parseDouble(args[0]);
    4. double mass = earthWeight / Planet.EARTH.surfaceGravity();
    5. for (Planet p : Planet.values())
    6. System.out.printf("Weight on %s is %f%n",
    7. p, p.surfaceWeight(mass));
    8. }
    9. }

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

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

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

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

    1. // Enum type that switches on its own value - questionable
    2. public enum Operation {
    3. PLUS, MINUS, TIMES, DIVIDE;
    4. // Do the arithmetic operation represented by this constant
    5. public double apply(double x, double y) {
    6. switch(this) {
    7. case PLUS: return x + y;
    8. case MINUS: return x - y;
    9. case TIMES: return x * y;
    10. case DIVIDE: return x / y;
    11. }
    12. throw new AssertionError("Unknown op: " + this);
    13. }

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

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

    1. // Enum type with constant-specific method implementations
    2. public enum Operation {
    3. PLUS {public double apply(double x, double y){return x + y;}},
    4. MINUS {public double apply(double x, double y){return x - y;}},
    5. TIMES {public double apply(double x, double y){return x * y;}},
    6. DIVIDE{public double apply(double x, double y){return x / y;}};
    7. public abstract double apply(double x, double y);
    8. }

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

      特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是 Operation 的一个版本,它重写 toString 方法以返回通常与该操作关联的符号:

    1. // Enum type with constant-specific class bodies and data
    2. public enum Operation {
    3. PLUS("+") {
    4. public double apply(double x, double y) { return x + y; }
    5. },
    6. MINUS("-") {
    7. public double apply(double x, double y) { return x - y; }
    8. },
    9. TIMES("*") {
    10. public double apply(double x, double y) { return x * y; }
    11. },
    12. DIVIDE("/") {
    13. public double apply(double x, double y) { return x / y; }
    14. };
    15. private final String symbol;
    16. Operation(String symbol) { this.symbol = symbol; }
    17. @Override public String toString() { return symbol; }
    18. public abstract double apply(double x, double y);
    19. }

      显示的 toString 实现可以很容易地打印算术表达式,正如这个小程序所展示的那样:

    1. public static void main(String[] args) {
    2. double x = Double.parseDouble(args[0]);
    3. double y = Double.parseDouble(args[1]);
    4. for (Operation op : Operation.values())
    5. System.out.printf("%f %s %f = %f%n",
    6. x, op, y, op.apply(x, y));
    7. }

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

    1. // Implementing a fromString method on an enum type
    2. private static final Map<String, Operation> stringToEnum =
    3. Stream.of(values()).collect(
    4. toMap(Object::toString, e -> e));
    5. // Returns Operation for string, if any
    6. public static Optional<Operation> fromString(String symbol) {
    7. return Optional.ofNullable(stringToEnum.get(symbol));
    8. }

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

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

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

    1. // Enum that switches on its value to share code - questionable
    2. enum PayrollDay {
    3. MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    4. SATURDAY, SUNDAY;
    5. private static final int MINS_PER_SHIFT = 8 * 60;
    6. int pay(int minutesWorked, int payRate) {
    7. int basePay = minutesWorked * payRate;
    8. int overtimePay;
    9. switch(this) {
    10. case SATURDAY: case SUNDAY: // Weekend
    11. overtimePay = basePay / 2;
    12. break;
    13. default: // Weekday
    14. overtimePay = minutesWorked <= MINS_PER_SHIFT ?
    15. 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
    16. }
    17. return basePay + overtimePay;
    18. }
    19. }

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

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

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

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

    1. // The strategy enum pattern
    2. enum PayrollDay {
    3. MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    4. SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
    5. private final PayType payType;
    6. PayrollDay(PayType payType) { this.payType = payType; }
    7. PayrollDay() { this(PayType.WEEKDAY); } // Default
    8. int pay(int minutesWorked, int payRate) {
    9. return payType.pay(minutesWorked, payRate);
    10. }
    11. // The strategy enum type
    12. private enum PayType {
    13. WEEKDAY {
    14. int overtimePay(int minsWorked, int payRate) {
    15. return minsWorked <= MINS_PER_SHIFT ? 0 :
    16. (minsWorked - MINS_PER_SHIFT) * payRate / 2;
    17. }
    18. },
    19. WEEKEND {
    20. int overtimePay(int minsWorked, int payRate) {
    21. return minsWorked * payRate / 2;
    22. }
    23. };
    24. abstract int overtimePay(int mins, int payRate);
    25. private static final int MINS_PER_SHIFT = 8 * 60;
    26. int pay(int minsWorked, int payRate) {
    27. int basePay = minsWorked * payRate;
    28. return basePay + overtimePay(minsWorked, payRate);
    29. }
    30. }
    31. }

      如果对枚举的 switch 语句不是实现常量特定行为的好选择,那么它们有什么好处呢?枚举类型的 switch 有利于用常量特定的行为增加枚举类型。例如,假设 Operation 枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。你可以用以下静态方法模拟效果:

    1. // Switch on an enum to simulate a missing method
    2. public static Operation inverse(Operation op) {
    3. switch(op) {
    4. case PLUS: return Operation.MINUS;
    5. case MINUS: return Operation.PLUS;
    6. case TIMES: return Operation.DIVIDE;
    7. case DIVIDE: return Operation.TIMES;
    8. default: throw new AssertionError("Unknown op: " + op);
    9. }

      如果某个方法不属于枚举类型,则还应该在你控制的枚举类型上使用此技术。 该方法可能需要用于某些用途,但通常不足以用于列入枚举类型。

      一般而言,枚举通常在性能上与 int 常数相当。 枚举的一个小小的性能缺点是加载和初始化枚举类型存在空间和时间成本,但在实践中不太可能引人注意。

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