编写高质量代码:改善Java程序的151个建议(第6章:枚举和注解___建议83~87)
枚举和注解都是在Java1.5中引入的,虽然它们是后起之秀,但其功效不可小觑,枚举改变了常量的声明方式,注解耦合了数据和代码。
建议83:推荐使用枚举定义常量
常量声明是每一个项目都不可或缺的,在Java1.5之前,我们只有两种方式的声明:类常量和接口常量,若在项目中使用的是Java1.5之前的版本,基本上都是如此定义的。不过,在1.5版本以后有了改进,即新增了一种常量声明方式:枚举声明常量,看如下代码:
enum Season { Spring, Summer, Autumn, Winter; }
这是一个简单的枚举常量命名,清晰又简单。顺便提一句,JLS(Java Language Specification,Java语言规范)提倡枚举项全部大写,字母之间用下划线分割,这也是从常量的角度考虑的(当然,使用类似类名的命名方式也是比较友好的)。
那么枚举常量与我们经常使用的类常量和静态常量相比有什么优势?问得好,枚举的优点主要表现在四个方面:
1.枚举常量简单:简不简单,我们来对比一下两者的定义和使用情况就知道了。先把Season枚举翻写成接口常量,代码如下:
1 interface Season { 2 int SPRING = 0; 3 int SUMMER = 1; 4 int AUTUMN = 2; 5 int WINTER = 3; 6 }
此处定义了春夏秋冬四个季节,类型都是int,这与Season枚举的排序值是相同的。首先对比一下两者的定义,枚举常量只需定义每个枚举项,不需要定义枚举值,而接口常量(或类常量)则必须定义值,否则编译不通过,即使我们不需要关注其值是多少也必须定义;其次,虽然两者被引用的方式相同(都是 “类名 . 属性”,如Season.SPRING),但是枚举表示的是一个枚举项,字面含义是春天,而接口常量确是一个int类型,虽然其字面含义也是春天,但在运算中我们势必要关注其int值。
2.枚举常量属于稳态型
例如我们要描述一下春夏秋冬是什么样子,使用接口常量应该是这样写。
1 public void describe(int s) { 2 // s变量不能超越边界,校验条件 3 if (s >= 0 && s < 4) { 4 switch (s) { 5 case Season.SPRING: 6 System.out.println("this is spring"); 7 break; 8 case Season.SUMMER: 9 System.out.println("this is summer"); 10 break; 11 ...... 12 } 13 } 14 }
很简单,先使用switch语句判断哪一个是常量,然后输出。但问题是我们得对输入值进行检查,确定是否越界,如果常量非常庞大,校验输入就成了一件非常麻烦的事情,但这是一个不可逃避的过程,特别是如果我们的校验条件不严格,虽然编译能照样通过,但是运行期就会产生无法预知的后果。
我们再来看看枚举常量是否能够避免校验的问题,代码如下:
1 public void describe(Season s){ 2 switch(s){ 3 case Spring: 4 System.out.println("this is "+Season.Spring); 5 break; 6 case Summer: 7 System.out.println("this is summer"+Season.Summer); 8 break; 9 ...... 10 } 11 } 12
不用校验,已经限定了是Season枚举,所以只能是Season类的四个实例,即春夏秋冬4个枚举项,想输入一个int类型或其它类型?门都没有!这是我们最看重枚举的地方:在编译期间限定类型,不允许发生越界的情况。
3.枚举具有内置方法
有一个简单的问题:如果要列出所有的季节常量,如何实现呢?接口常量或类常量可以通过反射来实现,这没错,只是虽然能实现,但会非常繁琐,大家可以自己写一个反射类实现此功能(当然,一个一个地动手打印出输出常量,也可以算是列出)。对于此类问题可以非常简单的解决,代码如下:
1 public void query() { 2 for (Season s : Season.values()) { 3 System.out.println(s); 4 } 5 }
通过values方法获得所有的枚举项,然后打印出来即可。如此简单,得益于枚举内置的方法,每个枚举都是java.lang.Enum的子类,该基类提供了诸如获得排序值的ordinal方法、compareTo比较方法等,大大简化了常量的访问。
4.枚举可以自定义的方法
这一点似乎并不是枚举的优点,类常量也可以有自己的方法呀,但关键是枚举常量不仅可以定义静态方法,还可以定义非静态方法,而且还能够从根本上杜绝常量类被实例化。比如我们要在常量定义中获得最舒服季节的方法,使用常量枚举的代码如下:
1 enum Season { 2 Spring, Summer, Autumn, Winter; 3 public static Season getComfortableSeason(){ 4 return Spring; 5 } 6 }
我们知道,每个枚举项都是该枚举的一个实例,对于我们的例子来说,也就表示Spring其实是Season的一个实例,Summer也是其中一个实例,那我们在枚举中定义的静态方法既可以在类(也就是枚举Season)中引用,也可以在实例(也就是枚举项Spring、Summer、Autumn、Winter)中引用,看如下代码:
public static void main(String[] args) { System.out.println("The most comfortable season is "+Season.getComfortableSeason()); }
那如果使用类常量要如何实现呢?代码如下:
1 class Season { 2 public final static int SPRING = 0; 3 public final static int SUMMER = 1; 4 public final static int AUTUMN = 2; 5 public final static int WINTER = 3; 6 public static int getComfortableSeason(){ 7 return SPRING; 8 } 9 }
想想看,我们怎么才能打印出"The most comfortable season is Spring" 这句话呢?除了使用switch和if判断之外没有其它办法了。
虽然枚举在很多方面比接口常量和类常量好用,但是有一点它是比不上接口常量和类常量的,那就是继承,枚举类型是不能继承的,也就是说一个枚举常量定义完毕后,除非修改重构,否则无法做扩展,而接口常量和类常量则可以通过继承进行扩展。但是,一般常量在项目构建时就定义完毕了,很少会出现必须通过扩展才能实现业务逻辑的场景。
注意: 在项目中推荐使用枚举常量代替接口常量或类常量。
建议84:使用构造函数协助描述枚举项
一般来说,我们经常使用的枚举项只有一个属性,即排序号,其默认值是从0、1、2......,这一点我们很熟悉,但是除了排序号之外,枚举还有一个(或多个)属性:枚举描述,他的含义是通过枚举的构造函数,声明每个枚举项(也就是枚举的实例)必须具有的属性和行为,这是对枚举项的描述或补充,目的是使枚举项描述的意义更加清晰准确。例如有这样一段代码:
1 public enum Season { 2 Spring("春"), Summer("夏"), Autumn("秋"), Winter("冬"); 3 private String desc; 4 5 Season(String _desc) { 6 desc = _desc; 7 } 8 //获得枚举描述 9 public String getDesc() { 10 return desc; 11 } 12 }
其枚举选项是英文的,描述是中文的,如此设计使其表述的意义更加精确,方便了多个作者共同引用该常量。若不考虑描述的使用(即访问getDesc方法),它与如下接口定义的描述很相似:
interface Season{ //春 int SPRING =0; //夏 int SUMMER =1; //...... }
比较两段代码,很容易看出使用枚举项描述是一个很好的解决办法,非常简单、清晰。因为是一个描述(Description),那我们在开发时就可以赋予更多的含义,比如可以通过枚举构造函数声明业务值,定义可选项,添加属性等,看如下代码:
1 enum Role { 2 Admin("管理员", new LifeTime(), new Scope()), User("普通用户", new LifeTime(), new Scope()); 3 private String name; 4 private LifeTime lifeTime; 5 private Scope scope; 6 /* setter和getter方法略 */ 7 8 Role(String _name, LifeTime _lifeTime, Scope _scope) { 9 name = _name; 10 lifeTime = _lifeTime; 11 scope = _scope; 12 } 13 14 } 15 16 class LifeTime { 17 } 18 class Scope { 19 }
这是一个角色定义类,描述了两个角色:管理员和普通用户,同时它还通过构造函数对这两个角色进行了描述:
- name:表示的是该角色的中文名称
- lifeTime:表示的是该角色的生命周期,也就是多长时间该角色失效
- scope:表示的该角色的权限范围
大家可以看出,这样一个描述可以使开发者对Admin和User两个常量有一个立体多维度的认知,有名称,有周期,还有范围,而且还可以在程序中方便的获得此类属性。所以,推荐大家在枚举定义中为每个枚举项定义描述,特别是在大规模的项目开发中,大量的常量定义使用枚举项描述比在接口常量或类常量中增加注释的方式友好的多,简洁的多。
建议85:小心switch带来的空指针异常
使用枚举定义常量时。会伴有大量switch语句判断,目的是为了每个枚举项解释其行为,例如这样一个方法:
1 public static void doSports(Season season) { 2 switch (season) { 3 case Spring: 4 System.out.println("春天放风筝"); 5 break; 6 case Summer: 7 System.out.println("夏天游泳"); 8 break; 9 case Autumn: 10 System.out.println("秋天是收获的季节"); 11 break; 12 case Winter: 13 System.out.println("冬天滑冰"); 14 break; 15 default: 16 System.out.println("输出错误"); 17 break; 18 } 19 }
上面的代码传入了一个Season类型的枚举,然后使用switch进行匹配,目的是输出每个季节的活动,现在的问题是这段代码又没有问题:
我们先来看看它是如何被调用的,因为要传递进来的是Season类型,也就是一个实例对象,那当然允许为空了,我们就传递一个null值进去看看代码又没有问题,如下:
public static void main(String[] args) { doSports(null); }
似乎会打印出“输出错误”,因为switch中没有匹配到指定值,所以会打印出defaut的代码块,是这样的吗?不是,运行后的结果如下:
Exception in thread "main" java.lang.NullPointerException at com.book.study85.Client85.doSports(Client85.java:8) at com.book.study85.Client85.main(Client85.java:28)
竟然是空指针异常,也就是switch的那一行,怎么会有空指针呢?这就与枚举和switch的特性有关了,此问题也是在开发中经常发生的。我们知道,目前Java中的switch语句只能判断byte、short、char、int类型(JDk7允许使用String类型),这是Java编译器的限制。问题是为什么枚举类型也可以跟在switch后面呢?
因为编译时,编译器判断出switch语句后跟的参数是枚举类型,然后就会根据枚举的排序值继续匹配,也就是或上面的代码与以下代码相同:
1 public static void doSports(Season season) { 2 switch (season.ordinal()) { 3 case season.Spring.ordinal(): 4 System.out.println("春天放风筝"); 5 break; 6 case season.Summer.ordinal(): 7 System.out.println("夏天游泳"); 8 break; 9 //...... 10 } 11 }
看明白了吧,switch语句是先计算season变量的排序值,然后与枚举常量的每个排序值进行对比,在我们的例子中season是null,无法执行ordinal()方法,于是就报空指针异常了。问题清楚了,解决很简单,在doSports方法中判断输入参数是否为null即可。
建议86:在switch的default代码块中增加AssertionError错误
switch后跟枚举类型,case后列出所有的枚举项,这是一个使用枚举的主流写法,那留着default语句似乎没有任何作用,程序已经列举了所有的可能选项,肯定不会执行到defaut语句,看上去纯属多余嘛!错了,这个default还是很有作用的。以我们定义的日志级别来说明,这是一个典型的枚举常量,如下所示:
enum LogLevel{ DEBUG,INFO,WARN,ERROR }
一般在使用的时候,会通过switch语句来决定用户设置的日志级别,然后输出不同级别的日志代码,代码如下:
1 switch(LogLevel) 2 3 { 4 case:DEBUG: 5 //..... 6 case:INFO: 7 //...... 8 case:WARN: 9 //...... 10 case:ERROR: 11 //...... 12 }
由于把所有的枚举项都列举完了,不可能有其它值,所以就不需要default代码快了,这是普遍认识,但问题是我们的switch代码与枚举之间没有强制约束关系,也就是说两者只是在语义上建立了联系,并没有一个强制约束,比如LogLevel的枚举项发生变化了,增加了一个枚举项FATAL,如果此时我们对switch语句不做任何修改,编译虽不会出问题,但是运行期会发生非预期的错误:FATAL类型的日志没有输出。
为了避免出现这类错误,建议在default后直接抛出一个AssertionError错误,其含义就是“不要跑到这里来,一跑到这里就会出问题”,这样可以保证在增加一个枚举项的情况下,若其它代码未修改,运行期马上就会出错,这样一来就很容易找到错误,方便立即排除。
当然也有其它方法解决此问题,比如修改IDE工具,以Eclipse为例,可以把Java-->Compiler--->Errors/Warnings中的“Enum type constant not covered on 'switch' ”设置为Error级别,如果不判断所有的枚举项就不能编译通过。
建议87:使用valueOf前必须进行校验
我们知道每个枚举项都是java.lang.Enum的子类,都可以访问Enum类提供的方法,比如hashCode、name、valueOf等,其中valueOf方法会把一个String类型的名称转换为枚举项,也就是在枚举项中查找出字面值与参数相等的枚举项。虽然这个方法简单,但是JDK却做了一个对于开发人员来说并不简单的处理,我们来看代码:
1 public static void main(String[] args) { 2 // 注意summer是小写 3 List<String> params = Arrays.asList("Spring", "summer"); 4 for (String name : params) { 5 // 查找字面值与name相同的枚举项,其中Season是前面例子中枚举Season 6 Season s = Season.valueOf(name); 7 if (null != s) { 8 // 有枚举项时 9 System.out.println(s); 10 } else { 11 // 没有该枚举项 12 System.out.println("无相关枚举项"); 13 } 14 } 15 }
这段程序看起来没什么错吧,其中考虑到从String转换为枚举类型可能存在着转换不成功的情况,比如没有匹配找到指定值,此时ValueOf的返回值应该为空,所以后面又跟着if...else判断输出。我们看看运行结果
Spring Exception in thread "main" java.lang.IllegalArgumentException: No enum constant com.book.study01.Season.summer at java.lang.Enum.valueOf(Unknown Source) at com.book.study01.Season.valueOf(Season.java:1) at com.book.study85.Client85.main(Client85.java:14)
报无效的参数异常,也就说我们的summer(注意s是小写),无法转换为Season枚举,无法转换就 不转换嘛,那也别抛出IllegalArgumentException异常啊,一但抛出这个异常,后续的代码就不会执行了,这与我们的习惯不符合呀,例如我们从List中查找一个元素,即使不存在也不会报错,顶多indexOf方法返回-1。那么我们来深入分析一下该问题,valueOf方法的源代码如下:
1 public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) { 2 //通过反射,从常量列表中查找 3 T result = enumType.enumConstantDirectory().get(name); 4 if (result != null) 5 return result; 6 if (name == null) 7 throw new NullPointerException("Name is null"); 8 //最后抛出无效参数异常 9 throw new IllegalArgumentException("No enum constant " + enumType.getCanonicalName() + "." + name); 10 }
valueOf方法先通过反射从枚举类的常量声明中查找,若找到就直接返回,若找不到则抛出无效参数异常。valueOf的本意是保护编码中的枚举安全性,使其不产生空枚举对象,简化枚举操作,但是却引入了一个我们无法避免的IllegalArgumentException异常。
大家是否觉得此处的valueOf方法的源码不对,这里要输入两个参数,而我们的Season.valueOf只传递一个String类型的参数,真的是这样吗?是的,因为valueOf(String name)方法是不可见的,是JVM内置的方法,我们只有通过阅读公开的valueOf方法来了解其运行原理了。
问题清楚了,有两个方法可以解决此问题:
(1)、使用try......catch捕捉异常
这里是最直接也是最简单的方式,产生IllegalArgumentException即可确认为没有同名的枚举的枚举项,代码如下:
try{ Season s = Season.valueOf(name); //有该枚举项时 System.out.println(s); }catch(Exception e){ e.printStackTrace(); System.out.println("无相关枚举项"); }
(2)、扩展枚举类:由于Enum类定义的方法基本上都是final类型的,所以不希望被覆写,我们可以学习String和List,通过增加一个contains方法来判断是否包含指定的枚举项,然后再继续转换,代码如下。
1 enum Season { 2 Spring, Summer, Autumn, Winter; 3 // 是否包含指定的枚举项 4 public static boolean contains(String name) { 5 // 所有的枚举值 6 Season[] season = values(); 7 for (Season s : season) { 8 if (s.name().equals(name)) { 9 return true; 10 } 11 } 12 return false; 13 } 14 }
Season枚举具备了静态方法contains后,就可以在valueOf前判断一下是否包含指定的枚举名称了,若包含则可以通过valueOf转换为枚举,若不包含则不转换。