编写高质量代码:改善Java程序的151个建议(1)

第1章 Java开发中通用的方法和准则

  Thereasonablemanadaptshimselftotheworld;theunreasonableonepersistsintryingtoadapttheworldtohimself.

  明白事理的人使自己适应世界;不明事理的人想让世界适应自己。

  —萧伯纳

  Java的世界丰富又多彩,但同时也布满了荆棘陷阱,大家一不小心就可能跌入黑暗深渊,只有在了解了其通行规则后才能使自己在技术的海洋里遨游飞翔,恣意驰骋。

  “千里之行始于足下”,本章主要讲述与Java语言基础有关的问题及建议的解决方案,例如常量和变量的注意事项、如何更安全地序列化、断言到底该如何使用等。

  建议1:不要在常量和变量中出现易混淆的字母

  包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法(CamelCase)命名等,这些都是最基本的Java编码规范,是每个Javaer都应熟知的规则,但是在变量的声明中要注意不要引入容易混淆的字母。尝试阅读如下代码,思考一下打印出的i等于多少:

  1. public class Client {  
  2.      public static void main(String[] args) {  
  3.            long i = 1l;  
  4.            System.out.println("i的两倍是:" + (i+i));  
  5.      }  
  6. }

  肯定有人会说:这么简单的例子还能出错?运行结果肯定是22!实践是检验真理的唯一标准,将其拷贝到Eclipse中,然后Run一下看看,或许你会很奇怪,结果是2,而不是22,难道是Eclipse的显示有问题,少了个“2”?

  因为赋给变量i的数字就是“1”,只是后面加了长整型变量的标示字母“l”而已。别说是我挖坑让你跳,如果有类似程序出现在项目中,当你试图通过阅读代码来理解作者的思想时,此情此景就有可能会出现。所以,为了让您的程序更容易理解,字母“l”(还包括大写字母“O”)尽量不要和数字混用,以免使阅读者的理解与程序意图产生偏差。如果字母和数字必须混合使用,字母“l”务必大写,字母“O”则增加注释。

  注意 字母“l”作为长整型标志时务必大写。

  建议2:莫让常量蜕变成变量

  常量蜕变成变量?你胡扯吧,加了final和static的常量怎么可能会变呢?不可能二次赋值的呀。真的不可能吗?看我们神奇的魔术,代码如下:

  1. public class Client {     
  2.      public static void main(String[] args) {  
  3.            System.out.println("常量会变哦:" + Const.RAND_CONST);  
  4.      }  
  5. }  
  6. /*接口常量*/  
  7. interface Const{  
  8.     //这还是常量吗?  
  9.     public static final int RAND_CONST = new Random().nextInt();  
  10. }

  RAND_CONST是常量吗?它的值会变吗?绝对会变!这种常量的定义方式是极不可取的,常量就是常量,在编译期就必须确定其值,不应该在运行期更改,否则程序的可读性会非常差,甚至连作者自己都不能确定在运行期发生了何种神奇的事情。

  甭想着使用常量会变的这个功能来实现序列号算法、随机种子生成,除非这真的是项目中的唯一方案,否则就放弃吧,常量还是当常量使用。

  注意:务必让常量的值在运行期保持不变。

建议3:三元操作符的类型务必一致

  三元操作符是if-else的简化写法,在项目中使用它的地方很多,也非常好用,但是好用又简单的东西并不表示就可以随便用,我们来看看下面这段代码:

  1. public class Client {  
  2.      public static void main(String[] args) {  
  3.            int i = 80;  
  4.            String s = String.valueOf(i<100?90:100);  
  5.            String s1 = String.valueOf(i<100?90:100.0);  
  6.            System.out.println("两者是否相等:"+s.equals(s1));  
  7.      }  
  8. }

  分析一下这段程序:i是80,那它当然小于100,两者的返回值肯定都是90,再转成String类型,其值也绝对相等,毋庸置疑的。恩,分析得有点道理,但是变量s中三元操作符的第二个操作数是100,而s1的第二个操作数是100.0,难道没有影响吗?不可能有影响吧,三元操作符的条件都为真了,只返回第一个值嘛,与第二个值有一毛钱的关系吗?貌似有道理。

  果真如此吗?我们通过结果来验证一下,运行结果是:“两者是否相等:false”,什么?不相等,Why?

  问题就出在了100和100.0这两个数字上,在变量s中,三元操作符中的第一个操作数(90)和第二个操作数(100)都是int类型,类型相同,返回的结果也就是int类型的90,而变量s1的情况就有点不同了,第一个操作数是90(int类型),第二个操作数却是100.0,而这是个浮点数,也就是说两个操作数的类型不一致,可三元操作符必须要返回一个数据,而且类型要确定,不可能条件为真时返回int类型,条件为假时返回float类型,编译器是不允许如此的,所以它就会进行类型转换了,int型转换为浮点数90.0,也就是说三元操作符的返回值是浮点数90.0,那这当然与整型的90不相等了。这里可能有读者疑惑了:为什么是整型转为浮点,而不是浮点转为整型呢?这就涉及三元操作符类型的转换规则:

  若两个操作数不可转换,则不做转换,返回值为Object类型。

  若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。

  若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。

  若两个操作数都是直接量数字(Literal),则返回值类型为范围较大者。

  知道是什么原因了,相应的解决办法也就有了:保证三元操作符中的两个操作数类型一致,即可减少可能错误的发生。

  建议4:避免带有变长参数的方法重载

  在项目和系统的开发中,为了提高方法的灵活度和可复用性,我们经常要传递不确定数量的参数到方法中,在Java 5之前常用的设计技巧就是把形参定义成Collection类型或其子类类型,或者是数组类型,这种方法的缺点就是需要对空参数进行判断和筛选,比如实参为null值和长度为0的Collection或数组。而 Java 5引入变长参数(varags)就是为了更好地提高方法的复用性,让方法的调用者可以“随心所欲”地传递实参数量,当然变长参数也是要遵循一定规则的,比如变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等,这些基本规则需要牢记,但是即使记住了这些规则,仍然有可能出现错误,我们来看如下代码:

  1. public class Client {     
  2.      //简单折扣计算  
  3.      public void calPrice(int price,int discount){  
  4.            float knockdownPrice =price * discount / 100.0F;  
  5.            System.out.println("简单折扣后的价格是:"+formateCurrency(knockdownPrice));  
  6.      }    
  7.      //复杂多折扣计算  
  8.      public void calPrice(int price,int... discounts){  
  9.            float knockdownPrice = price;  
  10.            for(int discount:discounts){  
  11.                    knockdownPriceknockdownPrice = knockdownPrice * discount / 100;  
  12.         }  
  13.         System.out.println("复杂折扣后的价格是:" +formateCurrency(knockdownPrice));  
  14.      }  
  15.      //格式化成本的货币形式  
  16.      private String formateCurrency(float price){  
  17.             return NumberFormat.getCurrencyInstance().format(price/100);  
  18.      }  
  19.       
  20.      public static void main(String[] args) {  
  21.            Client client = new Client();  
  22.            //499元的货物,打75折  
  23.            client.calPrice(49900, 75);  
  24.     }  
  25. }

  这是一个计算商品价格折扣的模拟类,带有两个参数的calPrice方法(该方法的业务逻辑是:提供商品的原价和折扣率,即可获得商品的折扣价)是一个简单的折扣计算方法,该方法在实际项目中经常会用到,这是单一的打折方法。而带有变长参数的calPrice方法则是较复杂的折扣计算方式,多种折扣的叠加运算(模拟类是一种比较简单的实现)在实际生活中也是经常见到的,比如在大甩卖期间对VIP会员再度进行打折;或者当天是你的生日,再给你打个9折,也就是俗话说的“折上折”。

  业务逻辑清楚了,我们来仔细看看这两个方法,它们是重载吗?当然是了,重载的定义是“方法名相同,参数类型或数量不同”,很明显这两个方法是重载。但是再仔细瞧瞧,这个重载有点特殊:calPrice(int price,int... discounts)的参数范畴覆盖了calPrice(int price,int discount)的参数范畴。那问题就出来了:对于calPrice(49900,75)这样的计算,到底该调用哪个方法来处理呢?

  我们知道Java编译器是很聪明的,它在编译时会根据方法签名(Method Signature)来确定调用哪个方法,比如calPrice(499900,75,95)这个调用,很明显75和95会被转成一个包含两个元素的数组,并传递到calPrice(int price,in.. discounts)中,因为只有这一个方法签名符合该实参类型,这很容易理解。但是我们现在面对的是calPrice(49900,75)调用,这个“75”既可以被编译成int类型的“75”,也可以被编译成int数组“{75}”,即只包含一个元素的数组。那到底该调用哪一个方法呢?我们先运行一下看看结果,运行结果是:

  简单折扣后的价格是:¥374.25。

  看来是调用了第一个方法,为什么会调用第一个方法,而不是第二个变长参数方法呢?因为Java在编译时,首先会根据实参的数量和类型(这里是2个实参,都为int类型,注意没有转成int数组)来进行处理,也就是查找到calPrice(int price,int discount)方法,而且确认它是否符合方法签名条件。现在的问题是编译器为什么会首先根据2个int类型的实参而不是1个int类型、1个int数组类型的实参来查找方法呢?这是个好问题,也非常好回答:因为int是一个原生数据类型,而数组本身是一个对象,编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。

  问题是阐述清楚了,为了让我们的程序能被“人类”看懂,还是慎重考虑变长参数的方法重载吧,否则让人伤脑筋不说,说不定哪天就陷入这类小陷阱里了。

  建议5:别让null值和空值威胁到变长方法

  上一建议讲解了变长参数的重载问题,本建议还会继续讨论变长参数的重载问题。上一建议的例子是变长参数的范围覆盖了非变长参数的范围,这次我们从两个都是变长参数的方法说起,代码如下:

  1. public class Client {  
  2.      public void methodA(String str,Integer... is){       
  3.      }  
  4.       
  5.      public void methodA(String str,String... strs){          
  6.      }  
  7.       
  8.      public static void main(String[] args) {  
  9.            Client client = new Client();  
  10.            client.methodA("China", 0);  
  11.            client.methodA("China", "People");  
  12.            client.methodA("China");  
  13.            client.methodA("China",null);  
  14.      }  
  15. }

  两个methodA都进行了重载,现在的问题是:上面的代码编译通不过,问题出在什么地方?看似很简单哦。

  有两处编译通不过:client.methodA("China")和client.methodA("China",null),估计你已经猜到了,两处的提示是相同的:方法模糊不清,编译器不知道调用哪一个方法,但这两处代码反映的代码味道可是不同的。

  对于methodA("China")方法,根据实参“China”(String类型),两个方法都符合形参格式,编译器不知道该调用哪个方法,于是报错。我们来思考这个问题:Client类是一个复杂的商业逻辑,提供了两个重载方法,从其他模块调用(系统内本地调用或系统外远程调用)时,调用者根据变长参数的规范调用, 传入变长参数的实参数量可以是N个(N>=0),那当然可以写成client.methodA("china")方法啊!完全符合规范,但是这却让编译器和调用者都很郁闷,程序符合规则却不能运行,如此问题,谁之责任呢?是Client类的设计者,他违反了KISS原则(Keep It Simple, Stupid,即懒人原则),按照此规则设计的方法应该很容易调用,可是现在在遵循规范的情况下,程序竟然出错了,这对设计者和开发者而言都是应该严禁出现的。

  对于client.methodA("china",null)方法,直接量null是没有类型的,虽然两个methodA方法都符合调用请求,但不知道调用哪一个,于是报错了。我们来体会一下它的坏味道:除了不符合上面的懒人原则外,这里还有一个非常不好的编码习惯,即调用者隐藏了实参类型,这是非常危险的,不仅仅调用者需要“猜测”该调用哪个方法,而且被调用者也可能产生内部逻辑混乱的情况。对于本例来说应该做如下修改:

  1. public static void main(String[] args) {  
  2.      Client client = new Client();  
  3.      String[] strs = null;  
  4.      client.methodA("China",strs);  
  5. }

  也就是说让编译器知道这个null值是String类型的,编译即可顺利通过,也就减少了错误的发生。


本文出自seven的测试人生公众号最新内容请见作者的GitHub页:http://qaseven.github.io/

上一篇:Lock与synchronized 的区别


下一篇:XGoServer 一个基础性、模块完整且安全可靠的服务端框架