编写高质量代码:改善Java程序的151个建议(第6章:枚举和注解___建议88~92)
阅读目录
建议 88:用枚举实现工厂方法模式更简洁
工厂方法模式 (Factory Method Pattern) 是 "创建对象的接口,让子类决定实例化哪一个类,并使一个类的实例化延迟到其它子类"。工厂方法模式在我们的开发中经常会用到。下面以汽车制造为例,看看一般的工厂方法模式是如何实现的,代码如下:
1 //抽象产品 2 interface Car{ 3 4 } 5 //具体产品类 6 class FordCar implements Car{ 7 8 } 9 //具体产品类 10 class BuickCar implements Car{ 11 12 } 13 //工厂类 14 class CarFactory{ 15 //生产汽车 16 public static Car createCar(Class<? extends Car> c){ 17 try { 18 return c.newInstance(); 19 } catch (InstantiationException | IllegalAccessException e) { 20 e.printStackTrace(); 21 } 22 return null; 23 } 24 }
这是最原始的工厂方法模式,有两个产品:福特汽车和别克汽车,然后通过工厂方法模式来生产。有了工厂方法模式,我们就不用关心一辆车具体是怎么生成的了,只要告诉工厂 "给我生产一辆福特汽车" 就可以了,下面是产出一辆福特汽车时客户端的代码:
public static void main(String[] args) { //生产车辆 Car car = CarFactory.createCar(FordCar.class);}
这就是我们经常使用的工厂方法模式,但经常使用并不代表就是最优秀、最简洁的。此处再介绍一种通过枚举实现工厂方法模式的方案,谁优谁劣你自行评价。枚举实现工厂方法模式有两种方法:
(1)、枚举非静态方法实现工厂方法模式
我们知道每个枚举项都是该枚举的实例对象,那是不是定义一个方法可以生成每个枚举项对应产品来实现此模式呢?代码如下:
1 enum CarFactory { 2 // 定义生产类能生产汽车的类型 3 FordCar, BuickCar; 4 // 生产汽车 5 public Car create() { 6 switch (this) { 7 case FordCar: 8 return new FordCar(); 9 case BuickCar: 10 return new BuickCar(); 11 default: 12 throw new AssertionError("无效参数"); 13 } 14 } 15 16 }
create 是一个非静态方法,也就是只有通过 FordCar、BuickCar 枚举项才能访问。采用这种方式实现工厂方法模式时,客户端要生产一辆汽车就很简单了,代码如下:
public static void main(String[] args) { // 生产车辆 Car car = CarFactory.BuickCar.create();}
(2)、通过抽象方法生成产品
枚举类型虽然不能继承,但是可以用 abstract 修饰其方法,此时就表示该枚举是一个抽象枚举,需要每个枚举项自行实现该方法,也就是说枚举项的类型是该枚举的一个子类,我们俩看代码:
1 enum CarFactory { 2 // 定义生产类能生产汽车的类型 3 FordCar{ 4 public Car create(){ 5 return new FordCar(); 6 } 7 }, 8 BuickCar{ 9 public Car create(){ 10 return new BuickCar(); 11 } 12 }; 13 //抽象生产方法 14 public abstract Car create(); 15 }
首先定义一个抽象制造方法 create,然后每个枚举项自行实现,这种方式编译后会产生 CarFactory 的匿名子类,因为每个枚举项都要实现 create 抽象方法。客户端调用与上一个方案相同,不再赘述。
大家可能会问,为什么要使用枚举类型的工厂方法模式呢?那是因为使用枚举类型的工厂方法模式有以下三个优点:
- 避免错误调用的发生:一般工厂方法模式中的生产方法 (也就是 createCar 方法),可以接收三种类型的参数:类型参数 (如我们的例子)、String 参数 (生产方法中判断 String 参数是需要生产什么产品)、int 参数 (根据 int 值判断需要生产什么类型的的产品),这三种参数都是宽泛的数据类型,很容易发生错误 (比如边界问题、null 值问题),而且出现这类错误编译器还不会报警,例如:
public static void main(String[] args) { // 生产车辆 Car car = CarFactory.createCar(Car.class);}
Car 是一个接口,完全合乎 createCar 的要求,所以它在编译时不会报任何错误,但一运行就会报出 InstantiationException 异常,而使用枚举类型的工厂方法模式就不存在该问题了,不需要传递任何参数,只需要选择好生产什么类型的产品即可。
- 性能好,使用简洁:枚举类型的计算时以 int 类型的计算为基础的,这是最基本的操作,性能当然会快,至于使用便捷,注意看客户端的调用,代码的字面意思就是 "汽车工厂,我要一辆别克汽车,赶快生产"。
- 降低类间耦合:不管生产方法接收的是 Class、String 还是 int 的参数,都会成为客户端类的负担,这些类并不是客户端需要的,而是因为工厂方法的限制必须输入的,例如 Class 参数,对客户端 main 方法来说,他需要传递一个 FordCar.class 参数才能生产一辆福特汽车,除了在 create 方法中传递参数外,业务类不需要改 Car 的实现类。这严重违背了迪米特原则 (Law of Demeter 简称 LoD), 也就是最少知识原则:一个对象应该对其它对象有最少的了解。
而枚举类型的工厂方法就没有这种问题了,它只需要依赖工厂类就可以生产一辆符合接口的汽车,完全可以无视具体汽车类的存在。
建议 89:枚举项的数量限制在 64 个以内
为了更好地使用枚举,Java 提供了两个枚举集合:EnumSet 和 EnumMap,这两个集合使用的方法都比较简单,EnumSet 表示其元素必须是某一枚举的枚举项,EnumMap 表示 Key 值必须是某一枚举的枚举项,由于枚举类型的实例数量固定并且有限,相对来说 EnumSet 和 EnumMap 的效率会比其它 Set 和 Map 要高。
虽然 EnumSet 很好用,但是它有一个隐藏的特点,我们逐步分析。在项目中一般会把枚举用作常量定义,可能会定义非常多的枚举项,然后通过 EnumSet 访问、遍历,但它对不同的枚举数量有不同的处理方式。为了进行对比,我们定义两个枚举,一个数量等于 64,一个是 65(大于 64 即可,为什么是 64 而不是 128,512 呢,一会解释),代码如下:
1 //普通枚举项,数量等于 64 2 enum Const{ 3 A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z, 4 AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ, 5 AAA,BBB,CCC,DDD,EEE,FFF,GGG,HHH,III,JJJ,KKK,LLL 6 } 7 //大枚举,数量超过 64 8 enum LargeConst{ 9 A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z, 10 AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ, 11 AAAA,BBBB,CCCC,DDDD,EEEE,FFFF,GGGG,HHHH,IIII,JJJJ,KKKK,LLLL,MMMM 12 }
Const 的枚举项数量是 64,LagrgeConst 的枚举项数量是 65, 接下来我们希望把这两个枚举转换为 EnumSet,然后判断一下它们的 class 类型是否相同,代码如下:
1 public class Client89 { 2 public static void main(String[] args) { 3 EnumSet<Const> cs = EnumSet.allOf(Const.class); 4 EnumSet<LargeConst> lcs = EnumSet.allOf(LargeConst.class); 5 //打印出枚举数量 6 System.out.println("Const 的枚举数量:"+cs.size()); 7 System.out.println("LargeConst 的枚举数量:"+lcs.size()); 8 //输出两个 EnumSet 的 class 9 System.out.println(cs.getClass()); 10 System.out.println(lcs.getClass()); 11 } 12 }
程序很简单,现在的问题是:cs 和 lcs 的 class 类型是否相同?应该相同吧,都是 EnumSet 类的工厂方法 allOf 生成的 EnumSet 类,而且 JDK API 也没有提示 EnumSet 有子类。我们来看看输出结果:
很遗憾,两者不相等。就差一个元素,两者就不相等了?确实如此,这也是我们重点关注枚举项数量的原因。先来看看 Java 是如何处理的,首先跟踪 allOf 方法,其源码如下:
1 public static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType) { 2 //生成一个空 EnumSet 3 EnumSet<E> result = noneOf(elementType); 4 //加入所有的枚举项 5 result.addAll(); 6 return result; 7 }
allOf 通过 noneOf 方法首先生成了一个 EnumSet 对象,然后把所有的枚举都加进去,问题可能就出在 EnumSet 的生成上了,我们来看看 noneOf 的源码:
1 public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) { 2 //获得所有的枚举项 3 Enum[] universe = getUniverse(elementType); 4 if (universe == null) 5 throw new ClassCastException(elementType + "not an enum"); 6 //枚举数量小于等于 64 7 if (universe.length <= 64) 8 return new RegularEnumSet<>(elementType, universe); 9 else 10 //枚举数量大于 64 11 return new JumboEnumSet<>(elementType, universe); 12 }
看到这里,恍然大悟,Java 原来是如此处理的:当枚举项数量小于等于 64 时,创建一个 RegularEnumSet 实例对象,大于 64 时则创建一个 JumboEnumSet 实例对象。
为什么要如此处理呢?这还要看看这两个类之间的差异,首先看 RegularEnumSet 类,源码如下:
1 class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> { 2 private static final long serialVersionUID = 3411599620347842686L; 3 /** 4 * Bit vector representation of this set. The 2^k bit indicates the 5 * presence of universe[k] in this set. 6 */ 7 //记录所有的枚举号,注意是 long 型 8 private long elements = 0L; 9 //构造函数 10 RegularEnumSet(Class<E>elementType, Enum[] universe) { 11 super(elementType, universe); 12 } 13 14 //加入所有元素 15 void addAll() { 16 if (universe.length != 0) 17 elements = -1L >>> -universe.length; 18 } 19 20 //其它代码略 21 }
我们知道枚举项的排序值 ordinal 是从 0、1、2...... 依次递增的,没有重号,没有跳号,RegularEnumSet 就是利用这一点把每个枚举项的 ordinal 映射到一个 long 类型的每个位置上的,注意看 addAll 方法的 elements 元素,它使用了无符号右移操作,并且操作数是负值,位移也是负值,这表示是负数 (符号位是 1) 的 "无符号左移":符号位为 0,并补充低位,简单的说,Java 把一个不多于 64 个枚举项映射到了一个 long 类型变量上。这才是 EnumSet 处理的重点,其他的 size 方法、contains 方法等都是根据 elements 方法等都是根据 elements 计算出来的。想想看,一个 long 类型的数字包含了所有的枚举项,其效率和性能能肯定是非常优秀的。
我们知道 long 类型是 64 位的,所以 RegularEnumSet 类型也就只能负责枚举项的数量不大于 64 的枚举 (这也是我们以 64 来举例,而不以 128,512 举例的原因),大于 64 则由 JumboEnumSet 处理,我们看它是怎么处理的:
1 class JumboEnumSet<E extends Enum<E>> extends EnumSet<E> { 2 private static final long serialVersionUID = 334349849919042784L; 3 4 /** 5 * Bit vector representation of this set. The ith bit of the jth 6 * element of this array represents the presence of universe[64*j +i] 7 * in this set. 8 */ 9 //映射所有的枚举项 10 private long elements[]; 11 12 // Redundant - maintained for performance 13 private int size = 0; 14 15 JumboEnumSet(Class<E>elementType, Enum[] universe) { 16 super(elementType, universe); 17 //默认长度是枚举项数量除以 64 再加 1 18 elements = new long[(universe.length + 63) >>> 6]; 19 } 20 21 void addAll() { 22 //elements 中每个元素表示 64 个枚举项 23 for (int i = 0; i < elements.length; i++) 24 elements[i] = -1; 25 elements[elements.length - 1] >>>= -universe.length; 26 size = universe.length; 27 } 28 }
JumboEnumSet 类把枚举项按照 64 个元素一组拆分成了多组,每组都映射到一个 long 类型的数字上,然后该数组再放置到 elements 数组中,简单来说 JumboEnumSet 类的原理与 RegularEnumSet 相似,只是 JumboEnumSet 使用了 long 数组容纳更多的枚举项。不过,这样的程序看着会不会觉得郁闷呢?其实这是因为我们在开发中很少使用位移操作。大家可以这样理解:RegularEnumSet 是把每个枚举项映射到一个 long 类型数字的每个位上,JumboEnumSet 是先按照 64 个一组进行拆分,然后每个组再映射到一个 long 类型数字的每个位上。
从以上的分析可知,EnumSet 提供的两个实现都是基本的数字类型操作,其性能肯定比其他的 Set 类型要好的多,特别是 Enum 的数量少于 64 的时候,那简直就是飞一般的速度。
注意:枚举项数量不要超过 64,否则建议拆分。
建议 90:小心注解继承
Java 从 1.5 版本开始引入注解 (Annotation), 其目的是在不影响代码语义的情况下增强代码的可读性,并且不改变代码的执行逻辑,对于注解始终有两派争论,正方认为注解有益于数据与代码的耦合,"在有代码的周边集合数据";反方认为注解把代码和数据混淆在一起,增加了代码的易变性,消弱了程序的健壮性和稳定性。这些争论暂且搁置,我们要说的是一个我们不常用的元注解 (Meta-Annotation):@Inheruted, 它表示一个注解是否可以自动继承,我们开看它如何使用。
思考一个例子,比如描述鸟类,它有颜色、体型、习性等属性,我们以颜色为例,定义一个注解来修饰一下,代码如下:
1 import java.lang.annotation.ElementType; 2 import java.lang.annotation.Inherited; 3 import java.lang.annotation.Retention; 4 import java.lang.annotation.RetentionPolicy; 5 import java.lang.annotation.Target; 6 7 @Retention(RetentionPolicy.RUNTIME) 8 @Target(ElementType.TYPE) 9 @Inherited 10 public @interface Desc { 11 enum Color { 12 White, Grayish, Yellow 13 } 14 15 // 默认颜色是白色的 16 Color c() default Color.White; 17 }
该注解 Desc 前增加了三个注解:Retention表示的是该注解的保留级别,Target 表示的是注解可以标注在什么地方,@Inherited表示该注解会被自动继承。注解定义完毕,我们把它标注在类上,代码如下:
1 @Desc(c = Color.White) 2 abstract class Bird { 3 public abstract Color getColor(); 4 } 5 6 // 麻雀 7 class Sparrow extends Bird { 8 private Color color; 9 10 // 默认是浅灰色 11 public Sparrow() { 12 color = Color.Grayish; 13 } 14 15 // 构造函数定义鸟的颜色 16 public Sparrow(Color _color) { 17 color = _color; 18 } 19 20 @Override 21 public Color getColor() { 22 return color; 23 } 24 } 25 26 // 鸟巢,工厂方法模式 27 enum BirdNest { 28 Sparrow; 29 // 鸟类繁殖 30 public Bird reproduce() { 31 Desc bd = Sparrow.class.getAnnotation(Desc.class); 32 return bd == null ? new Sparrow() : new Sparrow(bd.c()); 33 } 34 }
上面程序声明了一个 Bird 抽象类,并且标注了 Desc 注解,描述鸟类的颜色是白色,然后编写一个麻雀 Sparrow 类,它有两个构造函数,一个是默认的构造函数,也就是我们经常看到的麻雀是浅灰色的,另外一个构造函数是自定义麻雀的颜色,之后又定义了一个鸟巢 (工厂方法模式),它是专门负责鸟类繁殖的,它的生产方法 reproduce 会根据实现类注解信息生成不同颜色的麻雀。我们编写一个客户端调用,代码如下:
1 public static void main(String[] args) { 2 Bird bird = BirdNest.Sparrow.reproduce(); 3 Color color = bird.getColor(); 4 System.out.println("Bird's color is :" + color); 5 }
现在问题是这段客户端程序会打印出什么来?因为采用了工厂方法模式,它最主要的问题就是 bird 变量到底采用了那个构造函数来生成,是无参构造函数还是有参构造?如果我们单独看子类 Sparrow,它没有被添加任何注释,那工厂方法中的 bd 变量就应该是 null 了,应该调用的是无参构造。是不是如此呢?我们来看运行结果:“Bird‘s Color is White ”;
白色?这是我们添加到父类 Bird 上的颜色,为什么?这是因为我们在注解上加了 @Inherited 注解,它表示的意思是我们只要把注解 @Desc 加到父类 Bird 上,它的所有子类都会从父类继承 @Desc 注解,不需要显示声明,这与 Java 的继承有点不同,若 Sparrow 类继承了 Bird 却不用显示声明,只要 @Desc 注解释可自动继承的即可。
采用 @Inherited 元注解有利有弊,利的地方是一个注解只要标注到父类,所有的子类都会自动具有父类相同的注解,整齐,统一而且便于管理,弊的地方是单单阅读子类代码,我们无从知道为何逻辑会被改变,因为子类没有显示标注该注解。总体上来说,使用 @Inherited 元注解弊大于利,特别是一个类的继承层次较深时,如果注解较多,则很难判断出那个注解对子类产生了逻辑劫持。
建议 91:枚举和注解结合使用威力更大
我们知道注解的写法和接口很类似,都采用了关键字 interface,而且都不能有实现代码,常量定义默认都是 public static final 类型的等,它们的主要不同点是:注解要在 interface 前加上 @字符,而且不能继承,不能实现,这经常会给我们的开发带来些障碍。
我们来分析一下 ACL(Access Control List, 访问控制列表) 设计案例,看看如何避免这些障碍,ACL 有三个重要元素:
- 资源,有哪些信息是要被控制起来的。
- 权限级别,不同的访问者规划在不同的级别中。
- 控制器 (也叫鉴权人),控制不同的级别访问不同的资源。
鉴权人是整个 ACL 的设计核心,我们从最主要的鉴权人开始,代码如下:
interface Identifier{ //无权访问时的礼貌语 String REFUSE_WORD = "您无权访问"; //鉴权 public boolean identify();}
这是一个鉴权人接口,定义了一个常量和一个鉴权方法。接下来应该实现该鉴权方法,但问题是我们的权限级别和鉴权方法之间是紧耦合,若分拆成两个类显得有点啰嗦,怎么办?我们可以直接顶一个枚举来实现,代码如下:
1 enum CommonIdentifier implements Identifier { 2 // 权限级别 3 Reader, Author, Admin; 4 5 @Override 6 public boolean identify() { 7 return false; 8 } 9 10 }
定义了一个通用鉴权者,使用的是枚举类型,并且实现了鉴权者接口。现在就剩下资源定义了,这很容易定义,资源就是我们写的类、方法等,之后再通过配置来决定哪些类、方法允许什么级别的访问,这里的问题是:怎么把资源和权限级别关联起来呢?使用 XML 配置文件?是个方法,但对我们的示例程序来说显得太繁重了,如果使用注解会更简洁些,不过这需要我们首先定义出权限级别的注解,代码如下:
1 @Retention(RetentionPolicy.RUNTIME) 2 @Target(ElementType.TYPE) 3 @interface Access{ 4 //什么级别可以访问,默认是管理员 5 CommonIdentifier level () default CommonIdentifier.Admin; 6 }
该注解释标注在类上面的,并且会保留到运行期。我们定义一个资源类,代码如下:
@Access(level=CommonIdentifier.Author) class Foo{}
Foo 类只能是作者级别的人访问。场景都定义完毕了,那我们看看如何模拟 ACL 实现,代码如下:
1 public static void main(String[] args) { 2 // 初始化商业逻辑 3 Foo b = new Foo(); 4 // 获取注解 5 Access access = b.getClass().getAnnotation(Access.class); 6 // 没有 Access 注解或者鉴权失败 7 if (null == access || !access.level().identify()) { 8 // 没有 Access 注解或者鉴权失败 9 System.out.println(access.level().REFUSE_WORD); 10 } 11 }
看看这段代码,简单,易读,而且如果我们是通过 ClassLoader 类来解释该注解的,那会使我们的开发更简洁,所有的开发人员只要增加注解即可解决访问控制问题。注意看加粗代码,access 是一个注解类型,我们想使用 Identifier 接口的 identity 鉴权方法和 REFUSE_WORD 常量,但注解释不能集成的,那怎么办? 此处,可通过枚举类型 CommonIdentifier 从中间做一个委派动作 (Delegate), 委派?你可以然 identity 返回一个对象,或者在 Identifier 上直接定义一个常量对象,那就是“赤裸裸” 的委派了。
建议 92:注意@Override不同版本的区别
@Override注解用于方法的覆写上,它是在编译器有效,也就是 Java 编译器在编译时会根据注解检查方法是否真的是覆写,如果不是就报错,拒绝编译。该注解可以很大程度地解决我们的误写问题,比如子类和父类的方法名少写一个字符,或者是数字 0 和字母 O 为区分出来等,这基本是每个程序员都曾将犯过的错误。在代码中加上@Override注解基本上可以杜绝出现此类问题,但是@Override有个版本问题,我们来看如下代码:
1 interface Foo { 2 public void doSomething(); 3 } 4 5 class FooImpl implements Foo{ 6 @Override 7 public void doSomething() { 8 9 } 10 }
这是一个简单的@Override 示例,接口中定义了一个 doSomething 方法,实现类 FooImpl 实现此方法,并且在方法前加上了@Override注解。这段代码在 Java1.6 版本上编译没问题,虽然 doSomething 方法只是实现了接口的定义,严格来说并不是覆写,但@Override出现在这里可减少代码中出现的错误。
可如果在 Java1.5 版本上编译此段代码可能会出现错误:
The method doSomeThing() of type FooImpl must override a superclass method
注意,这是个错误,不能继续编译,原因是 Java1.5 版本的@Override是严格遵守覆写的定义:子类方法与父类方法必须具有相同的方法名、输出参数、输出参数 (允许子类缩小)、访问权限 (允许子类扩大),父类必须是一个类,不能是接口,否则不能算是覆写。而这在 Java1.6 就开放了很多,实现接口的方法也可以加上@Override注解了,可以避免粗心大意导致方法名称与接口不一致的情况发生。
在多环境部署应用时,需呀考虑@Override在不同版本下代表的意义,如果是 Java1.6 版本的程序移植到 1.5 版本环境中,就需要删除实现接口方法上的@Override注解。