Effective Java:05 枚举和注解

Demon.Lee 2024年08月18日 320次浏览

本文代码实践基于 JDK-21:java 21.0.4 2024-07-16 LTS

本章讨论的是枚举和注解两种特殊引用类型的最佳实践。


34 用 enum 代替 int 常量

用 enum 代替 int 会有什么好处呢?我们先上代码:

// int 枚举模式
public final class Constants {
  private static final int COLOR_RED = 1;
  private static final int COLOR_BLUE = 2;
  private static final int COLOR_GREEN = 3;
  // ...

  private static final int FRUIT_APPLE = 1;
  private static final int FRUIT_ORANGE = 2;
  private static final int FRUIT_PEAR = 3;
  // ...
}


// 枚举类型
public enum ColorEnum {
  RED, BLUE, GREEN
}

public enum FruitEnum {
  APPLE, ORANGE, PEAR
}

与使用枚举类型相比,int 枚举模式有以下劣势:

  • 自描述性,也就是表达业务意图的能力,使用 int 常量后明显被弱化了;
  • 命名空间,int 枚举模式下将各种常量放一起没法区分,所以只能加前缀(即上面例子中的 COLOR 或 FRUIT),除非把同类型的常量放在一个独立的类中;
  • 重新编译,由于 int 枚举模式使用了常量(即 static final ),javac 编译生成字节码时会将客户端引用处直接用值填充,而不是引用,这就导致当我们将 COLOR_RED 从 1 调整为 0 时,引用的地方依然是 1,除非重新编译;
  • 日志输出,显然打印 int 枚举模式中的 1,2,3 没什么太大的意义,缺乏自描述性,如果想打印更多的信息,需要额外敲代码,麻烦;
  • 代码安全,将 FRUIT_APPLE 传到使用 COLOR_RED 的地方,程序也不会报错,而且还能比较;
  • ……

但如果使用 enum 枚举类则不会有这些问题,enum 中的元素其实是一个个不可改变的单例常量,下面是 ColorEnum 类字节码的反编译结果:

Classfile /java-21/ColorEnum.class
  Last modified 2024年8月15日; size 924 bytes
  SHA-256 checksum 693faa62ede769af48dbe579e7561f1f6bd1cdafb1a9923166333925e7a3af12
  Compiled from "ColorEnum.java"
public final class ColorEnum extends java.lang.Enum<ColorEnum>
  minor version: 0
  major version: 65
  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM
  this_class: #1                          // ColorEnum
  super_class: #23                        // java/lang/Enum
  interfaces: 0, fields: 4, methods: 5, attributes: 2
Constant pool:
   #1 = Class              #2             // ColorEnum
   #2 = Utf8               ColorEnum
   #3 = Fieldref           #1.#4          // ColorEnum.RED:LColorEnum;
   #4 = NameAndType        #5:#6          // RED:LColorEnum;
   #5 = Utf8               RED
   #6 = Utf8               LColorEnum;
   #7 = Fieldref           #1.#8          // ColorEnum.BLUE:LColorEnum;
   #8 = NameAndType        #9:#6          // BLUE:LColorEnum;
   #9 = Utf8               BLUE
  #10 = Fieldref           #1.#11         // ColorEnum.GREEN:LColorEnum;
  #11 = NameAndType        #12:#6         // GREEN:LColorEnum;
  #12 = Utf8               GREEN
  #13 = Fieldref           #1.#14         // ColorEnum.$VALUES:[LColorEnum;
  #14 = NameAndType        #15:#16        // $VALUES:[LColorEnum;
  #15 = Utf8               $VALUES
  #16 = Utf8               [LColorEnum;
  #17 = Methodref          #18.#19        // "[LColorEnum;".clone:()Ljava/lang/Object;
  #18 = Class              #16            // "[LColorEnum;"
  #19 = NameAndType        #20:#21        // clone:()Ljava/lang/Object;
  #20 = Utf8               clone
  #21 = Utf8               ()Ljava/lang/Object;
  #22 = Methodref          #23.#24        // java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
  #23 = Class              #25            // java/lang/Enum
  #24 = NameAndType        #26:#27        // valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
  #25 = Utf8               java/lang/Enum
  #26 = Utf8               valueOf
  #27 = Utf8               (Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
  #28 = Methodref          #23.#29        // java/lang/Enum."<init>":(Ljava/lang/String;I)V
  #29 = NameAndType        #30:#31        // "<init>":(Ljava/lang/String;I)V
  #30 = Utf8               <init>
  #31 = Utf8               (Ljava/lang/String;I)V
  #32 = String             #5             // RED
  #33 = Methodref          #1.#29         // ColorEnum."<init>":(Ljava/lang/String;I)V
  #34 = String             #9             // BLUE
  #35 = String             #12            // GREEN
  #36 = Methodref          #1.#37         // ColorEnum.$values:()[LColorEnum;
  #37 = NameAndType        #38:#39        // $values:()[LColorEnum;
  #38 = Utf8               $values
  #39 = Utf8               ()[LColorEnum;
  #40 = Utf8               values
  #41 = Utf8               Code
  #42 = Utf8               LineNumberTable
  #43 = Utf8               (Ljava/lang/String;)LColorEnum;
  #44 = Utf8               MethodParameters
  #45 = Utf8               Signature
  #46 = Utf8               ()V
  #47 = Utf8               <clinit>
  #48 = Utf8               Ljava/lang/Enum<LColorEnum;>;
  #49 = Utf8               SourceFile
  #50 = Utf8               ColorEnum.java
{
  public static final ColorEnum RED;
    descriptor: LColorEnum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  public static final ColorEnum BLUE;
    descriptor: LColorEnum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  public static final ColorEnum GREEN;
    descriptor: LColorEnum;
    flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

  public static ColorEnum[] values();
    descriptor: ()[LColorEnum;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: getstatic     #13                 // Field $VALUES:[LColorEnum;
         3: invokevirtual #17                 // Method "[LColorEnum;".clone:()Ljava/lang/Object;
         6: checkcast     #18                 // class "[LColorEnum;"
         9: areturn
      LineNumberTable:
        line 1: 0

  public static ColorEnum valueOf(java.lang.String);
    descriptor: (Ljava/lang/String;)LColorEnum;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: ldc           #1                  // class ColorEnum
         2: aload_0
         3: invokestatic  #22                 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
         6: checkcast     #1                  // class ColorEnum
         9: areturn
      LineNumberTable:
        line 1: 0
    MethodParameters:
      Name                           Flags
      <no name>                      mandated

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=4, locals=0, args_size=0
         0: new           #1                  // class ColorEnum
         3: dup
         4: ldc           #32                 // String RED
         6: iconst_0
         7: invokespecial #33                 // Method "<init>":(Ljava/lang/String;I)V
        10: putstatic     #3                  // Field RED:LColorEnum;
        13: new           #1                  // class ColorEnum
        16: dup
        17: ldc           #34                 // String BLUE
        19: iconst_1
        20: invokespecial #33                 // Method "<init>":(Ljava/lang/String;I)V
        23: putstatic     #7                  // Field BLUE:LColorEnum;
        26: new           #1                  // class ColorEnum
        29: dup
        30: ldc           #35                 // String GREEN
        32: iconst_2
        33: invokespecial #33                 // Method "<init>":(Ljava/lang/String;I)V
        36: putstatic     #10                 // Field GREEN:LColorEnum;
        39: invokestatic  #36                 // Method $values:()[LColorEnum;
        42: putstatic     #13                 // Field $VALUES:[LColorEnum;
        45: return
      LineNumberTable:
        line 2: 0
        line 1: 39
}
Signature: #48                          // Ljava/lang/Enum<LColorEnum;>;
SourceFile: "ColorEnum.java"

如果我们再把上面的字节码翻译成普通的 Java 类,而不是 enum 类,代码的样子差不多是下面这样:

public final class ColorEnum extends Enum<ColorEnum> {
    public static final ColorEnum RED = new ColorEnum("RED", 0);
    public static final ColorEnum BLUE = new ColorEnum("BLUE", 1);
    public static final ColorEnum GREEN = new ColorEnum("GREEN", 2);

    private static final ColorEnum[] $VALUES = new ColorEnum[]{RED, BLUE, GREEN};

    private ColorEnum(String name, int ordinal) {
        super(name, ordinal);
    }

    public static ColorEnum[] values() {
        return $VALUES.clone();
    }

    public static ColorEnum valueOf(String name) {
        return (ColorEnum) Enum.valueOf(ColorEnum.class, name);
    }
}

从代码可以看到:

  • ColorEnum 的构造器是 private 的,也就是说客户端不能构造其他的实例;
  • ColorEnum 本身使用了 final 修饰,表示这个类不可扩展;
  • RED,BLUE 和 GREEN 三个实例对象都使用 static final 修饰,表示不可变;
  • 每个 Enum 对象实例默认拥有两个属性,name 和 ordinal,前者就是实例名称的字面量,后者则是序号,也就是说 enum 本身就是 int 枚举模式的升级版;
  • values 方法返回当前枚举类下各个实例的一个克隆数组;
  • valueOf 方法可以将字符串字面量转换为对应的实例对象;
  • ……

再看它的父类 Enum:

public abstract class Enum<E extends Enum<E>>
  implements Constable, Comparable<E>, Serializable {

  private final String name;

  public final String name() {
    return name;
  }

  private final int ordinal;

  public final int ordinal() {
    return ordinal;
  }

  protected Enum(String name, int ordinal) {
    this.name = name;
    this.ordinal = ordinal;
  }

  public String toString() {
    return name;
  }

  // ...
  // ...

  protected final Object clone() throws CloneNotSupportedException {
    throw new CloneNotSupportedException();
  }

  public final int compareTo(E o) {
    Enum<?> other = o;
    Enum<E> self = this;
    if (self.getClass() != other.getClass() && // optimization
        self.getDeclaringClass() != other.getDeclaringClass())
      throw new ClassCastException();
    return self.ordinal - other.ordinal;
  }

  // ...

  public final Class<E> getDeclaringClass() {
    Class<?> clazz = getClass();
    Class<?> zuper = clazz.getSuperclass();
    return (zuper == Enum.class) ? (Class<E>)clazz : (Class<E>)zuper;
  }

  @Override
  public final Optional<EnumDesc<E>> describeConstable() {
    return getDeclaringClass()
      .describeConstable()
      .map(c -> EnumDesc.of(c, name));
  }

  // ...

  public static <T extends Enum<T>> T valueOf(Class<T> enumClass,
                                              String name) {
    T result = enumClass.enumConstantDirectory().get(name);
    if (result != null)
      return result;
    if (name == null)
      throw new NullPointerException("Name is null");
    throw new IllegalArgumentException(
      "No enum constant " + enumClass.getCanonicalName() + "." + name);
  }

  // ...
  // ...
}

首先我们可以注意到,它实现了 Constable, Comparable<E>, Serializable 等几个接口,其中的 Constable 是 Java 12 引入的新特性,用来表达常量值的一个抽象,这不是我们的重点,简单了解一下:

Represents a type which is constable. A constable type is one whose values are constants that can be represented in the constant pool of a Java classfile as described in JVMS @jvms 4.4, and whose instances can describe themselves nominally as a ConstantDesc.

...

从上面的代码就能看到,前面 int 枚举模式中那些弊端都没有了,比如每个枚举类都有独立的命名空间,并且输出日志时默认就是当前实例的字面量( toString() 方法返回 name)。而这只是枚举类的冰山一角:枚举类型可以先作为枚举常量的一个简单集合,随着时间的推移再演变成为全功能的抽象。

当我们向枚举类增加属性域,或是结合策略模式进行派发时,可以玩出非常多的花样。如果我们发现代码中充斥着各种 if...else if..else 或是 switch...case 语句时,可以问问自己:枚举 + 策略模式会不会是一个解决方案?除了属性域,还可以定义抽象方法,然后让各个常量实例独立去表达(实现)自己的行为,这种方式又被称为特定于常量的方法实现。比如下面的 apply 方法,这同样消除了 switch...case 那种过气的不具备开闭原则的丑陋代码:

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

上面说的是每个枚举常量都有不同的行为,如果多个常量拥有相同的行为呢?可以考虑策略枚举(strategy enum)的玩法:

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 {
            @Override
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 :
                       (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            @Override
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };

        private static final int MINS_PER_SHIFT = 8 * 60;

        abstract int overtimePay(int minsWorked, int payRate);

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

最后,关于枚举类的可访问性:

  • 如果这个枚举类具备普适性,使用广泛,那它应该被设计成为顶层类(top-level class);
  • 如果它只用在某个顶层类中,那它应该是这个顶层类的一个成员类;
  • 其他情况下,遵循最小可访问性的原则,比如某一些与枚举常量相关的行为,只会用在枚举类或枚举类所在的包中,那这些方法应该被定义为私有或包级私有。

35 用实例域代替序数

这一条规则说的是不要使用枚举类自带的默认序数,因为枚举常量的位置一旦发生变化,对应的序数也会发生变化:

public enum ColorEnum {

    // YELLOW,
    RED,
    GREEN,
    BLUE;

    public static void main(String[] args) {
        System.out.println(RED.ordinal());
    }
}

上面的程序会输出 0,但如果我们在 RED 之前增加一个常量 YELLOW,或者把 RED 移到 GREEN 后面,再运行程序,则会输出 1。所以,如果客户端程序依赖这个序数,就存在未知风险。一个更好的方案是,在枚举类中定义一个属性域,将值配置在每个常量实例上:

public enum ColorEnum {

    YELLOW(1),
    RED(2),
    GREEN(3),
    BLUE(4);

    private final int seq;

    ColorEnum(int seq) {
        this.seq = seq;
    }

    public int getSeq() {
        return seq;
    }

    public static void main(String[] args) {
        System.out.println(RED.ordinal()); // 1
        System.out.println(RED.seq); // 2
    }
}

36 用 EnumSet 代替位域

这一条规则也很简单,但需要先解释一下什么是位域:

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) {
        // ...
    }
}

上面代码示例中定义的 STYLE_BOLD,STYLE_ITALIC,STYLE_UNDERLINE 和 STYLE_STRIKETHROUGH 就是我们上文说的 int 枚举模式,现在的需求是,我们要同时选择好几种 style,并将其传给一个函数,比如这里的 applyStyles 方法,传统的做法可能是下面这样的:

applyStyles(STYLE_BOLD | STYLE_ITALIC | STYLE_UNDERLINE);

STYLE_BOLD | STYLE_ITALIC | STYLE_UNDERLINE 这样使用 OR 运算符将几个常量合并到一个集合中,就被称为位域(bit field)。显然,这样的做法继承了 int 枚举模式的诸多缺点,并且在使用方要遍历这些常量时也没有简单的方法。也许有人会使用 int[] 来代替这里的位域,但依然不是最优解。

而 EnumSet 就是用来解决这个问题的,它是一个 Set,并且是枚举类实例的专用 Set:

public enum ColorEnum {

    YELLOW(1),
    RED(2),
    GREEN(3),
    BLUE(4);

    private final int seq;

    ColorEnum(int seq) {
        this.seq = seq;
    }

    public int getSeq() {
        return seq;
    }

    public static void main(String[] args) {
        EnumSet<ColorEnum> set = EnumSet.allOf(ColorEnum.class);
        EnumSet<ColorEnum> set2 = EnumSet.noneOf(ColorEnum.class);
        EnumSet<ColorEnum> set3 = EnumSet.range(ColorEnum.RED, ColorEnum.GREEN);
        System.out.println(set); // [YELLOW, RED, GREEN, BLUE]
        System.out.println(set.contains(ColorEnum.BLUE)); // true
        System.out.println(set2); // []
        System.out.println(set3); // [RED, GREEN]
        System.out.println(set3.contains(ColorEnum.BLUE)); // false
        set3.add(ColorEnum.BLUE);
        System.out.println(set3); // [RED, GREEN, BLUE]
    }
}

使用时,我们传 EnumSet 类的实例即可,比如上面的 applyStyles 方法:

public void applyStyles(EnumSet<Style> styles) {
  // ...
}

查看 EnumSet 源码,可以发现它有两个实现类:RegularEnumSet 和 JumboEnumSet,前者使用 long 类型的位向量表示,后者使用 long[] 数组表示。当元素个数小于等于 64 时,使用 RegularEnumSet 进行存储,否则使用 JumboEnumSet。

/**
 * A specialized {@link Set} implementation for use with enum types.  All of
 * the elements in an enum set must come from a single enum type that is
 * specified, explicitly or implicitly, when the set is created.  Enum sets
 * are represented internally as bit vectors.  This representation is
 * extremely compact and efficient. The space and time performance of this
 * class should be good enough to allow its use as a high-quality, typesafe
 * alternative to traditional {@code int}-based "bit flags."  Even bulk
 * operations (such as {@code containsAll} and {@code retainAll}) should
 * run very quickly if their argument is also an enum set.
 * ...
 * ...
 */
public abstract sealed class EnumSet<E extends Enum<E>> extends AbstractSet<E>
    implements Cloneable, java.io.Serializable permits JumboEnumSet, RegularEnumSet
{
    // declare EnumSet.class serialization compatibility with JDK 8
    @java.io.Serial
    private static final long serialVersionUID = 1009687484059888093L;

    /**
     * The class of all the elements of this set.
     */
    final transient Class<E> elementType;

    /**
     * All of the values comprising E.  (Cached for performance.)
     */
    final transient Enum<?>[] universe;

    EnumSet(Class<E>elementType, Enum<?>[] universe) {
        this.elementType = elementType;
        this.universe    = universe;
    }
  
   // ...
   // ...
}

最后,如果想创建不可变的 EnumSet,目前还没有什么简单的方式,一个可行的方案是通过 Collections.unmodifiableSet 再包装一层。


37 用 EnumMap 代替序数索引

与上一条类似,只是用 Map 代替了 Set:

/**
 * A specialized {@link Map} implementation for use with enum type keys.  All
 * of the keys in an enum map must come from a single enum type that is
 * specified, explicitly or implicitly, when the map is created.  Enum maps
 * are represented internally as arrays.  This representation is extremely
 * compact and efficient.
 *
 * <p>Enum maps are maintained in the <i>natural order</i> of their keys
 * (the order in which the enum constants are declared).  This is reflected
 * in the iterators returned by the collections views ({@link #keySet()},
 * {@link #entrySet()}, and {@link #values()}).
 * ...
 * ...
 */
public class EnumMap<K extends Enum<K>, V> extends AbstractMap<K, V>
  implements java.io.Serializable, Cloneable
{
  /**
     * The {@code Class} object for the enum type of all the keys of this map.
     *
     * @serial
     */
  private final Class<K> keyType;

  /**
     * All of the values comprising K.  (Cached for performance.)
     */
  private transient K[] keyUniverse;

  /**
     * Array representation of this map.  The ith element is the value
     * to which universe[i] is currently mapped, or null if it isn't
     * mapped to anything, or NULL if it's mapped to null.
     */
  private transient Object[] vals;

  /**
     * The number of mappings in this map.
     */
  private transient int size = 0;

  // ...
  // ...
  public EnumMap(Class<K> keyType) {
    this.keyType = keyType;
    keyUniverse = getKeyUniverse(keyType);
    vals = new Object[keyUniverse.length];
  }

  public EnumMap(EnumMap<K, ? extends V> m) {
    keyType = m.keyType;
    keyUniverse = m.keyUniverse;
    vals = m.vals.clone();
    size = m.size;
  }

  // ...
  // ...

}

这一条书中说的不少,但我觉得搞复杂了。简单来说,就是不要使用 Enum#ordinal 序数来映射关联数据,比如书中的示例:

class Plant {
    enum LifeCycle {
        ANNUAL, PERENNIAL, BIENNIAL
    }

    final String name;
    final LifeCycle lifeCycle;

    Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

    @Override
    public String toString() {
        return name;
    }

    public static void main(String[] args) {
        List<Plant> garden = List.of(
                new Plant("Aloe", Plant.LifeCycle.ANNUAL),
                new Plant("China rose", Plant.LifeCycle.PERENNIAL),
                new Plant("Daffodil", Plant.LifeCycle.PERENNIAL),
                new Plant("Tomato", Plant.LifeCycle.BIENNIAL));

        // 使用 ordinal() 方法将枚举类型映射到数组 - 不推荐的做法!
        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);
        }
        for (int i = 0; i < plantsByLifeCycle.length; i++) {
            System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
        }
    }
}

之前我们已经提到过,使用 ordinal 有好多弊端,所以针对这种场景,JDK 也提供了内置方案,这就是 EnumMap:

public static void main(String[] args) {
  List<Plant> garden = List.of(
    new Plant("Aloe", Plant.LifeCycle.ANNUAL),
    new Plant("China rose", Plant.LifeCycle.PERENNIAL),
    new Plant("Daffodil", Plant.LifeCycle.PERENNIAL),
    new Plant("Tomato", Plant.LifeCycle.BIENNIAL));

  // 将上面的代码使用 EnumMap 重新实现
  EnumMap<LifeCycle, Set<Plant>> plantsByLifeCycle2 = new EnumMap<>(Plant.LifeCycle.class);
  for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
    plantsByLifeCycle2.put(lc, new HashSet<>());
  }
  for (Plant p : garden) {
    plantsByLifeCycle2.get(p.lifeCycle).add(p);
  }
  System.out.println(plantsByLifeCycle2);
}

EnumMap 的 key 就是枚举常量实例,这显然比使用 ordinal 序数靠谱多了,完全不用担心上一个版本中用错序数导致的问题,另外也没有了泛型与数组不能很好合作的问题,即 (Set<Plant>[]) new Set[Plant.LifeCycle.values().length] 。不过,为了简化代码,我们经常会使用 Stream 流的方式对上述代码进行重构:

public static void main(String[] args) {
  List<Plant> garden = List.of(
    new Plant("Aloe", Plant.LifeCycle.ANNUAL),
    new Plant("China rose", Plant.LifeCycle.PERENNIAL),
    new Plant("Daffodil", Plant.LifeCycle.PERENNIAL),
    new Plant("Tomato", Plant.LifeCycle.BIENNIAL));

  // 将上面 EnumMap 的实现方式使用 Stream 流进行简化
  EnumMap<LifeCycle, Set<Plant>> plantsByLifeCycle3 = garden.stream()
    .collect(Collectors.groupingBy(p -> p.lifeCycle,
                                   () -> new EnumMap<>(Plant.LifeCycle.class),
                                   Collectors.toSet()));
  System.out.println(plantsByLifeCycle3);
}

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

一个枚举类是无法继承另一个枚举的(前面提到字节码中的枚举类是用 final 修饰的),如果想增强当前枚举类,怎么办?实现对应行为的接口即可:

interface Operation {
    double apply(double x, double y);
}

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

    public static void main(String[] args) {
        test1(List.of(ExtendedOperation.values()), 4, 2);
        test2(ExtendedOperation.class, 4, 2);
    }

    // 使用枚举值的方式测试
    private static void test1(Collection<Operation> operations, double x, double y) {
        operations.forEach(op -> System.out.printf("test1: %f %s %f = %f%n", x, op, y, op.apply(x, y)));
    }

    // 使用枚举Class对象的方式测试
    private static <T extends Enum<T> & Operation> void test2(Class<T> enumClass, double x, double y) {
        for (Operation op : enumClass.getEnumConstants()) {
            System.out.printf("test2: %f %s %f = %f%n", x, op, y, op.apply(x, y));
        }
    }
}

这是书中的示例,可以看到两种测试用例的区别,通过 Class 对象也是可以拿到枚举实例的。


39 注解优先于命名模式

这一条说的是,要用注解代替命名模式,那什么是命名模式(naming pattern)?书中举了一个例子:

在 Java4发行版本之前, JUnit 测试框架原本要求其用户一定要用 test 作为测试方法名称的开头。

也就是说,通过命名来约束或触发某种能力的标识,显然这是有一些弊端的:

  • 如果书写错误呢,比如将 test 写成了 tset,编译器能发现错误吗?并不能;
  • 没有将参数值与程序元素关联起来,比如,如果想在单元测试中支持异常的测试,难道要在方法名上加上对应异常的名字吗?

所以,使用命名模式来实现某种程序元素在工具或框架中进行特殊处理并不是一个优雅的方案,这样的代码不仅无法表达意图,同时十分脆弱,非常容易出 bug。而注解则很好地解决了这些问题,比如它的表达能力就非常强:

// 定义异常注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
  Class<? extends Throwable>[] value();
}


// 使用异常注解
@ExceptionTest({IndexOutOfBoundsException,NullPointerException})
public void testMethod(){
  //...
  //...
}

上面含义是,当 testMethod 在运行过程中出现 IndexOutOfBoundsException 或 NullPointerException 时,则自动过滤。


40 坚持使用 Override 注解

/**
 * Indicates that a method declaration is intended to override a
 * method declaration in a supertype. If a method is annotated with
 * this annotation type compilers are required to generate an error
 * message unless at least one of the following conditions hold:
 *
 * <ul><li>
 * The method does override or implement a method declared in a
 * supertype.
 * </li><li>
 * The method has a signature that is override-equivalent to that of
 * any public method declared in {@linkplain Object}.
 * </li></ul>
 *
 * ...
 * ... 
 * @since 1.5
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

这一条比较简单,就是当出现方法重写时,要给方法加上 Override 注解。这么做的目的,是避免错误的覆盖,导致程序运行错误。从上面的定义可以看到,这个注解使用的场景是编译期,也就是让编译器帮我们找问题,下面就是一个典型的案例:

public class Foo {

    private String name;
    private int score;

    // @Override
    public boolean equals(Foo o) {
        if (this == o) return true;
        if (!(o instanceof Foo foo)) return false;
        return score == foo.score && Objects.equals(name, foo.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, score);
    }
}

如果将 equals 方法上的 Override 注解注释掉,编译不会报错,运行也正常,但当我们比较两个 Foo 对象实例时就会发现,它们死活不会相等,哪怕它们拥有相同的 name 和 score,这是因为我们的 equals 方法并没有重写 Object 类中的 equals 方法,而是重载了。Object 类中的 equals 方法要求的参数类型是 Object,而不是这里的 Foo。但当我们将 Override 注解放开,再进行编译就会提示错误:error: method does not override or implement a method from a supertype

所以,加这个注解就能提前帮我们发现问题,从而避免更严重的代码 bug 产生。书中还提到一个例外,如果抽象类中定义的抽象方法,在具体的实现类中可以不用加这个注解,因为抽象方法不重写的话,肯定会出现编译错误。但笔者认为,养成好习惯,都加上也没事。


41 用标记接口定义类型

这里讨论了两个概念,标记接口和标记注解:

标记接口(marker interface):即没有任何方法或属性的空接口,主要用来表示具备某种能力,比如 java.io.Serializable 接口表示序列化的能力;

标记注解(marker annotation):即没有任何方法或属性的空注解,主要用于标识某个类、方法或属性具备某种特性。

看上去,二者有些类似,但稍微想一想就知道,标记接口在编译/运行时都有一定的表达能力,而标记注解只能在运行时通过反射进行能力的表达。所以,使用标记接口在编译期就能发现某些错误,让问题提前暴露出来。再者,标记接口(比如 MarkerA)可以继承某个特定接口(比如 A),再让 A 接口的某些子类去实现 MarkerA,如此,我们便能精确控制这几个子类,让它们也具备了 MakerA 的能力。

当然,标记注解也有其优势,比如作为更大的注解框架的一部分。那什么时候用标记接口,什么时候用标记注解?

如果想要定义一个任何新方法都不会与之关联的类型,标记接口就是最好的选择 。 如果想要标记程序元素而非类和接口,或者标记要适合于已经广泛使用了注解类型的框架,那么标记注解就是正确的选择 。


好,本期内容就总结到这里。关于枚举类的更多玩法和解读,笔者推荐《Java 编程思想》的升级版《On Java 中文版 进阶卷》第一章,有兴趣的小伙伴可以去瞅瞅。