美文网首页
ITEM 34: 使用Enums 而不是 int 常量

ITEM 34: 使用Enums 而不是 int 常量

作者: rabbittttt | 来源:发表于2019-07-25 18:29 被阅读0次

    ITEM 34: USE ENUMS INSTEAD OF INT CONSTANTS
      枚举类型是一种合法值由一组固定的常量组成的类型,如一年中的季节、太阳系中的行星或一副扑克牌中的花色。在将枚举类型添加到语言之前,表示枚举类型的一种常见模式是声明一组 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 枚举模式的技术有很多缺点:它在类型安全方面没有提供任何东西,在表达能力方面也没有提供任何东西。如果你传递一个苹果给期望一个橙子的方法、使用==操作符比较苹果与橘子,编译器不会提示你,或者更糟的是:

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

      注意,每个 apple 常量的名称都以 APPLE_ 作为前缀,每个 orange 常量的名称都以 ORANGE_ 作为前缀。这是因为 Java 没有为 int enum组提供名称空间。当两个 int 枚举组具有相同名称的常量时,前缀可以防止名称冲突,例如 ELEMENT_MERCURY 和 PLANET_MERCURY之间。
      使用 int 枚举的程序很脆弱,因为 int 枚举是常量[JLS, 4.12.4],所以它们的 int 值在编译时会在客户端代码中被替换[JLS, 13.1]。如果更改了与 int 枚举关联的值,则必须重新编译它的客户机。如果没有,客户机将仍然运行,但是它们的行为将是不正确的。
      没有简单的方法可以将 int 枚举常量转换成可打印字符串。如果您打印这样一个常量,或者从调试器中显示它,您所看到的只是一个数字,这不是很有用。没有可靠的方法可以遍历组中的所有int枚举常量,甚至无法获得一个 int enum 组的大小。
      您可能会遇到这种模式的变体,其中字符串常量用于替换int常量。这种称为字符串枚举模式的变体就更不可取了。虽然它确实为其常量提供了可打印的字符串,但它可能会导致天真的用户将字符串常量硬编码到客户机代码中,而不是使用字段名。如果这样一个硬编码的字符串常量包含排版错误,它将在编译时逃避检测,并在运行时导致错误。此外,它可能会导致性能问题,因为它依赖于字符串比较。
      幸运的是,Java提供了一种替代方法,它避免了 int 和 string 枚举模式的所有缺点,并提供了许多额外的好处。它是 Enum 类型[JLS, 8.9]。下面是它最简单的形式:

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

      从表面上看,这些枚举类型可能看起来与其他语言类似,比如C、C++ 和 C#,但是外表是具有欺骗性的。Java的枚举类型是功能完备的类,比其他语言中的枚举要强大得多,在其他语言中枚举本质上是 int 值。
      Java enum 类型背后的基本思想很简单:它们是通过公共静态 final 字段为每个枚举常量导出一个实例的类。枚举类型实际上是 final 类型,因为它没有可访问的构造函数。因为客户机既不能创建枚举类型的实例,也不能扩展它,所以除了声明的枚举常量外,不能有其他实例。换句话说,枚举类型是实例控制的(P 6)。它们是单例的泛化(item 3),本质上是单元素枚举。
      枚举提供编译时类型安全性。如果将参数声明为 Apple 类型,则可以确保传递给该参数的任何非空对象引用都是三个有效 Apple 值之一。尝试传递错误类型的值将导致编译时错误,尝试将一种枚举类型的表达式分配给另一种枚举类型的变量,或者使用 == 操作符比较不同枚举类型的值也是如此。
      具有相同命名常量的枚举类型和平共存,因为每种类型都有自己的名称空间。您可以在 enum 类型中添加或重新排序常量,而无需重新编译其客户机,因为导出常量的字段在 enum 类型及其客户机之间提供了一层隔离:常量值不会像在 int enum模式中那样编译到客户机中。最后,您可以通过调用枚举的 toString 方法将枚举转换为可打印的字符串。
      除了纠正 int 枚举的不足之外,枚举类型还允许您添加任意方法和字段并实现任意接口。它们提供了所有对象方法的高质量实现(第3章),它们实现了 Comparable (第14章)和 Serializable (第12章),并且它们的序列化形式被设计为能够承受 enum 类型的大部分更改。
    那么,为什么要向枚举类型添加方法或字段呢?对于初学者,您可能希望将数据与其常量关联起来。例如,我们的苹果和橘子类型可能受益于返回水果颜色的方法,或者返回水果图像的方法。您可以使用任何合适的方法来扩充枚举类型。枚举类型可以从枚举常量的简单集合开始,并随着时间的推移演化为功能齐全的抽象。
      举一个丰富enum类型的好例子,考虑我们太阳系的八大行星。每颗行星都有质量和半径,从这两个属性可以计算出它的表面重力。这反过来又让你计算一个物体在行星表面的重量,给定物体的质量。这是 enum 的样子。每个枚举常量后面括号中的数字是传递给其构造函数的参数。在这种情况下,它们是行星的质量和半径:

    // 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(item 17)。字段可以是公共的,但是最好将它们设置为私有并提供公共访问器(item 16)。在 Planet 的情况下,构造函数还计算和存储表面重力,但这只是一个优化。每次用表面重量法计算物体的质量和半径时,重力都可以重新计算。
      虽然 enum 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 也有一个静态值方法,该方法按照声明值的顺序返回一个值数组。还请注意,toString 方法返回每个枚举值的声明名称,使 println 和 printf 能够方便地进行打印。如果您不满意这个字符串表示,您可以通过覆盖 toString 方法来更改它。下面是用命令行参数 185 运行我们的加权表程序(它不会覆盖 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 年之前,冥王星还是一颗行星。这就提出了一个问题:“当您从枚举类型中删除一个元素时会发生什么?”答案是,任何不引用已删除元素的客户机程序都将继续正常工作。因此,例如,我们的磅表程序只需打印一个少一行的表。客户端程序引用被删除的元素(在本例中是Planet.Pluto )又如何呢?如果重新编译客户端程序,编译将失败,并在指向“以前的行星”的行上显示一条有用的错误消息;
      如果您未能重新编译客户机,它将在运行时从这一行抛出一个有用的异常。这是您所希望的最佳行为,比使用 int enum 模式得到的结果要好得多。
      一些与枚举常量相关的行为可能只需要从定义枚举的类或包中使用。这样的行为最好实现为私有方法或包私有方法。然后,每个常量都带有一个隐藏的行为集合,这些行为集合允许包含枚举的类或包在呈现常量时做出适当的反应。与其他类一样,除非您有令人信服的理由将 enum方法公开给它的客户端,否则请将其声明为私有,或者(如果需要的话)声明为 package-private (item 15)。
      如果枚举经常使用,它应该是一个顶级类;如果将其使用绑定到特定的顶级类,则它应该是该顶级类的成员类(item 24)。例如 java.math.RoundingMode 表示小数的舍入模式。BigDecimal 类使用这些舍入模式,但是它们提供了一个有用的抽象,这个抽象并不从根本上绑定到 BigDecimal。通过使 RoundingMode 成为顶级枚举,库设计人员鼓励任何需要舍入模式的程序员重用这个枚举,从而提高了 api 之间的一致性。
      Planet示例中演示的技术对于大多数枚举类型来说已经足够了,但有时您还需要更多。每个行星常数都有不同的数据,但有时您需要将基本不同的行为与每个常数关联起来。例如,假设您正在编写一个枚举类型来表示基本四函数计算器上的操作,并且希望提供一个方法来执行由每个常量表示的算术操作。实现这一点的一种方法是使用 switch:

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

      这段代码可以工作,但不是很漂亮。如果没有抛出语句,它将无法编译,因为该方法的末尾在技术上是可到达的,即使它永远不会到达[JLS, 14.21]。更糟的是,代码很脆弱。如果您添加了一个新的 enum常 量,但是忘记向开关添加相应的情况,enum 仍然会编译,但是当您尝试应用新操作时,它会在运行时失败。幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply 方法,并在特定于常量的类主体中为每个常量使用一个具体的方法覆盖它。这些方法被称为常量特定的方法实现:

    // 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 方法,因为该方法紧跟在每个常量声明之后。在不太可能忘记的情况下,编译器会提醒您,因为 enum 类型中的抽象方法必须用其所有常量中的具体方法覆盖。
      特定于常量的方法实现可以与特定于常量的数据相结合。例如,下面是一个操作版本,它覆盖了 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)); 
    }
    

      注意,操作常量是从创建 enum 常量之后运行的静态字段初始化中放入 stringToEnum 映射的。前面的代码在 values() 方法返回的数组上使用一个流(第7章); 在Java 8之前,我们将创建一个空散列映射,并遍历值数组,将 string-to-enum 映射插入到映射中,如果您愿意,仍然可以这样做。但是请注意,试图将每个常量从其自己的构造函数中放入映射是行不通的。它会导致编译错误,这是件好事,因为如果它是合法的,它会在运行时导致 NullPointerException。除了常量变量(item 34),枚举构造函数不允许访问枚举的静态字段。这个限制是必要的,因为当enum 构造函数运行时,静态字段还没有初始化。这种限制的一个特殊情况是 enum 常量不能从它们的构造函数相互访问。
      还要注意,fromString 方法返回Optional<String> 。这允许方法指出传入的字符串不代表有效的操作,并迫使客户机面对这种可能性(item 55)。
      特定于常量的方法实现的一个缺点是,它们使枚举常量之间更难共享代码。例如,考虑一个 enum,它表示工资包中的每周天数。这个 enum有一个方法,根据工人的基本工资(每小时)和当天的工作分钟数计算工人当天的工资。在五个工作日内,任何超过正常轮班时间的工作都会产生加班费;在这两个周末,所有的工作都会产生加班费。使用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。该程序仍然会编译,但是pay方法将静默地向工人支付与普通工作日相同的假期工资。
      执行薪酬计算安全 constant-specific 方法实现,你必须复制加班费计算为每个常数,或移动计算为两个辅助方法,工作日和周末的一天,从每个常数并调用适当的辅助方法。任何一种方法都会导致大量的样板代码,从而大大降低可读性,增加出错的机会。
      可以用一个具体的方法来代替发薪日的抽象加班工资方法,从而减少样板文件。该方法可以执行工作日的加班计算。只有周末的时候才需要重写这个方法。但是,这将与switch语句具有相同的缺点:如果您添加了另一个没有覆盖 overtimePay 方法的日子,那么您将默默地继承工作日的计算。
      你真正想要的是每次你添加一个 enum 常量时,被迫选择加班费策略。幸运的是,有一个很好的方法可以做到这一点。其思想是将加班工资计算转移到私有嵌套枚举中,并将此策略枚举的实例传递给 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 语句不是在枚举上实现特定于常量的行为的好选择,那么它们有什么用呢?开启枚举有助于使用特定于常量的行为扩展枚举类型。例如,假设操作 enum 不在您的控制之下,并且您希望它有一个实例方法来返回每个操作的倒数。你可以用下面的静态方法来模拟效果:

    // 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 常量的优势是引人注目的。枚举更具可读性、更安全、更强大。许多枚举不需要显式构造函数或成员,但其他枚举则从将数据与每个常量关联并提供其行为受该数据影响的方法中获益。将多个行为与一个方法相关联可以使很少的枚举受益。在这种相对少见的情况下,宁可选择常量特定的方法,也不要选择那些打开自己值的枚举。如果一些(但不是所有)枚举常量具有相同的行为,请考虑策略枚举模式。

    相关文章

      网友评论

          本文标题:ITEM 34: 使用Enums 而不是 int 常量

          本文链接:https://www.haomeiwen.com/subject/ecfkrctx.html