Effective Java——枚举和注解

时间:2023-02-15 19:10:34

                    目录
三十、用enum代替int常量
三十一、用实例域代替序数
三十二、用EnumSet代替位域
三十三、用EnumMap代替序数索引
三十四、用接口模拟可伸缩的枚举
三十五、注解优先于命名模式
三十六、坚持使用override注解
三十七、用标记接口定义类型


三十、用enum代替int常量

        枚举类型是指由一组固定的常量组成合法值的类型,该特征是在Java 1.5 中开始被支持的,之前的Java代码都是通过“公有静态常量域字段”的方法来简单模拟枚举的,如:

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;

        这样的写法是比较脆弱的。首先是没有提供相应的类型安全性,如两个逻辑上不相关的常量值之间可以进行比较或运算(APPLE_FUJI - ORANGE_TEMPLE),再有就是常量int是编译时常量,被直接编译到使用他们的客户端中。如果与该常量关联的int发生了变化,客户端就必须重新编译。如果没有重新编译,程序还是可以执行,但是他们的行为将不确定。下面我们来看一下Java 1.5 中提供的枚举的声明方式:

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

        和“公有静态常量域字段”不同的是,如果函数的参数是枚举类型,如Apple,那么他的实际值只能来自于该枚举所声明的枚举值,即FUJI, PIPPIN, GRANNY_SMITH。如果试图将Apple和Orange中的枚举值进行比较,将会导致编译错误。Java的枚举类型是功能十分齐全的类,其本质上是int值。

        Java中允许在枚举中添加任意的方法和域,并实现任意的接口。举个枚举类型的好例子,比如太阳系中的8颗行星。每颗行星都有质量和半径,通过这两个属性可以计算出它的表面重力。从而给定物体的质量,就可以计算出一个物体在行星表面上的重量。下面这个枚举中枚举常量后面括号中的数值就是传递给构造器的参数。这个例子中,它们就是行星的质量和半径:

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
        枚举的静态方法values()将按照声明顺序返回他的值数组。枚举的toString方法返回每个枚举值的声明名称。注意:Java中printf的换行用的是%n而C中则用\n。

        在实际的编程中,我们常常需要针对不同的枚举常量提供不同的数据操作行为,见如下代码:

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);
}
}
        上面的代码已经表达出这种根据不同的枚举值,执行不同的操作。但是上面的代码在设计方面确实存在一定的缺陷,或者说漏洞,如果我们新增枚举值的时候,所有和apply类似的域函数,都需要进行相应的修改,如有遗漏将会导致异常的抛出。幸运的是,Java的枚举提供了一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的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);}

        这样在添加新枚举常量时就不会轻易忘记提供相应的apply方法了。我们在进一步看一下如何将枚举常量和特定的数据进行关联,下面的Operation覆盖了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);
}
        有些情况下,在枚举中覆盖toString非常有用,上述的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.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方法,将定制的字符串表示法变回相应的枚举,见如下代码:
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);}
        需要注意的是,我们无法在枚举常量构造的时候将自身放入到Map中,这样会导致编译错误。与此同时,枚举构造器不可以访问枚举的静态域,除了编译时的常量域之外。

        考虑用一个枚举表示薪资包中的工作天数,枚举中有一个方法,根据给定的基本工资(按小时)以及当天的工作时间,来计算当天的报酬。在五个工作日中,超过八小时的工作时间都会产生加班工工资在双休日中,所有的工作都产生加班工资。为了简洁期间起见,示例代码使用的double,但是注意double并不是适合薪资应用程序的数据类型:

enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURADAY, FRIDAY, SATURDAY, SUNDAY;
private static final int HOURS_PER_SHIFT = 8;
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
double overtimePay;
switch(this) {
case SATURDAY: case SUNDAY:
overtimePay = hoursWorked * payRate / 2;
default: //Weekdays
overtimePay = hoursWorked <= HOURS_PER_SHIFT ?
0 : (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
break;
}
return basePay + overtimePay;
}
}
        不可否认。这段代码十分简洁,但是从维护的角度看,它非常危险。假设将一个元素添加到该枚举中,或许是一个表示假期天数的特殊值,但是忘记给switch语句添加相应的case,程序依然可以编译,但pay方法会悄悄地将假期的工资计算成与政策工作日的相同。

        有一种很好的方法可以实现为每一个枚举常量强制选择一种加班报酬策略。即将加班工资计算移到一个私有的嵌套枚举中,将这个策略枚举的实例传到PayrollDay枚举的构造器中。之后PayrollDay枚举将这个加班工资计算委托给策略枚举,PayrollDay中就不需要switch语句或者特定于常量的方法实现了。这种模式更加安全,也更加灵活:

enum PayrollDay {
MONDAY(PayType.WEEKDAY),
TUESDAY(PayType.WEEKDAY),
WEDNESDAY(PayType.WEEKDAY),
THURADAY(PayType.WEEKDAY),
FRIDAY(PayType.WEEKDAY),
SATURDAY(PayType.WEEKEND),
SUNDAY(PayType.WEEKEND);

private final PayType payType;
PayrollDay(PayType payType) {
this.payType = payType;
double pay(double hoursWorked, double payRate) {
return payType.pay(hoursWorked, payRate);
}

private enum PayType {
WEEKDAY {
double overtimePay(double hours, double payRate) {
return hoursWorked <= HOURS_PER_SHIFT ?
0 : (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;

abstract double overtimePay(double hours, double payRate);

double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}
        总而言之,枚举比iint常量要易读,更加安全,功能更强大。许多枚举都不需要显式的构造器或成员,但许多其他枚举则收益于“每个常量与属性的关联”以及“提供行为受属性影响的方法”。如果多个枚举常量同时共享相同的行为,则考虑策略枚举。


三十一、用实例域代替序数

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

public enum Color {
WHITE,RED,GREEN,BLUE,ORANGE,BLACK;

public int indexOfColor() {
return ordinal() + 1;
}
}
        上面的枚举中提供了一个获取颜色索引的方法(indexOfColor),该方法将返回颜色值在枚举类型中的声明位置,如果我们的外部程序依赖了该顺序值,那么这将会是非常危险和脆弱的,因为一旦这些枚举值的位置出现变化,或者在已有枚举值的中间加入新的枚举值时,都将导致该索引值的变化。该条目推荐使用实例域的方式来代替枚举提供的序数值,见如下修改后的代码:
public enum Color {    WHITE(1),RED(2),GREEN(3),ORANGE(4),BLACK(5);    private final int indexOfColor;    Color(int index) {        this.indexOfColor = index;    }    public int indexOfColor() {        return indexOfColor;    }}
        Enum规范中谈到ordinal时这么写道:“大多数程序员都不需要这个方法。它是设计成用于像EnumSet和EnumMap这种基于枚举的通用数据结构的。”除非你在编写的是这种数据结构,否则最好避免使用ordinal()方法。


三十二、用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位运算将几个常量合并到一个集合中,称作位域(bit field),使用方式如下:text.applyStyles(Text.STYLE_BOLD | Text.STYLE_ITALIC);

        Java.util包中提供了EnumSet类有效地表示从单个枚举类型中提取的多个值的多个集合。该类实现Set接口,提供了丰富的功能,类型安全性,以及可以从任何其他Set实现中得到的互用性。但是在内部具体实现上,每个EnumSet内容都表示为位矢量。如果底层的枚举类型有64个或者更少的元素,整个EnumSet就用单个long来表示,因此他的性能也是可以比肩位域的。与此同时,他提供了大量的操作方法,其实现也是基于位操作的,但是相比于手工位操作,由于EnumSet替我们承担了这部分的开发,从而也避免了一些容易出现的低级错误,代码的美观程度也会有所提升,见如下修改的代码:

public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set<Style> styles) { ... }
}
        注意这个方法采用的是Set<Style>而非EnumSet<Style>,最好还是接受接口类型而非接受现实类型。下面是将EnumSet实例传递给applyStyles方法的客户端代码。EnumSet提供了丰富的静态工厂来轻松创建集合,其中一个如这个代码所示:text.applyStyles(EnumSet.of(Style.BOLD,Style.ITALIC));

        总而言之,EnumSet类集位域的简洁和性能优势以及之前所述的所有枚举类型的优点。只有一个缺点,即截止Java 1.6发行版本,它都无法创建不可变的EnumSet。同时,可以用Collections.unmodifiableSet将EnumSet封装起来,但是简洁性和性能会受到影响。


三十三、用EnumMap代替序数索引

        下面这个类表示一种香草,按照类型(一年生、多年生或者两年生植物)区分:

public class Herb {
public enum Type { ANNUAL, PERENNIAL, BIENNIAL }
private final String name;
private final Type type;
Herb(String name, Type type) {
this.name = name;
this.type = type;
}
@Override public String toString() {
return name;
}
}
        现在假设有一个香草数组,表示一座花园中的植物,想要按照类型进行组织之后将这些植物列出来。需要构建三个集合,每种类型一个,并且遍历整座花园,将每种香草放到相应集合中,如果用数组和序数索引实现起来存在许多问题,如数组不能与泛型一同使用。有一种非常快速的Map实现专门用于枚举键,称作java.util.EnumMap:
public static void main(String[] args) {    Herb[] garden = ...;    Map<Herb.Type,Set<Herb>> herbsByType =        new EnumMap<Herb.Type,Set<Herb>(Herb.Type.class);  //指明枚举键的类    for (Herb.Type t : Herb.Type.values()) {        herbssByType.put(t,new HashSet<Herb>());    }    for (Herb h : garden) {        herbsByType.get(h.type).add(h);    }    System.out.println(herbsByType);}
        使用EnumMap简短清楚,更加安全,由于其在内部使用数组实现,因此运行速度也很快。注意EnumMap构造器采用键类型的Class对象:这是一个有限制的类型令牌,它提供了运行时的泛型信息。

        总而言之,最好不要用序数来索引数组,而要使用EnumMap。如果你所表示的这种关系时多维的,就是用EnumMap<..., EnumMap<...>>。


三十四、用接口模拟可伸缩的枚举

        枚举是无法被扩展(extends)的。如果我们的操作中存在一些基础操作,如计算器中的基本运算类型(加减乘除)。然而对于有些用户来讲,他们也可以使用更高级的操作,如求幂和求余等。针对这样的需求,该条目提出了一种非常巧妙的设计方案,即利用枚举可以实现接口这一事实,我们将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;
}
}
        通过以上的代码可以看出,在任何可以使用BasicOperation的地方,我们也同样可以使用ExtendedOperation,只要我们的API是基于Operation接口的,而非BasicOperation或ExtendedOperation。下面为以上代码的应用示例:
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));    }}
        参数Class<T> opSet将推演出类型参数的实际类型,即上例中的ExtendedOperation。与此同时,test函数的参数类型限定确保了类型参数既是枚举类型又是Operation的实现类,这正是遍历元素和执行每个元素相关联的操作所必须的。

        第二种方法是使用Collection<? extends Operation>,作为opSet参数的类型:

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 (Operattion op : opSet)
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
        这样的代码没有那么复杂,test方法也比较灵活:它允许调用者将多个实现类型的操作合并到一起。另一方面,也放弃了在指定操作上使用EnumSet和EnumMap的功能,因此,除非需要灵活地合并多个实现类型的操作,否则可能最好使用有限制的类型令牌。

        总而言之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样允许客户端编写自己的枚举来实现接口。


三十五、注解优先于命名模式

        大多数程序员都不必定义注解类型。但是所有的程序员都应该使用Java平台提供的预定义的注解类型,如@Override。还要考虑使用IDE和静态分析工具所提供的任何注解。但是要注意这些注解还没有标准化,因此如果变换工具或者形成标准,就有很多工作要做了。


三十六、坚持使用override注解

        考虑下面的程序,这里的类Bigram表示一个双字母组或者有序的字母对:

public class Bigram {
private final char first;
private final char second;
public Bigram(char first, char second) {
this.first = first;
this.second = second;
}
public boolean equals(Bigram b) {
return b.first == first && b.second == second;
}
public int hashCode() {
return 31 * first + second;
}

public static void main(String[] args) {
Set<Bigram> s = new HashSet<Bigram>();
for (int i = 0; i < 10; i++)
for (char = 'a'; ch <= 'z'; ch++)
s.add(new Bigram(ch, ch));
System.out.println(s.size());
}
}
        主程序反复地将26个双字母添加到集合中,每个双字母都由两个相同的小写字母组成。随后打印出集合的大小。你可能以为程序打印出的大小是26,因为HashSet集合不能包含重复。但事实是程序打印的是260。显然,类的创建者本想覆盖equals方法,同时还记得覆盖了hashCode,遗憾的是,此类没能覆盖到equals,而是将它重载了。为了覆盖Object.equals,必须定义一个参数为Object类型的equals方法,但Bigram的equals方法的参数并不是Object类型。因此这个equals方法测试对象的同一性就像==操作符一样,所以程序会打印出260。

        如果你用@Override标注了equals方法时,编译器就可以帮你发现这个错误。下面是正确的equals实现:

@Override
public boolean equals(Object o) {
if (!(o instanceof Bigram))
return false;
Bigram b = (Bigram) o;
return b.first == first && b.second == second;
}
        总而言之,应该在你想要覆盖超类或者接口声明的每个方法中使用Override注解。


三十七、用标记接口定义类型

        标记接口是没有包含方法声明的接口,如Serializable接口。标记接口相对于标记注解的优势在于:1.标记接口定义的类型是由被标记类的实例实现的;标记注解没有定义这样的类型。如果参数没有实现Serializable接口,ObjectOutputStream.write会在运行时失败。2.标记接口可以被更加精确的锁定。

        总而言之,如果标记是应用在任何程序元素而非类或者接口,那么应该使用注解。如果标记永远只用于限制特殊元素的接口,那么应该使用标记接口。