Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必不少人都读过,号称Java四大名著之一,不过第二版2009年出版,到如今已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深入的变化。
在这里第一时间翻译成中文版。供你们学习分享之用。java
Java支持两种引用类型的特殊用途的系列:一种称为枚举类型的类和一种称为注解类型的接口。 本章讨论使用这些类型系列的最佳实践。程序员
枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星或一副扑克牌中的套装。 在将枚举类型添加到该语言以前,表示枚举类型的常见模式是声明一组名为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枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 若是你将一个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枚举是编译时常量[JLS,4.12.4],因此它们的int值被编译到使用它们的客户端中[JLS,13.1]。 若是与int枚举关联的值发生更改,则必须从新编译其客户端。 若是没有,客户仍然会运行,但他们的行为将是不正确的。app
没有简单的方法将int枚举常量转换为可打印的字符串。 若是你打印这样一个常量或者从调试器中显示出来,你看到的只是一个数字,这不是颇有用。 没有可靠的方法来迭代组中的全部int枚举常量,甚至没法得到int枚举组的大小。ide
你可能会遇到这种模式的变体,其中使用了字符串常量来代替int常量。 这种称为字符串枚举模式的变体更不理想。 尽管它为常量提供了可打印的字符串,但它能够致使初级用户将字符串常量硬编码为客户端代码,而不是使用属性名称。 若是这种硬编码的字符串常量包含书写错误,它将在编译时逃脱检测并致使运行时出现错误。 此外,它可能会致使性能问题,由于它依赖于字符串比较。性能
幸运的是,Java提供了一种避免int和String枚举模式的全部缺点的替代方法,并提供了许多额外的好处。 它是枚举类型[JLS,8.9]。 如下是它最简单的形式:学习
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH } public enum Orange { NAVEL, TEMPLE, BLOOD }
从表面上看,这些枚举类型可能看起来与其余语言相似,好比C,C ++和C#,但事实并不是如此。 Java的枚举类型是完整的类,比其余语言中的其余语言更强大,其枚举本质本上是int值。优化
Java枚举类型背后的基本思想很简单:它们是经过公共静态final属性为每一个枚举常量导出一个实例的类。 因为没有可访问的构造方法,枚举类型其实是final的。 因为客户既不能建立枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第6页)。 它们是单例(条目 3)的泛型化,基本上是单元素的枚举。
枚举提供了编译时类型的安全性。 若是声明一个参数为Apple类型,则能够保证传递给该参数的任何非空对象引用是三个有效Apple值中的一个。 尝试传递错误类型的值将致使编译时错误,由于会尝试将一个枚举类型的表达式分配给另外一个类型的变量,或者使用==
运算符来比较不一样枚举类型的值。
具备相同名称常量的枚举类型能够和平共存,由于每种类型都有其本身的名称空间。 能够在枚举类型中添加或从新排序常量,而无需从新编译其客户端,由于导出常量的属性在枚举类型与其客户端之间提供了一层隔离:常量值不会编译到客户端,由于它们位于int枚举模式中。 最后,能够经过调用其toString
方法将枚举转换为可打印的字符串。
除了纠正int枚举的缺陷以外,枚举类型还容许添加任意方法和属性并实现任意接口。 它们提供了全部Object方法的高质量实现(第3章),它们实现了Comparable
(条目 14)和Serializable
(第12章),并针对枚举类型的可任意改变性设计了序列化方式。
那么,为何你要添加方法或属性到一个枚举类型? 对于初学者,可能想要将数据与其常量关联起来。 例如,咱们的Apple和Orange类型可能会从返回水果颜色的方法或返回水果图像的方法中受益。 还可使用任何看起来合适的方法来加强枚举类型。 枚举类型能够做为枚举常量的简单集合,并随着时间的推移而演变为全功能抽象。
对于丰富的枚举类型的一个很好的例子,考虑咱们太阳系的八颗行星。 每一个行星都有质量和半径,从这两个属性能够计算出它的表面重力。 从而在给定物体的质量下,计算出一个物体在行星表面上的重量。 下面是这个枚举类型。 每一个枚举常量以后的括号中的数字是传递给其构造方法的参数。 在这种状况下,它们是地球的质量和半径:
// 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
枚举很简单,但它的功能很是强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在全部八个行星上的重量(以相同单位):
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中加入枚举两年以后,冥王星再也不是一颗行星。 这引起了一个问题:“当你从枚举类型中移除一个元素时会发生什么?”答案是,任何不引用移除元素的客户端程序都将继续正常工做。 因此,举例来讲,咱们的WeightTable
程序只须要打印一行少一行的表格。 什么是客户端程序引用删除的元素(在这种状况下,Planet.Pluto
)? 若是从新编译客户端程序,编译将会失败并在引用前一个星球的行处提供有用的错误消息; 若是没法从新编译客户端,它将在运行时今后行中引起有用的异常。 这是你所但愿的最好的行为,远远好于你用int枚举模式获得的结果。
一些与枚举常量相关的行为只须要在定义枚举的类或包中使用。 这些行为最好以私有或包级私有方式实现。 而后每一个常量携带一个隐藏的行为集合,容许包含枚举的类或包在与常量一块儿呈现时做出适当的反应。 与其余类同样,除非你有一个使人信服的理由将枚举方法暴露给它的客户端,不然将其声明为私有的,若是须要的话将其声明为包级私有(条目 15)。
若是一个枚举是普遍使用的,它应该是一个顶级类; 若是它的使用与特定的顶级类绑定,它应该是该顶级类的成员类(条目 24)。 例如,java.math.RoundingMode
枚举表示小数部分的舍入模式。 BigDecimal
类使用了这些舍入模式,但它们提供了一种有用的抽象,它并不与BigDecimal
有根本的联系。 经过将RoundingMode
设置为顶层枚举,类库设计人员鼓励任何须要舍入模式的程序员重用此枚举,从而提升跨API的一致性。
// 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
语句,就不能编译,由于该方法的结束在技术上是可达到的,尽管它永远不会被达到[JLS,14.21]。 更糟的是,代码很脆弱。 若是添加新的枚举常量,但忘记向switch语句添加相应的条件,枚举仍然会编译,但在尝试应用新操做时,它将在运行时失败。
幸运的是,有一种更好的方法能够将不一样的行为与每一个枚举常量关联起来:在枚举类型中声明一个抽象的apply
方法,并用常量特定的类主体中的每一个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的方法实现:
// 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
方法,由于该方法紧跟在每一个常量声明以后。 万一忘记了,编译器会提醒你,由于枚举类型中的抽象方法必须被全部常量中的具体方法重写。
特定于常量的方法实现能够与特定于常量的数据结合使用。 例如,如下是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
的map中,它来自于建立枚举常量后运行的静态属性初始化。前面的代码在values()
方法返回的数组上使用流(第7章);在Java 8以前,咱们建立一个空的hashMap
并遍历值数组,将字符串到枚举映射插入到map中,若是愿意,仍然能够这样作。但请注意,尝试让每一个常量都将本身放入来自其构造方法的map中不起做用。这会致使编译错误,这是好事,由于若是它是合法的,它会在运行时致使NullPointerException
。除了编译时常量属性(条目 34)以外,枚举构造方法不容许访问枚举的静态属性。此限制是必需的,由于静态属性在枚举构造方法运行时还没有初始化。这种限制的一个特例是枚举常量不能从构造方法中相互访问。
另请注意,fromString
方法返回一个Optional<String>
。 这容许该方法指示传入的字符串不表明有效的操做,而且强制客户端面对这种可能性(条目 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; } }
这段代码无能否认是简洁的,但从维护的角度来看是危险的。 假设你给枚举添加了一个元素,多是一个特殊的值来表示一个假期,但忘记在switch语句中添加一个相应的case条件。 该程序仍然会编译,但付费方法会默默地为工做日支付相同数量的休假日,与普通工做日相同。
要使用特定于常量的方法实现安全地执行工资计算,必须为每一个常量重复加班工资计算,或将计算移至两个辅助方法,一个用于工做日,另外一个用于周末,并调用适当的辅助方法来自每一个常量。 这两种方法都会产生至关数量的样板代码,大大下降了可读性并增长了出错机会。
经过使用执行加班计算的具体方法替换PayrollDay
上的抽象overtimePa
y方法,能够减小样板。 那么只有周末的日子必须重写该方法。 可是,这与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常量的优势是使人信服的。 枚举更具可读性,更安全,更强大。 许多枚举不须要显式构造方法或成员,但其余人则能够经过将数据与每一个常量关联并提供行为受此数据影响的方法而受益。 使用单一方法关联多个行为能够减小枚举。 在这种相对罕见的状况下,更喜欢使用常量特定的方法来枚举本身的值。 若是一些(但不是所有)枚举常量共享共同行为,请考虑策略枚举模式。