第三十四条:用enum代替int常量【枚举和注解start】

2021-02-19  本文已影响0人  taogan

Java支持两种特殊用途的引用类型,一种是枚举类型;一种是接口,称作注解类型。本章将讨论这两个新类型的最佳使用实践。

枚举类型(enum)是指由一组固定的常量组成合法值的类型,例如一年中的季节、太阳系中的行星或者一副牌中的花色。在Java中编程语言引入枚举类型之前,通常是用一组int常量来表示枚举类型,其中每一个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枚举模式(int enum pattern),它存在着很多不足。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_MERCURY和PLANET_MERCURY避免名称冲突。

采用int枚举模式的程序是十分脆弱的。因为int枚举是编译时常量,它们的int值会被编译到使用它们的客户端中。如果于int枚举常量关联的值发生了变化,客户端必须重新编译。如果没有重新编译,客户端程序还是可以运行,不过其行为已经不再准确

很难将int枚举常量转换成可打印的字符串。就算将这种常量打印出来,或者从调式器中将它显示出来,你所见到的也只是一个数字,这几乎没有什么用处。当需要遍历一个int枚举模式中的所有常量,以及获得int枚举数组的大小时,在int模式中,几乎不存在可靠的方式。

这种模式还有一种变体,它使用的是String常量,而不是int常量。这样的变体被称作String枚举模式,同样也不是我们期望的。它虽然为这些常量提供了可打印的字符串,但是会导致初级用户直接把字符串常量硬编码到客户端代码中,而不是使用对应的常量字段(field)名。一旦这样的硬编码字符串常量中包含书写错误,在编译时不会被检测到,但是在运行的时候却会报错。而且它会导致性能问题,因为它依赖于字符串的比较操作。

幸运的是,Java提供了另一种替代的解决方案,可以避免int和String枚举模式的缺点,并提供更多的好处。这就是枚举类型。下面以最简单的形式演示了这种模式:

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

表面上看来,这些枚举类型于其他语言中的没有什么两样,例如C,C++和C#,但是实际上并非如此。Java的枚举类型是功能十分齐全的类,其功能比其他语言中的对应类强大得多,Java的枚举本质上是int值。

Java枚举类型的基本想法非常简单:这些类通过共有的静态final域为每个枚举常量导出一个实例。枚举类型没有可访问的构造器,所以它是真正的final类。客户端不能创建枚举类型的实例。也不能对它进行扩展,因此不存在实例,而只存在声明过的枚举常量。换句话说,枚举类型是实例受控的(详见第1条)。它们是单例(详见第3条)的泛型化,本质上是单元素的枚举。

枚举类型保证了编译时的类型安全。例如声明参数的类型为Apple,它就能保证传到该参数上的任何非空的对象引用一定属于三个有效的Apple值之一,而其他任何试图传递类型错误的值都会导致编译错误,就像试图将某种枚举类型的表达式赋给另一个枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值都会导致编译时错误。

包含同名常量的多个枚举类型可以在一个系统中和平共处,因为每个类型都有自己的命名空间。你可以增加或者重新排列枚举类型中的常量,而无须重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式之中。最终,可以通过调用toString方法,将枚举转换成可打印的字符串。

除了完善int枚举模式的不足之外,枚举类型还允许添加任意的方法和域,并实现任意的接口。它们提供了所有的Object方法(详见第3章)的高级实现,实现了Comparable(详见第14条)和Serializable接口(详见第12章),并针对枚举类型的可任意改变性设计了序列化方式

那么我们为什么要向枚举类型中添加方法或者域呢?首先,可能是想将数据与它的常量关联起来。例如,一个能够返回水果颜色或者返回水果图片的方法,对于我们的Apple和Orange类型就很有必要。你可以利用任何适当的方法来增强枚举类型。枚举类型可以先作为枚举常量的一个简单集合,随着时间的推移再演变成为全功能的抽象。

举个有关枚举的例子,比如太阳系中的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 这样的枚举类型并不难。为了将数据与枚举常量关联起来,得声明实例域,并编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域都应该为 final 的(详见第 17 条) 它们可以是公有的,但最好将它们做成私有的,并提供公有的访问方法(详见第 16 条) Planet 这个示例中,构造器还计算和保存表面重力,但这正是一种优化 每当 surfaceWeight 方法用到重力时,都会根据质量和半径重新计算,并返回它在该常量所表示的行星上的重量。

虽然 Planet 枚举很简单,但它的功能强大得出奇。下面是一个简短的程序,根据某个物体在地球上的重量(以任何单位 ), 打印出 一张很棒 表格,显示出该物体在所有8颗行星上的重量(用相同的单位):

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 方法返回每个枚举值的声明名称 ,使得 println和printf 的打印变得更加容易。如果你不满意这种字符串表示法,可以通过覆盖 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 中增加了枚举的两年之后,当时冥王星 Pluto 还属于行星。这引发出一个问题:当把一个元素从一个枚举类型中移除时,会发生什么情况呢?答案是:没有引用该元素的任何客户端程序都会继续正常工作。因此,我 WeightTable 程序只会打印出一个少了一行 表格而已。对于引用了被删除元素(如本例中是指 Planet.Pluto)的客户端程序又如何呢?如果重新编译客户端程序 ,就会失败 ,并在引用被删除行 的那一条出现一条错误消息;如果没有重新编译客户端代码,在运行时就会在这 行抛出一个异常。这是你能期待的最佳行为了,远比使用 int 枚举模式时要好得多。

有些与枚举常量相关的行为,可能只会用在枚举类型的定义类或者所在的包中,那么这些方法最好被实现成私有的或者包级私有的。于是每个枚举常量都带有一组隐藏的行为,这使得枚举类型的类或者所在的包能够运作得很好,像其他的类一样,除非要将枚举方法导出至它的客户端,否则都应该声明为私有的,或者声明为包级私有的(详见第15条)。

如果一个枚举具有普遍适用性,它就应该成为 个顶层类( top-level class );如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类(详见第24条)。例如,java.math.RoundingMode 枚举表示十进制小数的舍入模式( rounding mode)。这些舍入模式被用于 BigDecirnal 类,但是它们却不属于 BigDecirnal 类的一个抽象。通过RoundingMode 变成一个顶层类,库的设计者鼓励任何需要舍入模式的程序员重用这个枚举,从而增强 API 间的一致性。

Planet 示例中所示的方法对于大多数枚举类型来说就足够了,但有时候我们会需要更多的方法 每个 Planet 常量关联了不同的数据,但你有时需要将不同的行为( behavior)与每个常量关联起来。例如,假设你在编写一个枚举类型,来表示计算器的四大基本操作(即加减乘除),你想要提供一个方法来执行每个常量所表示的算术运算。有一种方法是通过启用枚举的值来实现:

// 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语句,它就不能进行编译,虽然从技术角度来看代码的结束部分是可以执行到的,但是实际上是不可能执行到这行代码的。更糟糕的是,这段代码很脆。 如果你添加了新的枚举常量 ,却忘记给switch 加相应的条件,枚举仍然可以编译,但是当你试图运用新的运算时,就会运行失败。

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

// 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的第二个版本添加新的常量,你就不可能会忘记提供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的映射中。前面的代码在values()方法返回数组上使用流(见第7章);在Java8之前,我们将创建一个空的散列映射并遍历values数组,将字符串到枚举的映射插入到映射中,当然,如果你愿意,现在仍然可以这么做。但是,试图使每个常量都从自己的构造器将自身放入到映射中是不起作用的。它会导致编译时错误,这是好事,因为如果这是合法的,可能会引发NullPointerException异常。除了编译时常量域(见第34条)之外,枚举构造器不可能访问枚举的静态域。这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。这条限制有一个特例:枚举常量无法通过其构造器访问另一个构造器

还要注意返回Optional<Operation>的fromString方法。它用该方法声明:传入的字符串并不代表一项有效的操作,并强制客户端面对这种可能性(详见第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; 
  }
}

不可否认,这段代码十分简洁,但是从维护的角度来看,他非常危险。假设将一个元素添加到该枚举中,或许是一个表示假期天数的特殊值,但是忘记给swicth语句添加相应的case。程序依然可以编译,但pay方法会悄悄地将节假日的工资计算成正常工作日的工资。

为了利用特定于常量的方法实现安全的执行工资计算,你可能必须重新计算每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算节假日),并从每个常量调用相应的辅助方法。任何一种方法都会产生相当数量的样板代码,这会降低可读性,并增加了出错的概率。

通过用计算工作日加班的具体方法来代替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常量相比,枚举有个小小的性能缺点,即装载和初始化枚举时会需要空间和时间的成本,但在实践中几乎注意不到这个问题

那么什么时候应该使用枚举呢?每当需要一组固定常量,并且在编译时就知道其成员的时候,就应该使用枚举。当然,这包括“天然的枚举类型”,例如行星、一周的天数以及棋子的数目等。但它也包括你在编译时就知道其所有可能值的其他集合,例如菜单的选型、操作代码以及命令行标记等。枚举类型中的常量集并不一定要始终保持不变。专门设计枚举特性是考虑到枚举类型的二进制兼容演变。

总而言之,与int常量相比,枚举类型的优势是不言而喻的。枚举的可读性更好,也更加安全、功能更加强大。许多枚举都不需要显式的构造器或者成员,但许多其他枚举则收益于属性与每个常量的关联以及其行为受到该属性影响的方法。只有极少数的枚举受益于将多种行为与单个方法关联。在这种相对少见的情况下,特定于常量的方法要优先于启用自有值的枚举。如果多个(但非所有)枚举常量同时共享相同的行为,则要考虑策略枚举

上一篇 下一篇

猜你喜欢

热点阅读