第34条:用enum代替int常量
34.1 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;
34.1.1 缺点
- 不具有类型安全性
- 例如将apple传到想要orange的方法中,编译器不报错
- ==、-、+等操作符对apple和orange进行比较,编译器不报错
- 当两个int枚举组具有相同名称的常量时,必须加前缀来区分
//例如apple和orange都有HAN这个品种,那么必须加前缀,即APPLE_HAN和ORANGE_HAN这两个变量来区分
- int枚举值一旦改变,必须重新编译客户端代码
//因为int枚举为编译时常量
- 打印int枚举值时只会打印出数字,同时没有方法可以遍历所有的int枚举值常量,也没有方法能够获取int枚举值的个数
34.2 String枚举模式
- 提供了打印上的方便
- 性能没有int枚举模式好,因为依赖字符串的比较
- 初级程序员可能用错,而编译时发现不了,运行时报错
public static final String APPLE_FUJI = "FUJI";
public static void main(String[] args) {
//没使用常量的属性名APPLE_FUJI,而是将字符串常量硬编码到客户端代码
//如果写错了,编译时不报错,运行时可能会有问题
System.out.println("FUJI1");
}
34.3 枚举类型
- Java枚举本质是int值
- 枚举值默认被public static final修饰,不允许人为添加
- 枚举的构造器默认使用private修饰,因此不能被继承
- 客户端无法创建其实例,即枚举类型是实例受控的
- 单例模式本质上是单个元素的枚举类型
34.3.1 优点
- 类型安全:将Apple的枚举值传给Orange的变量会报错
- 不同枚举类型的同名常量可以共处,因为每个枚举类型都有自己的命名空间
- 增加、重新排序枚举类型中的常量,无需重新编译客户端代码
//因为不像int枚举模式一样,将常量值编译到了客户端代码中
- 可以通过调用枚举值的toString方法,将其打印
- 枚举类中可以添加属性和方法
34.3.2 示例
//7. 如果一个枚举类具有普遍适用性,可以将它设计为一个顶层类,例如java.math.RoundingMode,表示十进制小数的舍入模式(四舍五入还是什么),它被用于BigDecimal类,但该API的设计者,还希望程序员你重用这个枚举类,增强自己设计的API与他们设计的API的一致性,因此设计为顶层类
//8. 如果枚举类只被用在特定的顶层类中,那就应该把该枚举类设计为这个顶层类的一个成员类(内部的枚举类)
public enum Planet {
//5. 但删除一个枚举值时,客户端用到这个枚举值的地方重新编译时会失败,如果不重新编译,执行时也会报错,不会像int枚举模式那样,不报错,但给出错误的结果
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);
//1. 设计枚举类的初衷,就是希望它是不可变的类,为了实现这一点,应该将成员变量都设置为final
//2. 最好private修饰,并提供getter方法
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;
}
//6. 如果方法只用在枚举类,或其所在的包中,最好用private或default修饰,除非客户端需要调用该方法,可以使用public、protected
private void wusihan() {
}
public static void main(String[] args) {
//3. 打印一个在地球上1234g的物体,在各个星球上的重量
double earthWeight = Double.parseDouble("1234");
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
//4. 打印枚举类对象p时,默认调用其toString方法,而枚举类的toString方法默认返回其枚举值的字符串,也可以覆盖toString方法, 进行修改
System.out.printf("Weight on %s is %f%n", p, p.surfaceWeight(mass));
}
}
34.4 在枚举类中定义根据不同枚举值有不同表现的方法
34.4.1 switch
- 代码
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;
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语句肯定执行不到,但代码必须存在,否则编译报错。因为该句话不存在,系统认为你如果传入的枚举值不在PLUS、MINUS、TIMES、DIVIDE中,就没有返回值,因此编译不通过
- 当加入新的枚举值,却没给swtich增加相应的条件,编译通过,但执行报错
34.4.2 特定于常量(枚举值)的方法实现:constant-specific method implementation
- 在枚举类中声明一个抽象的apply方法,每个枚举值,用不同的方式覆盖该方法
public enum Operation {
//1. 该括号后的内容,书中叫做特定于常量的类主体(constant-specific class body),其实constant-specific翻译应该是独特的常量,强调每个常量中的方法实现不同
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);
}
34.4.3 特定于常量的方法实现与特定于常量的数据结合,从而方便打印
- 代码
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
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;
}
};
// 1. 当枚举值不同时,其属性symbol的值也不同,因此叫做特定于常量的数据
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
public abstract double apply(double x, double y);
//3. Stream 就如同一个高级版本的 迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返
//4. 以Operation的toString作为key,Operation对象作为value的map
private static final Map<String, Operation> stringToEnum = Stream.of(values())
.collect(Collectors.toMap(Object::toString, e -> e));
//2. fromString方法,可以通过传入枚举值的toString打印的结果,得到该枚举值
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
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));
}
}
- 无法将枚举值,通过自己的构造器,将自身放入映射
//我觉得这个操作是可以的,怀疑作者的本意是想表达,static的这种map,枚举值做不到通过构造器将自身放入,因为枚举构造器压根无法访问枚举的静态域
//新成员,定义一个映射
public Map map = new HashMap();
//构造器
Operation(String symbol) {
this.symbol = symbol;
//将自身放入映射,编译不会报错
map.put("/",this);
}
- 枚举构造器不可以访问枚举的静态域,除非该静态域是编译时常量
private static final String name = "handidiao";
private static final String name1 = new String("handidiao");
Operation(String symbol) {
this.symbol = symbol;
//这是因为在枚举类中,枚举值必须写在最前面,而枚举值默认由public static final修饰,属于静态域,而静态域的初始化,是按顺序的,也就是说,构造器被调用时,实际上你想使用的其他静态域还没被初始化,因此不允许使用
//而如果该静态域为编译时常量,由于编译后,该值被直接替换为一个常量,因此可以使用
System.out.println(name);
//System.out.println(name1);
//1. 这导致构造器无法将自身放入 静态的映射中
//stringToEnum.put("123", DIVIDE);
//2. 这导致构造器中无法访问其他枚举值
//System.out.println(TIMES);
}
34.5 策略枚举
实际上这个方案的本质,是为枚举值,传入一个实例(该实例可以是另一个枚举类的枚举值),然后枚举值的方法中,转为调用该实例的方法,可以减少样板代码
- 用于解决特定于常量的方法实现,所造成的样板代码的增加
- 利用switch语句:新增枚举值,但不维护switch中代码,会造成编译通过,但和自己想要的行为不同
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:
overtimePay = basePay / 2;
break;
default:
overtimePay = minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
return basePay + overtimePay;
}
}
- 特定于常量的方法实现:样板代码过多
enum PayrollDay {
MONDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
TUESDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEDNESDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
THURSDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
FRIDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
SATURDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked * payRate / 2;
}
},
SUNDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked * payRate / 2;
}
};
private static final int MINS_PER_SHIFT = 8 * 60;
int basePay(int minutesWorked, int payRate) {
return minutesWorked * payRate;
}
abstract int overtimePay(int minutesWorked, int payRate);
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
return basePay + overtimePay(minutesWorked, payRate);
}
}
- 用具体实现替代抽象方法overtimePay以减少样板代码:与switch语句缺点相似,新增枚举值(节假日),如果不覆盖overtimePay方法,可能造成行为错误
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
//只对SATURDAY、SUNDAY覆盖overtimePay实现
SATURDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked * payRate / 2;
}
},
SUNDAY {
@Override
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked * payRate / 2;
}
};
private static final int MINS_PER_SHIFT = 8 * 60;
int basePay(int minutesWorked, int payRate) {
return minutesWorked * payRate;
}
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
return basePay + overtimePay(minutesWorked, payRate);
}
}
- 策略枚举模式
enum PayrollDay {
//1. 每当新增一个枚举值,必须指定其使用的策略。更加安全灵活
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY),
FRIDAY(PayType.WEEKDAY), SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;
PayrollDay(PayType payType) {
this.payType = payType;
}
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);
}
}
}
34.6 switch中使用枚举值的场景
- 之前讲述了对于枚举类而言,switch语句并不适合作为特定于常量的行为的实现
- 我理解书中表达意思是,如果一个方法在枚举类A外部,而该方法想根据A的不同枚举值,有不同的逻辑实现,就应该使用swtich,因为这种情况,你没法做到设定一个抽象方法, 不同枚举值用不同的逻辑覆盖它,因为不在一个类里,根本没法覆盖
- 例如inverse方法,如果不在枚举类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);
}
}
34.7 最佳实践
- 枚举在使用时与int常量性能相当,只不过在装载和初始化时,需要更多的时间和空间
- 使用枚举的场景:需要一组常量,且这些常量在编译时就知道所有可能的值
- 天然枚举类型:行星、一周的天数、棋子的数目
- 其他:菜单选项、操作代码、命令行标记
- 枚举是为了二进制兼容性设计的
- 枚举与int枚举模式比较
- 可行强
- 安全
- 功能强大
- 多个枚举值,同时共享相同行为(周一到周五同一个工资计算方法),需考虑策略枚举
- 特定于常量的方法优于swtich方法
- 大部分枚举不提供构造器和属性,剩下一部分将枚举值与其属性关联,并根据这个属性,提供独特的方法,只有很少一部分将多种行为,与同一个方法关联
35 用实例的属性替代枚举值的ordinal
35.1 使用枚举类的ordinal方法,表示该枚举值关联的int值
- 枚举类都继承Enum,拥有ordinal方法,该返回枚举值的索引
- Ensemble代码中,枚举值表示演奏音乐的类型,例如SOLO表示独奏、QUARTET表示四重奏
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET;
//1. 该方法希望返回该演奏类型需要的音乐家数,编写该方法的程序员观察到,正好目前为止,演奏类型所音乐家数,正好是他们的索引+1,因此想使用ordinal方法获取其索引值,再+1
//2. 这样做有局限性
//a. 如果想加入一个双四重奏,实际上需要八个人,但索引值为7的位置,已经有了枚举值
//b. 如果当前的枚举值顺序颠倒,该方法将失效
//c. 如果想加入一个三四重奏,需要12个音乐家,即为了让该方法生效,其枚举值,应该排在第12位,但目前只有10个枚举值,那么意味着,如果想让该方法生效,你必须补充一个没用的、作为第11个枚举值
public int numberOfMusicians() {
return ordinal() + 1;
}
}
35.2 用实例的属性替代ordinal
- 如果枚举值想和一个int值关联,不要使用ordinal方法,而是将这个关联的int值作为枚举值的属性存放
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;
}
}
35.3 最佳实践
- 大多数程序员不需要使用ordinal方法,只在需要设计那种基于枚举的通用数据结构(EnumSet、EnumMap)时才使用,除非你在编写这种数据结构,否则不要使用
36 用EnumSet代替位域
36.1 位域
36.1.1 概念
- 位域在本文中指的是一种表示集合的方法,它利用二进制的特性,来表示几个不同整数值的集合,即利用二进制某几个位(域)上的值(位)来表示存放的数据,比如15,在二进制中为1111,那么它可以表示二进制为1000、0100、0010、0001的四个数的集合,即1、2、4、8的集合
- 某些信息在存储时,并不需要占用一个完整的字节, 而只需占几个或一个二进制位,为了节省存储空间,出现了位域这种表示方法
36.1.1 使用
例如,在一个系统中,用户一般有查询(Select)、新增(Insert)、修改(Update)、删除(Delete)四种权限,四种权限有多种组合方式,也就是有16中不同的权限状态
- 常规
//用四个boolean类型变量来保存每种权限状态
public class Permission {
// 是否允许查询
private boolean allowSelect;
// 是否允许新增
private boolean allowInsert;
// 是否允许删除
private boolean allowDelete;
// 是否允许更新
private boolean allowUpdate;
// 省略Getter和Setter
}
- 位域
//用一个二进制数即可,每一位来表示一种权限,0表示无权限,1表示有权限
public class NewPermission {
// 是否允许查询,二进制第1位,0表示否,1表示是
public static final int ALLOW_SELECT = 1 << 0; // 0001
// 是否允许新增,二进制第2位,0表示否,1表示是
public static final int ALLOW_INSERT = 1 << 1; // 0010
// 是否允许修改,二进制第3位,0表示否,1表示是
public static final int ALLOW_UPDATE = 1 << 2; // 0100
// 是否允许删除,二进制第4位,0表示否,1表示是
public static final int ALLOW_DELETE = 1 << 3; // 1000
// 存储目前的权限状态,该值的二进制表示法为1111,即同时拥有四个权限
private int flag;
/**
* 重新设置权限
*/
public void setPermission(int permission) {
flag = permission;
}
/**
* 添加一项或多项权限,即书中所说的联合(union)操作
*/
public void enable(int permission) {
flag |= permission;
}
/**
* 删除一项或多项权限,即书中所说的交集(intersection)操作
*/
public void disable(int permission) {
flag &= ~permission;
}
/**
* 是否拥某些权限
*/
public boolean isAllow(int permission) {
return (flag & permission) == permission;
}
/**
* 是否禁用了某些权限
*/
public boolean isNotAllow(int permission) {
return (flag & permission) == 0;
}
/**
* 是否仅仅拥有某些权限
*/
public boolean isOnlyAllow(int permission) {
return flag == permission;
}
}
- 设置权限时代码比对
//常规
permission.setAllowSelect(true);
permission.setAllowInsert(true);
permission.setAllowUpdate(false);
permission.setAllowDelete(false);
//位域
permission.setPermission(NewPermission.ALLOW_SELECT | NewPermission.ALLOW_INSERT);
36.2 位域的缺点
- 因为位域实际上是用整型值的二进制格式,表示一组整型的值,既然是一组整型的值,就一定会具有int枚举常量的所有缺点
- 位域以数字形式打印时,翻译位域比翻译int枚举常量更困难
- 无法遍历位域中所有元素
- 编写API时,必须预测好位域的二进制有多少位,然后根据这个信息给位域选定对应的类型,一般为int/long,一旦选好类型,在没有修改API的情况下,该位域将无法存放超出其位宽度(32/64)的整型值,也就是说上例中位域flag中,最多存放32种状态
36.3 EnumSet替代位域
- EnumSet提供了更丰富的功能,和类型安全性
- 如果EnumSet中存放的元素少于64个,那么其底层是使用一个long型的位域实现的,其removeAll、retainAll方法,都是利用算法实现的,因此性能与位域性能相同
- EnumSet相当于帮你解决了使用位域时,容易产生的错误、以及难以阅读的代码
- 代码
import java.util.Set;
public class PermissionEnumSet {
public enum Authority {
allowSelect, allowInsert, allowDelete, allowUpdate;
//此处考虑到客户端可能需要传递一些其他Set的实现,所以没用EnumSet定义
public void applyStyles(Set<Authority> authorities) {
//省略业务代码
}
}
}
36.4 最佳实践
- EnumSet同时具有位域的简洁性、性能优势,与枚举类型的所有优点,应使用EnumSet替代位域
- 截止Java9无法创建不可变的EnumSet,可以用Collections.unmodifiableSet封装EnumSet,得到不可变的集合,但性能会受影响
37 不要用ordinal作为Map中的键,或数组中的索引,使用枚举值本身替代
37.1 几种不同方式的区别
import java.util.Arrays;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
//表示植物
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) {
//假设有个一个花园(Plant[]),它里面包含着多种植物
Plant[] garden = new Plant[5];
garden[0] = new Plant("植物一", Plant.LifeCycle.ANNUAL);
garden[1] = new Plant("植物二", Plant.LifeCycle.ANNUAL);
garden[2] = new Plant("植物三", Plant.LifeCycle.PERENNIAL);
garden[3] = new Plant("植物四", Plant.LifeCycle.PERENNIAL);
garden[4] = new Plant("植物五", Plant.LifeCycle.BIENNIAL);
//1. ordinal作为数组的下标
//a. 数组无法与泛型兼容,会有异常警告
Set<Plant>[] plantsByLifeCycle = 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);
//b. 数组(plantsByLifeCycle)不知道它的索引(i)代表什么,必须手工标注(Plant.LifeCycle.values()[i]),打印复杂
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
//c. int无法提供类型安全,这里数组下标正常只能放入LifeCycle的枚举值的ordinal,如果你传入了Orange的枚举值的ordinal,那么如果该值大于数组中元素个数,会出现ArrayIndexOutOfBoundsException,如果小于,可能导致打印出的内容和想要的不一致
// System.out.println(plantsByLifeCycle[5]);
//2. ordinal作为Map的key:同样打印复杂、无法提供类型安全
Map<Integer, Set<Plant>> plantsByLifeCycleMap = new HashMap<Integer, Set<Plant>>();
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycleMap.put(lc.ordinal(), new HashSet<>());
for (Plant p : garden)
plantsByLifeCycleMap.get(p.lifeCycle.ordinal()).add(p);
//3. ordinal作为Map的key:解决了所有缺点。注意EnumMap的构造器中传入的是该映射中key的类型令牌。内部是使用以ordinal作为索引的数组实现的
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycleEnumMap = new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycleEnumMap.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycleEnumMap.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycleEnumMap);
//4. Stream简化创建EnumMap过程
//a. groupingBy函数,第一个参数为Map的key的来源,第二个参数为创建出的Map的类型,第三个参数为Map的value的来源
//b. Stream创建EnumMap与正常创建EnumMap区别:如果花园中只包含一年生和多年生植物,不包含二年生植物,Stream这种方式创建的EnumMap对象中只有两组键值,而正常方式,会有三组
EnumMap<Plant.LifeCycle, Set<Plant>> plantsByLifeCycleStream = Arrays.stream(garden).collect(Collectors.groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(Plant.LifeCycle.class), Collectors.toSet()));
System.out.println(plantsByLifeCycleStream);
}
}
37.2 以ordinal作为索引的二维数组的修改方案
- ordinal作为索引的二维数组
public enum Phase {
//固、液、气
SOLID, LIQUID, GAS;
public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
//1. 这个数组中元素个数必须为Phase中枚举值(3)的平方数,即使里面有很多是null,但不可以去掉
//2. Phase,或Transition中枚举值修改后,这个数组必须重新维护,如果没维护,很有可能报错,或不报错,但和自己想要的结果不一致
//3. int值不具有类型安全性,一旦用错了int值,会报错、与想要结果不一致
private static final Transition[][] TRANSITIONS = {
{ null, MELT, SUBLIME },
{ FREEZE, null, BOIL },
{ DEPOSIT, CONDENSE, null }
};
public static Transition from(Phase from, Phase to) {
return TRANSITIONS[from.ordinal()][to.ordinal()];
}
}
}
- EnumMap<Phase,Map<Phase,Transaction>>替代
import static java.util.stream.Collectors.*;
import java.util.EnumMap;
import java.util.Map;
import java.util.stream.Stream;
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;
}
//groupingBy函数用于返回一个收集Map的Collector,第一个对象表示该Map的键的来源,第二个参数表示创建出的Map的类型,第三个参数表示Map中值的来源
//toMap函数也用于返回一个收集Map的Collector,第一个参数表示该Map的键的来源,第二个参数表示该Map的值的来源,第三个参数用于将并行流中结果进行合并,第四个参数表示创建出的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);
}
}
}
- 为Phase新增枚举值PLASMA(离子),Transition新增DEIONIZATION(离子变气体)、IONIZATION(气体变离子)
- 第一种方案:TRANSITIONS数组需改为一个4*4的数组
- 第二种方案
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), //新增 DEIONIZATION(PLASMA,GAS),IONIZATION(GAS,PLASMA); ... } ... }
37.3 最佳实践
- EnumMap内部是使用ordinal为索引的数组实现的,因此性能与ordinal为索引的数组相同,又避免了其所有缺点,因此尽量使用EnumMap
- 尽量不要使用Enum的ordinal方法
38 用接口模拟枚举类的继承
这样做可以达到自己编写枚举类的目的,比如一个方法传入参数为Operation接口,那么你的枚举类,只要实现该接口,就可以传入该方法
38.1 类型安全枚举模式
- 类型安全枚举模式
public class Food{
int size;
public Food(int size) {
this.size = size;
}
//java5之前,使用这种模式来替代枚举类,这种模式类型安全,且可以被继承
public static final Food food_1 = new Food(5);
}
- 类型安全枚举模式的继承:基本上这种继承后来被证明都不是什么好点子
public class Apple extends Food{
public Apple(int size) {
super(size);
}
//1. apple_1这个实例,可以转为Food,但Food中的food_1却不可以转为Apple,会造成使用上的混乱
//2. 没有好的方法直接获得Food和Apple下所有的枚举值(food_1和apple_1)
public static final Apple apple_1 = new Apple(15);
}
- 枚举类的继承:无法做到,因为默认枚举类已经继承了Enum
38.2 模拟枚举继承的效果
- 需要模拟枚举继承的效果的使用场景:枚举值表示在机器上的某些操作,比如计算器上的加、减、乘、除
- 这种场景下,用户可能需要提供自己的操作,比如原客户端只提供了加减乘除,需要用户自己提供求幂、求余的实现
- 模拟枚举继承时,原类型称为基本枚举,自定义的枚举叫做扩展枚举,因为它效果上是基本枚举的一个扩充
//比如你现在有一个方法,需要传入一个枚举类型BasicOperation的枚举值(加减乘除),那么当你想自己定义一个枚举类(求幂、求余)传进来,是做不到的,因为枚举类无法被继承,因此没法自己定义一个ExtendedOperation继承BasicOperation
private static void printWu(BasicOperation a) {
System.out.println(a);
}
- 模拟方案:利用枚举类可以实现接口这一特性
//1. 定义Operation接口
public interface Operation {
double apply(double x, double y);
}
//2. 原类型BasicOperation实现Operation接口
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;
}
}
//3. 自定义枚举类型ExtendedOperation,也实现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;
}
}
//4. 修改方法签名中,需传入的参数类型成接口类型,那么所有使用原操作(加减乘除)地方,都可以传入一个新的操作(幂、余)
//5. 这样,原本该方法,只能传入枚举类BasicOperation中的枚举值,现也可以传入自定义的枚举类ExtendedOperation中的枚举值
private static void printWu(Operation a) {
System.out.println(a);
}
38.3 为方法传入扩展枚举的所有枚举值
38.3.1 通过传入类型令牌实现
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(ExtendedOperation.class, x, y);
}
//T extends Enum<T> & Operation,确保T为枚举类型,同时实现了Operation接口
private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
//getEnumConstants为Class的方法,用于返回该对象的所有枚举值组成的数组
for (Operation op : opEnumType.getEnumConstants())
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
38.3.2 通过传入一个集合
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);
}
//更加灵活,可以将Operation下所有枚举类的枚举值放到一起
//猜测作者的意思是,没法将所有枚举类的枚举值组成一个EnumSet,或EnumMap,传入这个方法,因为不同枚举类下的枚举值,没法组成一个EnumSet
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));
}
38.4 模拟枚举继承的缺点
- 基本枚举和扩展枚举的实现,无法共用,必须分别实现Operation中的apply方法
- 如果共享代码(apply实现)比较多,可以考虑将这些方法逻辑封装在一个辅助类中,或静态辅助方法中
38.5 Java类库中的应用
- Java类库中的java.nio.file.LinkOption枚举类,同时实现了CopyOption、OpenOption接口,想扩展这个枚举类,可以实现CopyOption、OpenOption接口即可
39 注解优先于命名模式
39.1 命名模式
39.1.1 定义
- 命名模式,可以理解为,某个工具或框架,需要特殊处理某些程序元素时,要求该程序元素以指定的命名规则来命名,这种设计方案就叫做明明模式
- 比如Java4之前,JUNIT,要求使用它的客户端,必须使用test作为其测试方法名称的开头,才能正常运行
39.1.2 命名模式缺点
- 文字拼写错误会导致JUNIT会忽略该测试方法,但编译器不会提示:例如将testSafetyOverride不小心写成tsetSafetyOverride
- 无法确保其只应用于指定的元素上,例如Annotation可以指定只能加在类上或方法上,而对于JUNIT,可能有客户自定义了一个类,以Test开头,例如TestSafetyMechanisms,希望JUNIT自动测试其内所有方法,但实际上JUNIT根本做不到
- 无法为需要特殊处理的程序元素,指定参数,例如Annotation,可以指定成员为一种异常类型,那么我们就可以做到测试方法中,抛出这个指定的异常时,才算测试成功,命名模式虽然也能做到,但需要将方法名定义为testRuntimeException这种格式,然后拆字符串判断,这样代码不雅观,也脆弱,编译器无法检测到你拼写的是否正确,如果拼错了,比如拼成testRtimeException,得等到运行时,才会报错
39.2 Annotation替代命名模式
39.2.1 自定义一个测试框架
- Test:注释
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
- Sample:被测试的类
//1. 一会要编写一个测试类RunTests,只测试@Test注释的方法,且测试的方法如果未抛异常,计入为成功数(Passed),抛异常时,计入失败数,并打印异常,非static方法时,也计入失败
public class Sample {
@Test
public static void m1() {
}
public static void m2() {
}
@Test
public static void m3() {
throw new RuntimeException("Boom");
}
public static void m4() {
}
//2. 其实可以通过编写一个注解处理器(annotation processor),来指定编译器限制Test注释只能用在static方法上,本例中在运行时实现这个功能
@Test
public void m5() {
}
public static void m6() {
}
@Test
public static void m7() {
throw new RuntimeException("Crash");
}
public static void m8() {
}
}
- RunTests:测试主方法
//打印结果
//public static void Sample.m3() failed: java.lang.RuntimeException: Boom
//Invalid @Test: public void Sample.m5()
//public static void Sample.m7() failed: java.lang.RuntimeException: Crash
//Passed: 1, Failed: 3
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName("Sample");
for (Method m : testClass.getDeclaredMethods()) {
//判断是否包含指定类型的注释
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
//InvocationTargetException只能获取反射调用方法时,方法中产生的异常
} catch (InvocationTargetException wrappedExc) {
//该方法可以从InvocationTargetException中获取真正在方法中抛出的异常
Throwable exc = wrappedExc.getCause();
System.out.println(m + " failed: " + exc);
} catch (Exception exc) {
//实现了非static方法,执行失败
//非static方法的m5方法,被反射调用时,因为后面参数为null,即调用该方法的对象为null,会报空指针,根本无法进入方法中,因此不会被InvocationTargetException获取,即Exception异常
//static方法由于不需要调用的对象,因此即使invoke后第一个参数为null,也不会抛出异常
System.out.println("Invalid @Test: " + m);
}
}
}
System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}
}
39.2.2 控制测试方法在抛出指定一种异常时打印
- ExceptionTest:注释
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
- Sample2:被测试的类
//1. 一会要修改测试类RunTests,只测试@ExceptionTest注释的方法,且测试的方法如果抛ArithmeticException异常,计入为成功数(Passed),抛其他异常或不抛异常时,计入失败数,并打印异常,非static方法时,也计入失败
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() {
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() {
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() {
}
}
- RunTests:测试主方法修改的内容
//打印结果:
//Test public static void Sample2.m3() failed: no exception
//Test public static void Sample2.m2() failed: expected //java.lang.ArithmeticException, got //java.lang.ArrayIndexOutOfBoundsException: 1
//Passed: 1, Failed: 2
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);
}
}
39.2.3 控制测试方法在抛出某些异常之一时打印
- ExceptionTest:注释
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
//注意此处异常由Throwable改为了Exception,缩小了范围
Class<? extends Exception>[] value();
}
- Sample2:被测试类新增方法
//这样注释,doublyBad方法抛出IndexOutOfBoundsException和NullPointerException时都计入为成功数。大括号表示数组
@ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
list.addAll(5, null);
}
- RunTests:测试主方法修改
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);
}
}
39.2.4 Java8的重复注释
提升源码可读性,看起来好像将同一个注释的多个实例应用到同一个程序元素上,但在声明和处理注释时,会增加样板代码,且容易出错
- ExceptionTest:注释
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
//指定该注释允许重复注释,且指定其容器为注释ExceptionTestContainer
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Exception> value();
}
- ExceptionTestContainer:注释的容器
import java.lang.annotation.*;
//容器的Retention需要大于等于其包含的注释的Retention,Target也要符合规定,否则编译会报错
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
//容器里面只包含一个value属性存放ExceptionTest数组
ExceptionTest[] value();
}
- Sample2:被测试类修改doublyBad方法
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
List<String> list = new ArrayList<>();
list.addAll(5, null);
}
- RunTests:测试主方法修改
//1. 重复的注释,使用时,底层会产生一个合成注释ExceptionTestContainer,getAnnotationsByType方法掩盖了这事实,而isAnnotationPresent却暴露了这个问题
//2. isAnnotationPresent(ExceptionTest.class)方法, 对于使用重复注解的方法doublyBad,会返回false,而对于使用了一遍该注释的方法m2,会返回true
//3. isAnnotationPresent(ExceptionTestContainer.class)正好相反
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;
//4. getAnnotationsByType方法,无论是否使用了重复注释,都会将所有的ExceptionTest注释返回
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);
}
}
39.3 最佳实践
- 应用注释替代命名模式
- 一般只有平台框架程序员才自定义注释,但所有程序员都应使用Java平台提供的预定义的注释,同时应该考虑IDE或静态分析工具提供的注释,这些注释可以提升由这些工具所提供的诊断信息的质量,但他们尚未标准化,当你一旦换了工具,或出现标准,那么需要有大量的工作要做
40 坚持使用Override注释
40.1 @Override使用
import java.util.HashSet;
import java.util.Set;
public class Bigram {
private final char first;
private final char second;
public Bigram(char first, char second) {
this.first = first;
this.second = second;
}
//1. 该方法并没有对Object的equals构成重写,因为方法名和形参列表并不完全相同,实际上是与Object的equals构成了重载
//4. 对该方法使用@Override注释,如果该方法未能重写某方法,编译器就会报错,这样程序员可以立即意识到自己犯的错误
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++)
//2. Set去重时,是根据equals和hashCode完全相同,才会被去掉,但用的是Object的equals方法
s.add(new Bigram(ch, ch));
//3. 因此最后实际上插入的不是26个字母,而是260个
System.out.println(s.size());
}
}
40.2 最佳实践
- 非抽象类继承/实现抽象类/接口
- 重写抽象方法:无需使用@Override注释(例如接口中的default方法、类中的非抽象方法),因为编译器在发现你没有对抽象方法提供具体实现时,会自动报错
- 重写非抽象方法:使用@Override
- 抽象类/接口继承/实现抽象类/接口
- 无论重写的是抽象还是非抽象方法,都应提供@Override注释,例如Set接口对于Collection接口,不想添加任何的新功能,你对该接口的所有重写的方法,都使用@Override,可以确保你没有新增功能
- 大部分IDE在你选择重写某个方法时,会为你自动加上@Override注释
- 大部分IDE甚至可以启用代码检查功能,这个功能会检查你的代码:当代码覆盖了超类方法,但是没有使用@Override注释时,会产生警告,防止你无意识的覆盖
41 用标记接口定义类型
41.1 标记接口
- 标记接口:一种内部没有任何方法的接口,它只是为了表明它标记(实现它)的类,拥有某些限制,或表明它标记的类的实例,可以被其他类中的某个方法正确处理。例如标记接口Serializable表示它标记的类的实例,可以被ObjectOutputStream.writeObject方法正确处理
//以下为JDK中ObjectOutputStream.writeObject方法中,对该标记接口处理的部分
if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
} else {
if (extendedDebugInfo) {
throw new NotSerializableException(
cl.getName() + "\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}
- 由上面例子可以看出,标记接口实际上和标记注释功能类似,都可以为工具或框架,特殊处理某些程序元素时,提供帮助
41.2 标记接口与标记注释区别
- 标记接口可以在编译期被发现问题,而标记注释必须等到运行时
//例如ObjectOutputStream的writeObject方法,可以改写成如下方式,这样如果传入方法的参数没有实现标记接口Serializable,编译无法通过
public final void writeObject(Serializable obj)
- 标机接口可以被更加精确的锁定:例如可以只标记实现了某个接口的类/接口,这种标记接口叫做有限制的标记接口。而标记注释,一旦将target设置为ElementType.TYPE,那么它可以标记所有的类或接口
//例如Marker只想标记(被实现)那些,实现了Foo接口的接口/类,拥有特殊的功能,此时你可以用Marker继承Foo接口,这样实际上,只要用Marker标记的类,就一定也实现/继承了Foo接口
public interface Marker extends Foo{
}
//对类Han进行标记,标记它拥有特殊功能,Han实际上被动的继承了Foo接口,Marker也就达到了目的,即其只能标记,实现Foo接口的类/接口
public class Han implements Marker{
}
//又比如Set,也可以近似(因为它里面定义了方法,且改动了Collection的方法合约,比如Set的add方法不允许放入重复数据)看做这种"有限制的标记接口",它标记的类/接口,必须需实现Collection接口
41.3 最佳实践
- 如果你想编写的类型中,不需要任何方法,应该使用标记接口
- 如果想要标记程序元素,而不是接口/类,使用标记注释
- 如果在一个重度使用标记注释的框架中,因为已经提供了处理标记注释的相关的功能,应该直接用这些标记注释来进行标记
- 当你编写一个target为ElementType.TYPE的标记注释时,应该仔细考虑是否能用标记接口替代