《Effective Java》笔记30-34:枚举

第30条:用 enum 代替 int 常量

枚举类型是指由一组固定的常量组成合法值的类型

int 枚举模式

在还没有枚举时,表示枚举类型的常用模式是声明一组具名的 int 常量,每个类型成员一个常量:

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_FUJI - ORANGE_TEMPLE),再有就是常量 int 是编译时常量,被直接编译到使用他们的客户端中。如果与该常量关联的 int 发生了变化,客户端就必须重新编译。如果没有重新编译,程序还是可以执行,但是他们的行为将不确定。

枚举

Java 1.5 中提供的枚举的声明方式:

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

和 int 常量不同的是,如果函数的参数是枚举类型,如 Apple,那么他的实际值只能来自于该枚举所声明的枚举值,即 FUJI, PIPPIN, GRANNY_SMITH。如果试图将 传递类型错误的值时,将会导致编译错误。

枚举类型是真正的 final,是单例的泛型化,Java 中允许在枚举中添加任意的方法和域,并实现任意的接口。下面先给出一个带有域方法和域字段的枚举声明:

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;   // 千克
    private final double radius; // 米
    private final double surfaceGravity;  // 表面重力
    private static final double G = 6.67300E-11;
    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;
    }
}

在上面的枚举示例代码中,已经将数据和枚举常量关联起来了,因此需要声明实例域字段,同时编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域字段都应该为 final 的。下面看一下该枚举的应用示例:

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));
    }
}
// Weight on MERCURY is 66.133672
// Weight on VENUS is 158.383926
// Weight on EARTH is 175.000000
// Weight on MARS is 66.430699
// Weight on JUPITER is 442.693902
// Weight on SATURN is 186.464970
// Weight on URANUS is 158.349709
// Weight on NEPTUNE is 198.846116

行为与每个常量关联起来

上文示例中的方法对于大多说枚举类型来说已经足够了。但有时需要将本质上不同的行为与每个常量关联起来。

编写一个枚举类型,来表示计算器的加减乘除,想要提供一个方法来执行每个常量所表示的算术运算

public enum Operation {
    PLUS,MINUS,TIMES,DIVIDE;
    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 将无法编译,虽然无法执行到
    }
}

糟糕的是:如果我们新增枚举值的时候,所有和 apply 类似的域函数,都需要进行相应的修改。

抽象方法

在枚举类型中声明一个抽象的 apply 方法,并在特定于常量的类主体中,用具体的方法覆盖每个常量的抽象 apply 方法

public enum Operation {
    PLUS { double apply(double x,double y) { return x + y;} },
    MINUS { double apply(double x,double y) { return x - y;} },
    TIMES { double apply(double x,double y) { return x * y;} },
    DIVIDE { double apply(double x,double y) { return x / y;} };

    abstract double apply(double x, double y);
}

如果给 Operation 添加新的常量,也不会忘记添加 apply 方法。

构造器与 toString 方法

public enum Operation {
    PLUS("+") { double apply(double x,double y) { return x + y;} },
    MINUS("-") { double apply(double x,double y) { return x - y;} },
    TIMES("*") { double apply(double x,double y) { return x * y;} },
    DIVIDE("/") { 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;
    }

    abstract double apply(double x, double y);
}

枚举类型有一个自动产生的 valueOf(String) 方法,他将常量的名字转变为枚举常量本身,如果在枚举中覆盖了 toString 方法(如上例),就需要考虑编写一个 fromString 方法,将定制的字符串表示法变回相应的枚举,见如下代码:

private static final Map<String,Operation> stringToEnum = new HashMap<String,Operation>();

static {
    for (Operation op: values())
        stringToEnum.put(op.toString(),op);
}

public static Operation fromString(String symbol) {
    return stringToEnum.get(symbol);
}

在常量被创建完成后,静态代码块将 Operation 常量放入到了 stringToEnum 的 map 中。不能在构造器中将自身放入 map 中,会编译错误。枚举构造器不可以访问枚举的静态域,除了编译时常量域之外。

策略枚举

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

// 策略枚举模式
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); }  // 默认模式


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


    // 策略枚举类型
    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 枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。你可以用以下静态方法模拟效果:

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 常量的优点是令人信服的。枚举更具可读性,更安全,更强大。许多枚举不需要显式构造方法或成员,但其他枚举则可以通过将数据与每个常量关联提供行为受此数据影响的方法而受益。如果多个枚举常量同时共享共同行为,请考虑策略枚举模式。

第31条:用实例域代替序数

枚举提供了 ordinal() 方法,返回每个枚举常量在类型中的数字位置

public enum Ensemble {
    SOLO,   DUET,   TRIO, QUARTET, QUINTET,
    SEXTET, SEPTET, OCTET, NONET,  DECTET;

    public int numberOfMusicians() { return ordinal() + 1; }
}

虽然这个枚举能正常工作,但对于维护来说则是一场噩梦。如果常量被重新排序,numberOfMusicians 方法将会被破坏。

永远不要从枚举的序号中得出与它相关的值,而是要将其保存在实例属性中:

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 方法说道:大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如 EnumSet 和 EnumMap。除非你在编写这样数据结构的代码,否则最好避免使用 ordinal 方法。

第32条:用 EnumSet 代替位域

如果枚举类型的元素主要用于集合中,一般来说使用 int 枚举模式,下面将 2 的不同倍数赋值给每个常量:

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
    public void applyStyles(int styles) { ... }
}

这种表示法让你用 OR 位运算将几个常量合并到一个集合中,称作位域:

text.applyStyles(Text.STYLE_BOLD | Text.STYLE_ITALIC);

java.util 包提供了 EnumSet 类来有效地表示从单个枚举类型中提取的值集合。 这个类实现了 Set 接口,提供了所有其他 Set 实现的丰富性,类型安全性和互操作性。下面是前一个使用枚举和枚举集合替代位属性的示例。它更短,更清晰,更安全:

public class Text {
    public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }

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

下面是将 EnumSet 实例传递给 applyStyles 方法的客户端代码。EnumSet 类提供了一组丰富的静态工厂,可以轻松创建集合,其中一个代码如下所示:

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

请注意,applyStyles 方法采用 Set<Style> 而不是 EnumSet<Style> 参数。尽管所有客户端都可能会将 EnumSet 传递给该方法,但接受接口类型而不是实现类型通常是很好的做法。

EnumSet 的一个真正缺点是,它不像 Java 9 那样创建一个不可变的 EnumSet,但是在即将发布的版本中可能会得到补救。 同时,你可以用 Collections.unmodifiableSet 封装一个 EnumSet,但是简洁性和性能会受到影响。

第33条:用 EnumMap 代替序数索引

有时可能会看到使用 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;
    }
}

现在假设你有一组植物代表一个花园,想要列出这些由生命周期组织的植物(一年生,多年生,或双年生)。为此,需要构建三个集合,每个生命周期作为一个,并遍历整个花园,将每个植物放置在适当的集合中。

有一个非常快速的 Map 实现,设计用于枚举键,称为 java.util.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);

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

// 选择了自己的 Map 实现,实际上它不是 EnumMap
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle)));

为了解决这个问题,使用 Collectors.groupingBy 的三个参数形式的方法,它允许调用者使用 mapFactory 参数指定 map 的实现:

System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle,
        () -> new EnumMap<>(LifeCycle.class), toSet())));

你可能会看到数组索引(两次)的数组,用序数来表示从两个枚举值的映射。例如,这个程序使用这样一个数组来映射两个阶段到一个阶段转换(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()];
        }
    }
}

这段程序可以运行,甚至可能显得优雅,但外观可能是骗人的。就像前面显示的简单的花园示例一样,编译器无法知道序数和数组索引之间的关系。如果在转换表中出错或者在修改 Phase 或 Phase.Transition 枚举类型时忘记更新它,则程序在运行时将失败。

同样,可以用 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 = 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, t -> t, (x, y) -> y, () -> new EnumMap<>(Phase.class))));

        // 相当于
        // private static final Map<Phase, Map<Phase, Transition>> m = new EnumMap<>(Phase.class);
        // static {
        //     for (Phase p: Phase.values())
        //         m.put(p, new EnumMap<Phase, Transition>(Phase.class));

        //     for (Transition t: Transition.values())
        //         m.get(t.from).put(t.to, t);
        // }

        public static Transition from(Phase from, Phase to) {
            return m.get(from).get(to);
        }
    }
}

总之,使用序数来索引数组很不合适:改用 EnumMap。

第34条:用接口模拟可伸缩的枚举

目前还没有很好的方法来枚举基本类型的所有元素及其扩展。最终,可伸缩性会导致设计和实现的许多方面变得复杂起来。

操作码是指这样的枚举类型:它的元素表示在某种机器上的那些操作,例如前面的 Operation 类型,它表示一个简单的计算器中的某些函数。有时需要用户提供它们自己的操作,这样可以有效地扩展 API 所提供的操作集。

由于枚举类型可以通过给操作码类型和(属于接口的标准实现的)枚举定义接口,来实现任意接口,基本的想法就是利用这一事实。

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

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) {
    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> opSet, double x, double y) {
    for (Operation op : opSet.getEnumConstants()) {
        System.out.printf("%f %s %f = %f%n",x, op, y, op.apply(x, y));
    }
}

<T extends Enum<T> & Operation> Class<T> 确保了 opSet 对象即表示枚举又表示 Operation 的子类型。

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

这样的代码没有那么复杂,更灵活,允许将多个实现类型的操作合并到一起。但也放弃了在指定操作上使用 EnumSet 和 EnumMap 的功能。所以如果不是要灵活地合并多个实现类型的操作,最好还是使用第一种方法。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记30-34:枚举

文章字数:4.2k

本文作者:Bin

发布时间:2019-06-11, 22:50:03

最后更新:2019-08-06, 00:43:19

原始链接:http://coolview.github.io/2019/06/11/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B030-34%EF%BC%9A%E6%9E%9A%E4%B8%BE/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录