建议6:覆写变长方法也循规蹈矩
在JAVA中,子类覆写父类的中的方法很常见,这样做既可以修正bug,也可以提供扩展的业务功能支持,同时还符合开闭原则(Open-Closed Principle)。
符合开闭原则(Open-Closed Principle)的主要特征:
1.对于扩展是开放的(Open for extension)。这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。也就是说,我们可以改变模块的功能。
2.对于修改是关闭的(Closed for modification)。对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者.EXE文件,都无需改动。
下面我们看一下覆写必须满足的条件:
- 覆写方法不能缩小访问权限;
- 参数列表必须与被覆写方法相同;
- 返回类型必须与被重写方法的相同;
- 重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少,更有限的异常,或者不抛出异常。
看下面这段代码:
1 public class Client6 { 2 public static void main(String[] args) { 3 // 向上转型 4 Base base = new Sub(); 5 base.fun(100, 50); 6 // 不转型 7 Sub sub = new Sub(); 8 sub.fun(100, 50); 9 } 10 } 11 12 // 基类 13 class Base { 14 void fun(int price, int... discounts) { 15 System.out.println("Base......fun"); 16 } 17 } 18 19 // 子类,覆写父类方法 20 class Sub extends Base { 21 @Override 22 void fun(int price, int[] discounts) { 23 System.out.println("Sub......fun"); 24 } 25 }
该程序中sub.fun(100, 50)报错,提示找不到fun(int,int)方法。这太奇怪了:子类继承了父类的所有属性和方法,甭管是私有的还是公开的访问权限,同样的参数,同样的方法名,通过父类调用没有任何问题,通过子类调用,却编译不过,为啥?难到是没继承下来?或者子类缩小了父类方法的前置条件?如果是这样,就不应该覆写,@Override就应该报错呀。
事实上,base对象是把子类对象做了向上转型,形参列表由父类决定,由于是变长参数,在编译时,base.fun(100, 50);中的50这个实参会被编译器"猜测"而编译成"{50}"数组,再由子类Sub执行。我们再来看看直接调用子类的情况,这时编译器并不会把"50"座类型转换因为数组本身也是一个对象,编译器还没有聪明到要在两个没有继承关系的类之间转换,要知道JAVA是要求严格的类型匹配的,类型不匹配编译器自然就会拒绝执行,并给予错误提示。
这是个特例,覆写的方法参数列表竟然与父类不相同,这违背了覆写的定义,并且会引发莫名其妙的错误。所以读者在对变长参数进行覆写时,如果要使用次类似的方法,请仔细想想是不是要一定如此。
注意:覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式.
建议7:警惕自增的陷阱
记得大学刚开始学C语言时,老师就说:自增有两种形式,分别是i++和++i,i++表示的先赋值后加1,++i是先加1后赋值,这样理解了很多年也木有问题,直到遇到如下代码,我才怀疑我的理解是不是错了:
1 public class Client7 { 2 public static void main(String[] args) { 3 int count=0; 4 for(int i=0; i<10;i++){ 5 count=count++; 6 } 7 System.out.println("count = "+count); 8 } 9 }
这个程序输出的count等于几?是count自加10次吗?答案等于10?可以肯定的说,这个运行结果是count=0。为什么呢?
count++是一个表达式,是由返回值的,它的返回值就是count自加前的值,Java对自加是这样处理的:首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量+1,最后返回临时变量区的值。程序第一次循环处理步骤如下:
- JVM把count的值(其值是0)拷贝到临时变量区;
- count的值+1,这时候count的值是1;
- 返回临时变量区的值,注意这个值是0,没修改过;
- 返回值赋给count,此时count的值被重置为0.
"count=count++"这条语句可以按照如下代码理解:
1 public static int mockAdd(int count) { 2 // 先保存初始值 3 int temp = count; 4 // 做自增操作 5 count = count + 1; 6 // 返回原始值 7 return temp; 8 }
于是第一次循环后count的值为0,其它9次循环也是一样的,最终你会发现count的值始终没有改变,仍然保持着最初的状态.
此例中代码作者的本意是希望count自增,所以想当然的赋值给自身就可以了,不曾想到调到Java自增的陷阱中了,解决办法很简单,把"count=count++"改为"count++"即可。该问题在不同的语言环境中有着不同的实现:C++中"count=count++"与"count++"是等效的,而在PHP中保持着与JAVA相同的处理方式。每种语言对自增的实现方式各不相同。
建议8:不要让旧语法困扰你
1 public class Client8 { 2 public static void main(String[] args) { 3 // 数据定义初始化 4 int fee = 200; 5 // 其它业务处理 6 saveDefault: save(fee); 7 } 8 9 static void saveDefault() { 10 System.out.println("saveDefault...."); 11 } 12 13 static void save(int fee) { 14 System.out.println("save...."); 15 } 16 }
这段代码分析一下,输出结果,以及语法含义:
- 首先这段代码中有标号(:)操作符,C语言的同学一看便知,类似JAVA中的保留关键字 go to 语句,但Java中抛弃了goto语法,只是不进行语义处理,与此类似的还有const关键字。
- Java中虽然没有了goto语法,但扩展了break和continue关键字,他们的后面都可以加上标号做跳转,完全实现了goto功能,同时也把goto的诟病带进来了。
- 运行之后代码输入为"save....",运行时没错,但这样的代码,给大家阅读上造成了很大的问题,所以就语法就让他随风远去吧!
建议9:少用静态导入
从Java5开始引入了静态导入语法(import static),其目的是为了减少字符的输入量,提高代码的可阅读性,以便更好地理解程序。我们先俩看一个不用静态导入的例子,也就是一般导入:
1 public class Client9 { 2 // 计算圆面积 3 public static double claCircleArea(double r) { 4 return Math.PI * r * r; 5 } 6 7 // 计算球面积 8 public static double claBallArea(double r) { 9 return 4 * Math.PI * r * r; 10 } 11 }
这是很简单的两个方法,我们再这两个计算面积的方法中都引入了java.lang.Math类(该类是默认导入的)中的PI(圆周率)常量,而Math这个类写在这里有点多余,特别是如果Client9类中的方法比较多时。如果每次输入都需要敲入Math这个类,繁琐且多余,静态导入可以解决此问题,使用静态导入后的程序如下:
1 import static java.lang.Math.PI; 2 3 public class Client9 { 4 // 计算圆面积 5 public static double claCircleArea(double r) { 6 return PI * r * r; 7 } 8 9 // 计算球面积 10 public static double claBallArea(double r) { 11 return 4 * PI * r * r; 12 } 13 }
静态导入的作用是把Math类中的Pi常量引入到本类中,这会是程序更简单,更容易阅读,只要看到PI就知道这是圆周率,不用每次都把类名写全了。但是,滥用静态导入会使程序更难阅读,更难维护,静态导入后,代码中就不需要再写类名了,但我们知道类是"一类事物的描述",缺少了类名的修饰,静态属性和静态方法的表象意义可以被无限放大,这会让阅读者很难弄清楚其属性或者方法代表何意,绳子哪一类的属性(方法)都要思考一番(当然IDE的友好提示功能另说),把一个类的静态导入元素都引入进来了,那简直就是噩梦。我们来看下面的例子:
1 import static java.lang.Math.*; 2 import static java.lang.Double.*; 3 import static java.lang.Integer.*; 4 import static java.text.NumberFormat.*; 5 6 import java.text.NumberFormat; 7 8 public class Client9 { 9 10 public static void formatMessage(String s) { 11 System.out.println("圆面积是: " + s); 12 } 13 14 public static void main(String[] args) { 15 double s = PI * parseDouble(args[0]); 16 NumberFormat nf = getInstance(); 17 nf.setMaximumFractionDigits(parseInt(args[1])); 18 formatMessage(nf.format(s)); 19 20 } 21 }
就这么一段程序,看着就让人恼火,常量PI,这知道是圆周率;parseDouble方法可能是Double类的一个转换方法,这看名称可以猜的到。那紧接着getInstance()方法是哪个类的?是Client9本地类?不对呀,本地没有这个方法,哦,原来是NumberFormat类的方法,这个和formatMessage本地方法没有任何区别了---这代码太难阅读了,肯定有人骂娘。
所以,对于静态导入,一定要追寻两个原则:
- 不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口);
- 方法名是具有明确、清晰表象意义的工具类。
何为具有明确、清晰表象意义的工具类,我们看看Junit中使用静态导入的例子:
1 import static org.junit.Assert.*; 2 class DaoTest{ 3 @Test 4 public void testInsert(){ 5 //断言 6 assertEquals("foo","foo"); 7 assertFalse(Boolean.FALSE); 8 } 9 }
我们从程序中很容易判断出assertEquals方法是用来断言两个值是否相等的,assertFalse方法则是断言表达式为假,如此确实减少了代码量,而且代码的可读性也提高了,这也是静态导入用到正确的地方带来的好处。
建议10:不要在本类中覆盖静态导入的变量和方法
如果在一个类中的方法及属性与静态导入的方法及属性相同会出现什么问题呢?看下面的代码
1 import static java.lang.Math.PI; 2 import static java.lang.Math.abs; 3 4 public class Client10 { 5 // 常量名于静态导入的PI相同 6 public final static String PI = "祖冲之"; 7 //方法名于静态导入的方法相同 8 public static int abs(int abs) { 9 return 0; 10 } 11 12 public static void main(String[] args) { 13 System.out.println("PI = "+PI); 14 System.out.println("abs(-100) = "+abs(-100)); 15 } 16 }
以上代码中定义了一个String类型的常量PI,又定义了一个abs方法,与静态导入的相同。首先说好消息,代码没有报错,接下来是坏消息:我们不知道那个属性和方法别调用了,因为常量名和方法名相同,到底调用了那一个方法呢?运行之后结果为:
PI = "祖冲之",abs(-100) = 0;
很明显是本地的方法被调用了,为何不调用Math类中的属性和方法呢?那是因为编译器有一个"最短路径"原则:如果能够在本类中查找到相关的变量、常量、方法、就不会去其它包或父类、接口中查找,以确保本类中的属性、方法优先。
因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖.