编写高质量代码:改善Java程序的151个建议(第3章:类、对象及方法___建议31~35)
书读的多而不思考,你会觉得自己知道的很多。
书读的多而思考,你会觉得自己不懂的越来越多。
———伏尔泰
在面向对象编程(Object-Oriented Programming, OOP)的世界里,类和对象是真实世界的描述工具,方法是行为和动作的展示形式,封装、继承、多态则是其多姿多彩的主要实现方式,本章主要讲述关于Java对象,方法的种种规则,限制和建议。
建议31:在接口中不要存在实现代码
看到这样的标题,大家是否感到郁闷呢?接口中有实现代码吗?这怎么可能呢?确实,接口中可以声明常量,声明抽象方法,可以继承父接口,但就是不能有具体实现,因为接口是一种契约(Contract),是一种框架性协议,这表明它的实现类都是同一种类型,或者具备相似特征的一个集合体。对于一般程序,接口确实没有任何实现,但是在那些特殊的程序中就例外了,阅读如下代码:
1 public class Client31 { 2 public static void main(String[] args) { 3 //调用接口的实现 4 B.s.doSomeThing(); 5 } 6 } 7 8 // 在接口中存在实现代码 9 interface B { 10 public static final S s = new S() { 11 public void doSomeThing() { 12 System.out.println("我在接口中实现了"); 13 } 14 }; 15 } 16 17 // 被实现的接口 18 interface S { 19 public void doSomeThing(); 20 }
仔细看main方法,注意那个B接口。它调用了接口常量,在没有实现任何显示实现类的情况下,它竟然打印出了结果,那B接口中的s常量(接口是S)是在什么地方被实现的呢?答案在B接口中。
在B接口中声明了一个静态常量s,其值是一个匿名内部类(Anonymous Inner Class)的实例对象,就是该匿名内部类(当然,也可以不用匿名,直接在接口中是实现内部类也是允许的)实现了S接口。你看,在接口中也存在着实现代码吧!
这确实很好,很强大,但是在一般的项目中,此类代码是严禁出现的,原因很简单:这是一种非常不好的编码习惯,接口是用来干什么的?接口是一个契约,不仅仅约束着实现,同时也是一个保证,保证提供的服务(常量和方法)是稳定的、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。
注意:接口中不能出现实现代码。
建议32:静态变量一定要先声明后赋值
这个标题是否像上一个建议的标题一样让人郁闷呢?什么叫做变量一定要先声明后赋值?Java中的变量不都是先声明后使用的吗?难道还能先使用后声明?能不能暂且不说,我们看一个例子,代码如下:
1 public class Client32 { 2 public static int i = 1; 3 4 static { 5 i = 100; 6 } 7 public static void main(String[] args) { 8 System.out.println(i); 9 } 10 }
这段程序很简单,输出100嘛,对,确实是100,我们稍稍修改一下,代码如下:
1 public class Client32 { 2 static { 3 i = 100; 4 } 5 6 public static int i = 1; 7 8 public static void main(String[] args) { 9 System.out.println(i); 10 } 11 }
注意变量 i 的声明和赋值调换了位置,现在的问题是:这段程序能否编译?如过可以编译,输出是多少?还要注意,这个变量i可是先使用(也就是赋值)后声明的。
答案是:可以编译,没有任何问题,输出结果为1。对,输出是 1 不是100.仅仅调换了位置,输出就变了,而且变量 i 还是先使用后声明的,难道颠倒了?
这要从静态变量的诞生说起,静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。我们知道JVM初始化变量是先声明空间,然后再赋值,也就是说:在JVM中是分开执行的,等价于:
int i ; //分配空间
i = 100; //赋值
静态变量是在类初始化的时候首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照类的先后顺序执行赋值操作,首先执行静态块中i = 100,接着执行 i = 1,那最后的结果就是 i =1了。
哦,如此而已,如果有多个静态块对 i 继续赋值呢?i 当然还是等于1了,谁的位置最靠后谁有最终的决定权。
有些程序员喜欢把变量定义放到类最底部,如果这是实例变量还好说,没有任何问题,但如果是静态变量,而且还在静态块中赋值了,那这结果就和期望的不一样了,所以遵循Java通用的开发规范"变量先声明后赋值使用",是一个良好的编码风格。
注意:再次重申变量要先声明后使用,这不是一句废话。
建议33:不要覆写静态方法
我们知到在Java中可以通过覆写(Override)来增强或减弱父类的方法和行为,但覆写是针对非静态方法(也叫做实例方法,只有生成实例才能调用的方法)的,不能针对静态方法(static修饰的方法,也叫做类方法),为什么呢?我们看一个例子,代码如下:
1 public class Client33 { 2 public static void main(String[] args) { 3 Base base = new Sub(); 4 //调用非静态方法 5 base.doAnything(); 6 //调用静态方法 7 base.doSomething(); 8 } 9 } 10 11 class Base { 12 // 我是父类静态方法 13 public static void doSomething() { 14 System.out.println("我是父类静态方法"); 15 } 16 17 // 父类非静态方法 18 public void doAnything() { 19 System.out.println("我是父类非静态方法"); 20 } 21 } 22 23 class Sub extends Base { 24 // 子类同名、同参数的静态方法 25 public static void doSomething() { 26 System.out.println("我是子类静态方法"); 27 } 28 29 // 覆写父类非静态方法 30 @Override 31 public void doAnything() { 32 System.out.println("我是子类非静态方法"); 33 } 34 }
注意看程序,子类的doAnything方法覆写了父类方法,真没有问题,那么doSomething方法呢?它与父类的方法名相同,输入、输出也相同,按道理来说应该是覆写,不过到底是不是覆写呢?我们看看输出结果: 我是子类非静态方法 我是父类静态方法
这个结果很让人困惑,同样是调用子类方法,一个执行了父类方法,两者的差别仅仅是有无static修饰,却得到不同的结果,原因何在呢?
我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明的类型,实际类型是对象产生时的类型,比如我们例子,变量base的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名来访问的;其次,可以通过对象访问静态方法,如果是通过对象访问静态方法,JVM则会通过对象的表面类型查找静态方法的入口,继而执行之。因此上面的程序打印出"我是父类非静态方法",也就不足为奇了。
在子类中构建与父类方法相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类,子类都是静态方法,此种行为叫做隐藏(Hide),它与覆写有两点不同:
(1)、表现形式不同:隐藏用于静态方法,覆写用于非静态方法,在代码上的表现是@Override注解可用于覆写,不可用于隐藏。
(2)、职责不同:隐藏的目的是为了抛弃父类的静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是i期望父类的静态方法不要做破坏子类的业务行为,而覆写是将父类的的行为增强或减弱,延续父类的职责。
解释了这么多,我们回头看看本建议的标题,静态方法不能覆写,可以再续上一句话,虽然不能覆写,但可以隐藏。顺便说一下,通过实例对象访问静态方法或静态属性不是好习惯,它给代码带来了"坏味道",建议大家阅之戒之。
建议34:构造函数尽量简化
我们知道通过new关键字生成的对象必然会调用构造函数,构造函数的简繁情况会直接影响实例对象的创建是否繁琐,在项目开发中,我们一般都会制定构造函数尽量简单,尽可能不抛异常,尽量不做复杂运算等规范,那如果一个构造函数确实复杂了会怎么样?我们开看一段代码:
1 public class Client34 { 2 public static void main(String[] args) { 3 Server s= new SimpleServer(1000); 4 } 5 } 6 7 abstract class Server { 8 public final static int DEFAULT_PORT = 40000; 9 10 public Server() { 11 // 获得子类提供的端口号 12 int port = getPort(); 13 System.out.println("端口号:" + port); 14 /* 进行监听动作 */ 15 } 16 17 // 由子类提供端口号,并作可用性检查 18 protected abstract int getPort(); 19 } 20 21 class SimpleServer extends Server { 22 private int port = 100; 23 24 // 初始化传递一个端口号 25 public SimpleServer(int _port) { 26 port = _port; 27 } 28 29 // 检查端口是否有效,无效则使用默认端口,这里使用随机数模拟 30 @Override 31 protected int getPort() { 32 return Math.random() > 0.5 ? port : DEFAULT_PORT; 33 } 34 35 }
该代码是一个服务类的简单模拟程序,Server类实现了服务器的创建逻辑,子类要在生成实例对象时传递一个端口号即可创建一个监听端口的服务,该代码的意图如下:
- 通过SimpleServer的构造函数接收端口参数;
- 子类的构造函数默认调用父类的构造函数;
- 父类构造函数调用子类的getPort方法获得端口号;
- 父类的构造函数建立端口监听机制;
- 对象创建完毕,服务监听启动,正常运行。
貌似很合理,再仔细看看代码,确实与我们的意图相吻合,那我们尝试多次运行看看,输出结果要么是"端口号:40000",要么是"端口号:0",永远不会出现"端口号:100"或是"端口号:1000",这就奇怪了,40000还好说,那个0是怎么冒出来的呢?怠慢什么地方出现了问题呢?
要解释这个问题,我们首先要说说子类是如何实例化的。子类实例化时,会首先初始化父类(注意这里是初始化,不是生成父类对象),也就是初始化父类的变量,调用父类的构造函数,然后才会初始化子类的变量,调用子类的构造函数,最后生成一个实例对象。了解了相关知识,我们再来看看上面的程序,其执行过程如下:
- 子类SimpleServer的构造函数接收int类型的参数1000;
- 父类初始化常量,也就是DEFAULT_PORT初始化,并设置值为40000;
- 执行父类无参构造函数,也就是子类有参构造函数默认包含了super()方法;
- 父类无参构造函数执行到“int port = getPort() ”方法,调用子类的getPort方法实现;
- 子类的getPort方法返回port值(注意,此时port变量还没有赋值,是0)或DEFAULT_PORT(此时已经是40000)了;
- 父类初始化完毕,开始初始化子类的实例变量,port值赋值100;
- 执行子类构造函数,port值被重新赋值为1000;
- 子类SimpleServer实例化结束,对象创建完毕。
终于清楚了,在类初始化时getPort方法返回值还没有赋值,port只是获得了默认初始值(int类型的实例变量默认初始值是0),因此Server永远监听的是40000端口(0端口是没有意义的)。这个问题的产生从浅处说是类元素初始顺序导致的,从深处说是因为构造函数太复杂引起的。构造函数用作初始化变量,声明实例的上下文,这都是简单实现的,没有任何问题,但我们的例子却实现了一个复杂的逻辑,而这放在构造函数里就不合适了。
问题知道了,修改也很简单,把父类的无参构造函数中的所有实现都移动到一个叫做start的方法中,将SimpleServer类初始化完毕,再调用其start方法即可实现服务器的启动工作,简洁而又直观,这也是大部分JEE服务器的实现方式。
注意:构造函数简化,再简化,应该达到"一眼洞穿"的境界。
建议35:避免在构造函数中初始化其它类
构造函数是一个类初始化必须执行的代码,它决定着类初始化的效率,如果构造函数比较复杂,而且还关联了其它类,则可能产生想不到的问题,我们来看如下代码:
1 public class Client35 { 2 public static void main(String[] args) { 3 Son son = new Son(); 4 son.doSomething(); 5 } 6 } 7 8 // 父类 9 class Father { 10 public Father() { 11 new Other(); 12 } 13 } 14 15 // 相关类 16 class Other { 17 public Other() { 18 new Son(); 19 } 20 } 21 22 // 子类 23 class Son extends Father { 24 public void doSomething() { 25 System.out.println("Hi, show me Something!"); 26 } 27 }
这段代码并不复杂,只是在构造函数中初始化了其它类,想想看这段代码的运行结果是什么?会打印出"Hi ,show me Something!"吗?
答案是这段代码不能运行,报StatckOverflowError异常,栈(Stack)内存溢出,这是因为声明变量son时,调用了Son的无参构造函数,JVM又默认调用了父类的构造函数,接着Father又初始化了Other类,而Other类又调用了Son类,于是一个死循环就诞生了,知道内存被消耗完停止。
大家可能觉得这样的场景不会出现在开发中,我们来思考这样的场景,Father是由框架提供的,Son类是我们自己编写的扩展代码,而Other类则是框架要求的拦截类(Interceptor类或者Handle类或者Hook方法),再来看看问题,这种场景不可能出现吗?
可能大家会觉得这样的场景不会出现,这种问题只要系统一运行就会发现,不可能对项目产生影响。
那是因为我们这里展示的代码比较简单,很容易一眼洞穿,一个项目中的构造函数可不止一两个,类之间的关系也不会这么简单,要想瞥一眼就能明白是否有缺陷这对所有人员来说都是不可能完成的任务,解决此类问题最好的办法就是:不要在构造函数中声明初始化其他类,养成良好习惯。