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

  第3章 类、对象及方法

  书读得多而不思考,你会觉得自己知道的很多。

  书读得多而思考,你会觉得自己不懂的越来越多。

  —伏尔泰

  在面向对象编程(Object-OrientedProgramming,OOP)的世界里,类和对象是真实世界的描述工具,方法是行为和动作的展示形式,封装、继承、多态则是其多姿多彩的主要实现方式,如此,OOP才会像现在这样繁荣昌盛、欣欣向荣。

  本章主要讲述关于Java类、对象、方法的种种规则、限制及建议,让读者在面向对象编程的世界中走得更远,飞得更高。

  建议31:在接口中不要存在实现代码

  看到这样的标题读者可能会纳闷:接口中有实现代码?这怎么可能呢?确实,接口中可以声明常量,声明抽象方法,也可以继承父接口,但就是不能有具体实现,因为接口是一种契约(Contract),是一种框架性协议,这表明它的实现类都是同一种类型,或者是具备相似特征的一个集合体。对于一般程序,接口确实没有任何实现,但是在那些特殊的程序中就例外了,阅读如下代码:

  

    publicclassClient{ publicstaticvoidmain(String[]args){ //调用接口的实现 B.s.doSomething(); } } //在接口中存在实现代码 interfaceB{ publicstaticfinalSs=newS(){ publicvoiddoSomething(){ System.out.println(“我在接口中实现了”); } }; } //被实现的接口 interfaceS{ publicvoiddoSomething(); }

  仔细看main方法,注意那个B接口。它调用了接口常量,在没有任何显式实现类的情况下,它竟然打印出了结果,那B接口中的s常量(接口是S)是在什么地方被实现的呢?答案是在B接口中。

  在B接口中声明了一个静态常量s,其值是一个匿名内部类(Anonymous Inner Class)的实例对象,就是该匿名内部类(当然,可以不用匿名,直接在接口中实现内部类也是允许的)实现了S接口。你看,在接口中存在着实现代码吧!

  这确实很好,很强大,但是在一般的项目中,此类代码是严禁出现的,原因很简单:这是一种不好的编码习惯,接口是用来干什么的?接口是一个契约,不仅仅约束着实现者,同时也是一个保证,保证提供的服务(常量、方法)是稳定、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这就会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。

  注意:接口中不能存在实现代码。

  建议32:静态变量一定要先声明后赋值

  这标题看着让人很纳闷,什么叫做变量一定要先声明后赋值?Java中的变量不都是先声明后使用的吗?难道还能先使用后声明?能不能暂且不说,我们先来看一个例子,代码如下:

  

    publicclassClient{ publicstaticinti=1; static{ i=100; } publicstaticvoidmain(String[]args){ System.out.println(i); } }

  这段程序很简单,输出100嘛!对,确实是100,我们再稍稍修改一下,代码如下:

  

    publicclassClient{ static{ i=100; } publicstaticinti=1; publicstaticvoidmain(String[]args){ System.out.println(i); } }

  注意,变量i的声明和赋值调换了位置,现在的问题是:这段程序能否编译?如果可以编译那输出是多少?还要注意:这个变量i可是先使用(也就是赋值)后声明的。

  答案是:可以编译,没有任何问题,输出是1。对,你没有看错,输出确实是1,而不是100。仅仅调换了一下位置,输出就变了,而且变量i还真是先使用后声明的,难道这世界真的颠倒了?

  这要从静态变量的诞生说起了,静态变量是类加载时被分配到数据区(DataArea)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。我们知道JVM初始化变量是先声明空间,然后再赋值的,也就是说:

  

    inti=100; 在JVM中是分开执行,等价于: inti;//分配地址空间 i=100;//赋值

  静态变量是在类初始化时首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照类中的先后顺序执行赋值动作,首先执行静态块中i=100,接着执行i=1,那最后的结果就是i=1了。

  哦,如此而已,那再问一个问题:如果有多个静态块对i继续赋值呢?i当然还是等于1了,谁的位置最靠后谁有最终的决定权。

  有些程序员喜欢把变量定义放到类的底部,如果这是实例变量还好说,没有任何问题,但如果是静态变量,而且还在静态块中进行了赋值,那这结果可就和你期望的不一样了,所以遵循Java通用的开发规范“变量先声明后使用”是一个良好的编码风格。

  注意:再次重申变量要先声明后使用,这不是一句废话。

  建议33:不要覆写静态方法

  我们知道在Java中可以通过覆写(Override)来增强或减弱父类的方法和行为,但覆写是针对非静态方法(也叫做实例方法,只有生成实例才能调用的方法)的,不能针对静态方法(static修饰的方法,也叫做类方法),为什么呢?我们先看一个例子,代码如下:

  

    publicclassClient{ publicstaticvoidmain(String[]args){ Basebase=newSub(); //调用非静态方法 base.doAnything(); //调用静态方法 base.doSomething(); } } classBase{ //父类静态方法 publicstaticvoiddoSomething(){ System.out.println(“我是父类静态方法”); } //父类非静态方法 publicvoiddoAnything(){ System.out.println(“我是父类非静态方法”); } } classSubextendsBase{ //子类同名、同参数的静态方法 publicstaticvoiddoSomething(){ System.out.println(“我是子类静态方法”); } //覆写父类的非静态方法 @Override publicvoiddoAnything(){ System.out.println(“我是子类非静态方法”); } }

  注意看程序,子类的doAnything方法覆写了父类方法,这没有任何问题,那doSomething方法呢?它与父类的方法名相同,输入、输出也相同,按道理来说应该是覆写,不过到底是不是覆写呢?我们先看输出结果:

  

    我是子类非静态方法 我是父类静态方法

  这个结果很让人困惑,同样是调用子类方法,一个执行了子类方法,一个执行了父类方法,两者的差别仅仅是有无static修饰,却得到不同的输出结果,原因何在呢?

  我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明时的类型,实际类型是对象产生时的类型,比如我们例子,变量base的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名访问的;其次,可以通过对象访问静态方法,如果是通过对象调用静态方法,JVM则会通过对象的表面类型查找到静态方法的入口,继而执行之。因此上面的程序打印出“我是父类静态方法”,也就不足为奇了。

  在子类中构建与父类相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类、子类都是静态方法,此种行为叫做隐藏(Hide),它与覆写有两点不同:

  表现形式不同。隐藏用于静态方法,覆写用于非静态方法。在代码上的表现是:@Override注解可以用于覆写,不能用于隐藏。

  职责不同。隐藏的目的是为了抛弃父类静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是期望父类的静态方法不要破坏子类的业务行为;而覆写则是将父类的行为增强或减弱,延续父类的职责。

  解释了这么多,我们回头看一下本建议的标题:静态方法不能覆写,可以再续上一句话,虽然不能覆写,但是可以隐藏。顺便说一下,通过实例对象访问静态方法或静态属性不是好习惯,它给代码带来了“坏味道”,建议读者阅之戒之。

  建议34:构造函数尽量简化

  我们知道在通过new关键字生成对象时必然会调用构造函数,构造函数的简繁情况会直接影响实例对象的创建是否繁琐。在项目开发中,我们一般都会制订构造函数尽量简单,尽可能不抛异常,尽量不做复杂算法等规范,那如果一个构造函数确实复杂了会怎么样?我们来看一段代码:

  

    publicclassClient{ publicstaticvoidmain(String[]args){ Servers=newSimpleServer(1000); } } //定义一个服务 abstractclassServer{ publicfinalstaticintDEFAULT_PORT=40000; publicServer(){ //获得子类提供的端口号 intport=getPort(); System.out.println(“端口号:”+port); /*进行监听动作*/ } //由子类提供端口号,并做可用性检查 protectedabstractintgetPort(); } classSimpleServerextendsServer{ privateintport=100; //初始化传递一个端口号 publicSimpleServer(int_port){ port=_port; } //检查端口号是否有效,无效则使用默认端口,这里使用随机数模拟 @Override protectedintgetPort(){ returnMath.random()>0.5?port:DEFAULT_PORT; } }

  该代码是一个服务类的简单模拟程序,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值还没有赋值,port只是获得了默认初始值(int类的实例变量默认初始值是0),因此Server永远监听的是40000端口了(0端口是没有意义的)。这个问题的产生从浅处说是由类元素初始化顺序导致的,从深处说是因为构造函数太复杂而引起的。构造函数用作初始化变量,声明实例的上下文,这都是简单的实现,没有任何问题,但我们的例子却实现了一个复杂的逻辑,而这放在构造函数里就不合适了。

  问题知道了,修改也很简单,把父类的无参构造函数中的所有实现都移动到一个叫做start的方法中,将SimpleServer类初始化完毕,再调用其start方法即可实现服务器的启动工作,简洁而又直观,这也是大部分JEE服务器的实现方式。

  注意:构造函数简化,再简化,应该达到“一眼洞穿”的境界。

  相关链接:

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

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

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

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

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

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

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

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

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

最困难之时,就是我们离成功不远之日。

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

相关文章:

你感兴趣的文章:

标签云: