🍊 Java学习:Java从入门到精通总结
豆瓣评分9.8的图书《Effective Java》,是当今世界顶尖高手Josh Bloch的著作,在我之前的文章里我也提到过,编程就像练武,既需要外在的武功招式(编程语言、工具、中间件等等),也需要修炼心法(设计模式、源码等等) 学霸、学神OR开挂
我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。
最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。
接下来就针对此书列举一下我的收获与思考。
不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。
没有时间读原作的同学可以参考我这篇文章。
34 用enum代替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;
上述做法有很多不足: 1. 不安全且没有任何描述性 如果将apple传入了想要orange的方法里,不会报错,还会用==运算符比较 Apple 与 Orange
2. 使用int 枚举的程序很脆弱 因为 int 枚举是编译时常量,所以它们的int 值被编译到使用它们的客户端中,如果与int 枚举关联的值发生更改,则必须重新编译其客户端
3. 很难将 int 枚举常量转换为可打印的字符串 就算将其打印出来了,所看到的也只是一个数字,没什么意义
4. 没有可靠的方法来遍历所有 int 枚举常量
除了int枚举模式之外,还有String枚举模式(String enum pattern),它同样也有很多缺点:
1. 导致初级用户将字符串常量硬编码到客户端代码中,就是常说的魔法值
2. 会依赖字符串的比较操作故有很大的性能问题
Java提供的枚举类型可以很好的解决上面的问题:
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
Java的枚举本质上是int值,思想非常简单:通过public static final属性为每个枚举常量导出一个实例。由于没有可访问的构造方法,枚举类型实际上是 final 的。客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。
有以下几条优点: 1. 保证了编译时的类型安全 如果声明参数的类型为Apple,它就能保证传到该参数上的任何非空的对象引用一定是FUJI,PIPPIN,GRANNY_SMITH之一
2. 具有相同名称常量的多个枚举类型可以共存 因为每个类都有其自己的名称空间
3. 枚举类型还允许添加任意方法和属性并实现任意接口 提供了所有 Object 方法,实现了Comparable 和 Serializable 接口
比如太阳系的八颗行星,每个行星都有质量和半径,从这两个属性可以计算出它的表面重力
每个枚举常量之后的括号中的数字是传递给其构造方法的参数
package com.wjw.effectivejava1;
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
}
}
可以根据物体在地球上的重量,打印出该物体在所有8颗行星上的重量:
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));
}
}
运行结果:
如果一个枚举具有普适性,他就应该成为一个顶层类,如果他只是被用在一个特定的顶层类里,他就应该成为该顶级类的 成员类,例如java.math.RoundingMode表示小数部分的舍入模式。BigDecimal类使用了这些舍入模式,但他们却不属于BigDecimal类的一个抽象。让RoundingMode成为一个顶层类,以鼓励让任何需要舍入模式的程序员重用。
有时我们需要更多的方法,加入正在编写一个枚举类,表示计算器的加减乘除操作,还需要提供一个方法来执行每个常量所表示的运算:
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);
}
}
这段代码能用,但不优雅,如果添加了新的枚举常量,却忘记给switch添加相应的条件就会失败。
有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply方法,并在特定于常量的类主体(constant-specific class body)。这种方法被称为特定于常量的方法实现(constant-specific method implementation):
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);
}
特定于常量的方法实现可以与特定于常量的数据结合使用。toString 方法返回与操作关联的符号:
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);
}
上述代码可以很容易地打印算术表达式:
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
特定于常量的方法实现有一个美中不足的地方,它们使得在枚举常量中共享代码变得更加困难。例如,考虑用一个枚举代表工资包中的工作天数,根据给定的某工人的基本工资(每小时)和当天工作的时间计算当天工人的工资。
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 条件。该程序仍然会编译,但付费方法会将节假日的工资算成工作日的工资,原因是走了上面default里的逻辑。
我们真正想要的是每次添加枚举常量时,就自动选择加班费策略:再定义一个嵌套枚举类PayType,并将PayType实例传递给·PayrollDay·枚举的构造方法里。
public 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语句适合于给外部的枚举类型增加和常量值对应行为。
假设希望 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);
}
}
35 用实际属性代替序数
许多枚举天生就与某个 int 值关联。所以枚举都有一个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,除了在编写这种数据结构时可以用,其他时候都不要用。
36 使用 EnumSet 替代位属性
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
// Parameter is bitwise OR of zero or more STYLE_ constants
public void applyStyles(int styles) { ... }
}
这种int型表示方式允许使用按位或(or)运算,将几个常量合并到一个称为位属性(bit field)的集合中:
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
除了使用 OR 运算符之外,位属性表示法还可以支持求交集、异或求加法等很方便的操作,但是它本质上还是int型枚举常量,所以继承了int枚举常量的所有缺点: 1. 打印位属性时,翻译位域要难得多
就好比让你直接用二进制编程,酸爽程度可想而知
2. 没有一个好的方法可以遍历所有位属性表示的元素
3. 写API之前就要确定好需要多少位,选择相应的类型(int、long)
java.util包提供了 EnumSet 类来有效地表示从单个枚举类型中提取的值集合。
在内部具体的实现上,每个EnumSet表示为位矢量。
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
// Any Set could be passed in, but EnumSet is clearly best
public void applyStyles(Set<Style> styles) { ... }
}
EnumSet提供了丰富的静态工厂,可以轻松创建集合:
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
因为枚举类型要用在集合中,所以不推荐用位域来表示它,推荐使用EnumSet
37 使用EnumMap 替代序数索引
不要用ordinal来索引数组,要使用EnumMap
有时可能会用到ordinal方法来作为数组的索引,例如创建一个植物类:
public 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;
}
}
如果有一个植物数组plantsByLifeCycle,按照不同的生长周期(一年生,多年生,或双年生)将花园里的植物garden放入不同的位置:
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);
// Print the results
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
上面的代码有很多问题:
- 数组不能与泛型兼容,编译会报错
- 数组不知道它的索引代表什么,比如添加额外的注释来标注这些索引的输出
- 当使用以索引顺序为索引的数组时,必须人工保证使用的int值不出差错
使用java.util.EnumMap可以更好地达到上面想要的效果,并规避风险:
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);
EnumMap 与序数索引数组的速度相当,其原因正是 EnumMap 内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节
注意EnumMap 构造方法传入一个Class对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息
通过使用stream可以进一步缩短程序:
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle)));
这个代码的问题在于它选择了自己的 Map 实现,实际上并不是EnumMap,使用Collectors.groupingBy的三个参数形式的方法,它允许调用者使用mapFactory参数指定map的实现:
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));
还有按照序数进行二维数组索引的情况,例如下面代码表示了物理学中物质的状态变化过程(如液体到固体是凝固,液体到气体是沸腾):
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修改,使用Map(起始状态, Map(目标状态, 过渡方式))这种存储格式:
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))));
public static Transition from(Phase from, Phase to) {
return m.get(from).get(to);
}
}
}
映射的类型是Map<Phase, Map<Phase, Transition>>,例如Map<液体, Map<固体, 凝固过程>>
第一个集合Phase对Transition进行分组,第二个集合使用从Phase到Transition的映射创建一个EnumMap。第二个收集器((x, y)-> y)) 中的merge方法未使用,只有在我们因为我们想要获得一个EnumMap而定义映射工厂时才需要用到
现在假设想为系统添加一个新阶段:plasma(离子)或电离气体。只有两个Transition与之关联:电离化(ionization),将气体转为离子;和去离子;消电离化(deionization)将离子体转为气体。
要更新层序时,只需将PLASMA添加到Phase中,并将IONIZE(GAS, PLASMA)和DEIONIZE(PLASMA, GAS)添加到Transition中:
public enum Phase {
SOLID, LIQUID, GAS, PLASMA;
public enum Transition {
MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);
... // Remainder unchanged
}
}
很方便,也很安全!
38 用接口实现可继承的枚举
对于“可继承”的枚举来说,操作码(operation codes, opcodes)是一个经典的例子,操作码是枚举类型,其元素表示某些机器上的操作,例如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;
}
}
虽然枚举类BasicOperation无法被继承,但接口Operation是可以被继承的。假设想要扩展前面的操作类型,包括指数运算和余数运算。要做的就是编写一个实现 Operation 接口的枚举类型:
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> opEnumType, double x, double y) {
for (Operation op : opEnumType.getEnumConstants())
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
注意ExtendedOperation类的字面文字(ExtendedOperation.class)被传递给了test方法,<T extends Enum<T> & Operation> Class<T> 确保了Class 对象既是枚举又是Operation 的子类,这正是遍历元素和执行每个元素相关联的操作时所需要的
另一种方法是传入一个Collection<? extends Operation>,和上面的差异在于这是一个限定通配符类型(⻅第31条),而不是传递了一个class 对象:
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));
}
上面两个main函数的执行结果全都如下:
4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000
使用接口来实现可扩展枚举的一个小缺点是,无法实现一个枚举类继承另一个枚举类。
Java 平台也借鉴了这种方式来实现java.nio.file.LinkOption枚举类型,它同时实现了 CopyOption 和 OpenOption 接口。
39 注解优先于命名模式
所有的程序员都应该使用Java平台所提供的预定义的注解类型,既然有了注解,就不要使用命名模式了
使用命名模式(naming patterns)来指示某些程序元素需要通过工具或框架进行特殊处理。例如在 JUnit 4 发布之前,要求程序员必须以test作为测试方法名的开头,这有几个严重缺点:
1. 英语拼写错误会导致运行失败,且没有任何提示
2. 无法实现将测试用于某个程序元素上 意思是,假如将TestSafetyMechanisms类,希望JUnit 3 能够自动测试其所有方法,实际上并不会测试
3. 没有提供将参数值与程序元素相关联的好的方法 例如无法测试只有抛出异常才算成功的代码
注解很好地解决了所有这些问题,JUnit从版本4开始,使用注解来指定简单的测试:
import java.lang.annotation.*;
/** * Indicates that the annotated method is a test method. * Use only on parameterless static methods. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
Test 本身还被 Retention 和 Target 这两个元注解进行标记,@Retention(RetentionPolicy.RUNTIME)元注解表明Test这个注解在运行时有效,@Target.get(ElementType.METHOD)元注解表明Test 注解只能修饰方法。
在Test 的注释说:“Use only on parameterless static method”(只用于无参的静态方法),实际上编译器并未强制限定这一条。
public class Sample {
@Test
public static void ml() {
} // Test should pass
public static void m2() {
}
@Test
public static void m3() { // Test should fail
throw new RuntimeException ("Boom");
}
public static void m4() {
}
@Test
public void m5() {
} // INVALID USE: nonstatic method
public static void m6() {
}
@Test
public static void m7() { // Test should fail
throw new RuntimeException("Crash");
}
public static void m8() {
}
}
Sample 类有七个静态方法,其中四个被标注为Test。其中两个,m3 和m7 引发异常,两个m1 和m5 不引发异常。但是没有引发异常的注解方法之一是实例方法,因此它不是注释的有效用法。总之,Sample 包含四个测试:一个会通过,两个会失败,一个是无效的。未使用Test 注解标注的四种方法将被测试工具忽略。
注解永远不会改变被注解代码的语义,但是它可以通过工具进行特殊处理:
import java.lang.reflect.*;
import org.junit.Test;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) { //
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " failed: " + exc);
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
}
System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}
}
上述代码通过调用 Method.invoke 来反射地运行所有类标记有Test 注解的方法,运行结果:
现在添加对 仅在特定异常时 才算成功的测试的支持,添加一个新的注解类型:
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
此注解的参数类型是Class<? extends Throwable>,意思是:某个继承了Throwable类的Class对象,它允许注解的用户指定任何异常(或错误)类型。
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // Test should pass
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // Should fail (wrong exception type)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() {
} // Should fail (no exception)
}
现在要修改一下测试运行工具来处理新的注解:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
/
Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
/
} else {
System.out.printf("Test %s failed: expected %s, got %s%n", m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("Invalid @Test: " + m);
}
}
上面的代码提取注解参数的值,并用它检查代码抛出的异常是否是注解指定的类型。
如果需求更进一步:测试在抛出多个指定异常时都算成功。注解机制有一个支持这种用法的工具。将ExceptionTest 注解的参数类型更改为Class对象数组:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable>[] value();
}
最重要的是,这种语法十分灵活,使用了最新版的ExceptionTest之后,所有以前的ExceptionTest注解仍然有用。如果要指定多个异常,需要用花括号将其包裹起来:
// Code containing an annotation with an array parameter
@ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// The spec permits this method to throw either
// IndexOutOfBoundsException or NullPointerException
list.addAll(5, null);
}
修改测试运行工具类:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Exception>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Exception> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
从Java 8开始,还有另一种方法来执行多值注解。用 @Repeatable 元注解对注解的声明进行注解,表示该注解可以被重复地应用给单个元素。元注解只有1个参数,就是包含注解类型(containing annotation type)的类对象(下面代码里的ExceptionTestContainer.class),它的唯一参数是一个注解类型数组:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Exception> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
ExceptionTest[] value();
}
下面是我们的doublyBad 测试用一个重复的注解代替基于数组值注解的方式:
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
处理可重复的注解要非常小心。
要使用isAnnotationPresent检测重复和非重复的注解,需要检查注解类型及其包含的注解类型:
if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
如果你觉得这样写增强了代码的可读性就这样写,但在声明和处理可重复注解时存在更多的模板代码,并且处理可重复的注解很容易出错。
40 坚持使用Override注解
阿里巴巴开发手册这样规定: 这个注解只能在方法声明上使用,它表明带此注解的方法声明重写了父类的声明。坚持使用这个注解,将避免产生大量的bug。
下面代码中,类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<>();
for (int i = 0; i < 10; i++)
for (char ch = 'a'; ch <= 'z'; ch++)
s.add(new Bigram(ch, ch));
System.out.println(s.size());
}
}
主程序重复添加二十六个双字⺟组合到集合中,然后它会打印集合的大小。如果运行程序,会发现它打印的是260。
原因在于: 程序员没有覆盖equals,而是把它重载了,为什么没有覆盖呢?原因是为了覆盖Object的equals方法,必须定义一个入参为Object的equals方法,上面代码中的入参是Bigram,所以main方法里实际调用的还是Objecct的equals方法,这个方法可以简单理解为比较两个对象实例的地址,每个bigram的10个备份中,每个都与其余的9个不一样,所以上面打印出260。
如果加上@Override注解,编译器就能帮助我们发现这个错误:
@Override public boolean equals(Bigram b) {
return b.first == first && b.second == second;
}
此时就会报错:
这样我们就能反应过来要如何修改了:
@Override public boolean equals(Object o) {
if (!(o instanceof Bigram))
return false;
Bigram b = (Bigram) o;
return b.first == first && b.second == second;
}
要在每个覆盖父类的方法上声明@Override注解,如果你在编写一个继承了抽象类的类,就不用在方法上声明@Override注解
子类如果没有覆盖抽象类的方法,编译器会报错
不过话说回来,作者还是建议无论是对于抽象类还是接口,还是要用@Override标注所有要覆盖父类或接口的方法。例如Set接口没有给Collection接口添加新方法,他应该在所有的方法上用@Override标注,以确保它不会意外给Collection接口添加任何新方法。
41 用标记接口定义类型
标记接口(marker interface)指没有任何方法的接口,例如Serializable接口,实现了这个接口的类可以被写到ObjectOutputStream中,也就是经常说的可以被序列化。
标记接口有两条优于标记注解:
1. 标记接口定义的类型由被标记类的实例实现
所以标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。
比较遗憾的一点是:
Object.OutputStream.writeObject
API没有利用Serializable
接口的优势:它的参数被声明为Object 类型而不是Serializable
,所以尝试序列化一个不可序列化的对象,直到运行时才会失败。
2. 标记接口可以更精确地定位目标
如果注解类型用 ElementType.TYPE 声明,它就可以被应用于任何类或接口。但如果有一个注解只适用于特定接口,就需要将它定义成一个标记接口,可以扩展它使用的唯一接口,确保所有被注解的类型也都是该唯一接口的子类型。
Set 接口就是这样一个受限的标记接口(restricted marker interface)。它仅适用于 Collection 子类型
标记注解优于标记接口的主要优点是它们是更大的注解工具的一部分,标记注解在基于注解的框架中保持一致性。
所以什么时候应该使用「标记注解」,什么时候应该使用「标记接口」呢?
- 如果标记是应用于除类或接口以外的任何地方,则必须使用注解
- 如果标记仅适用于类和接口,可以问自己一个问题:「可能我想编写一个或多个只接受具有此标记的对象的方法呢?」,如果是这样,则用「标记接口」。这样就可以用接口作为相关方法的参数类型,这将带来编译时类型检查的好处
- 如果永远不会写一个只接收只标记对象的方法(类似ObjectOutputStream这种),则用「标记注解」
- 如果标记是大量使用注解的框架的一部分,则用「标记注解」