第3章 类、对象及方法
书读得多而不思考,你会觉得自己知道的很多。
书读得多而思考,你会觉得自己不懂的越来越多。
—伏尔泰
在面向对象编程(Object-Oriented Programming,OOP)的世界里,类和对象是真实世界的描述工具,方法是行为和动作的展示形式,封装、继承、多态则是其多姿多彩的主要实现方式,如此,OOP才会像现在这样繁荣昌盛、欣欣向荣。
本章主要讲述关于Java类、对象、方法的种种规则、限制及建议,让读者在面向对象编程的世界中走得更远,飞得更高。
建议31: 在接口中不要存在实现代码
看到这样的标题读者可能会纳闷:接口中有实现代码?这怎么可能呢?确实,接口中可以声明常量,声明抽象方法,也可以继承父接口,但就是不能有具体实现,因为接口是一种契约(Contract),是一种框架性协议,这表明它的实现类都是同一种类型,或者是具备相似特征的一个集合体。对于一般程序,接口确实没有任何实现,但是在那些特殊的程序中就例外了,阅读如下代码:
- public class Client {
- public static void main(String[] args) {
- //调用接口的实现
- B.s.doSomething();
- }
- }
- //在接口中存在实现代码
- interface B{
- public static final S s = new S(){
- public void doSomething(){
- System.out.println("我在接口中实现了");
- }
- };
- }
- //被实现的接口
- interface S{
- public void doSomething();
- }
仔细看main方法,注意那个B接口。它调用了接口常量,在没有任何显式实现类的情况下,它竟然打印出了结果,那B接口中的s常量(接口是S)是在什么地方被实现的呢?答案是在B接口中。
在B接口中声明了一个静态常量s,其值是一个匿名内部类(Anonymous Inner Class)的实例对象,就是该匿名内部类(当然,可以不用匿名,直接在接口中实现内部类也是允许的)实现了S接口。你看,在接口中存在着实现代码吧!
这确实很好,很强大,但是在一般的项目中,此类代码是严禁出现的,原因很简单:这是一种不好的编码习惯,接口是用来干什么的?接口是一个契约,不仅仅约束着实现者,同时也是一个保证,保证提供的服务(常量、方法)是稳定、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这就会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。
注意 接口中不能存在实现代码。
建议32: 静态变量一定要先声明后赋值
这标题看着让人很纳闷,什么叫做变量一定要先声明后赋值?Java中的变量不都是先声明后使用的吗?难道还能先使用后声明?能不能暂且不说,我们先来看一个例子,代码如下:
- public class Client {
- public static int i=1;
- static{
- i=100;
- }
- public static void main(String[] args) {
- System.out.println(i);
- }
- }
这段程序很简单,输出100嘛!对,确实是100,我们再稍稍修改一下,代码如下:
- public class Client {
- static{
- i=100;
- }
- public static int i=1;
- public static void main(String[] args) {
- System.out.println(i);
- }
- }
注意,变量i的声明和赋值调换了位置,现在的问题是:这段程序能否编译?如果可以编译那输出是多少?还要注意:这个变量i可是先使用(也就是赋值)后声明的。
答案是:可以编译,没有任何问题,输出是1。对,你没有看错,输出确实是1,而不是100。仅仅调换了一下位置,输出就变了,而且变量i还真是先使用后声明的,难道这世界真的颠倒了?
这要从静态变量的诞生说起了,静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。我们知道JVM初始化变量是先声明空间,然后再赋值的,也就是说:
- int i=100;
- 在JVM中是分开执行,等价于:
- int i; //分配地址空间
- i=100; //赋值
静态变量是在类初始化时首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照类中的先后顺序执行赋值动作,首先执行静态块中i=100,接着执行i=1,那最后的结果就是i=1了。
哦,如此而已,那再问一个问题:如果有多个静态块对i继续赋值呢?i当然还是等于1了,谁的位置最靠后谁有最终的决定权。
有些程序员喜欢把变量定义放到类的底部,如果这是实例变量还好说,没有任何问题,但如果是静态变量,而且还在静态块中进行了赋值,那这结果可就和你期望的不一样了,所以遵循Java通用的开发规范“变量先声明后使用”是一个良好的编码风格。
注意 再次重申变量要先声明后使用,这不是一句废话。
建议33: 不要覆写静态方法
我们知道在Java中可以通过覆写(Override)来增强或减弱父类的方法和行为,但覆写是针对非静态方法(也叫做实例方法,只有生成实例才能调用的方法)的,不能针对静态方法(static修饰的方法,也叫做类方法),为什么呢?我们先看一个例子,代码如下:
- public class Client {
- public static void main(String[] args) {
- Base base = new Sub();
- //调用非静态方法
- base.doAnything();
- //调用静态方法
- base.doSomething();
- }
- }
- class Base{
- //父类静态方法
- public static void doSomething(){
- System.out.println("我是父类静态方法");
- }
- //父类非静态方法
- public void doAnything(){
- System.out.println("我是父类非静态方法");
- }
- }
- class Sub extends Base{
- //子类同名、同参数的静态方法
- public static void doSomething(){
- System.out.println("我是子类静态方法");
- }
- //覆写父类的非静态方法
- @Override
- public void doAnything(){
- System.out.println("我是子类非静态方法");
- }
- }
注意看程序,子类的doAnything方法覆写了父类方法,这没有任何问题,那doSomething方法呢?它与父类的方法名相同,输入、输出也相同,按道理来说应该是覆写,不过到底是不是覆写呢?我们先看输出结果:
- 我是子类非静态方法
- 我是父类静态方法
这个结果很让人困惑,同样是调用子类方法,一个执行了子类方法,一个执行了父类方法,两者的差别仅仅是有无static修饰,却得到不同的输出结果,原因何在呢?
我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明时的类型,实际类型是对象产生时的类型,比如我们例子,变量base的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名访问的;其次,可以通过对象访问静态方法,如果是通过对象调用静态方法,JVM则会通过对象的表面类型查找到静态方法的入口,继而执行之。因此上面的程序打印出“我是父类静态方法”,也就不足为奇了。
在子类中构建与父类相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类、子类都是静态方法,此种行为叫做隐藏(Hide),它与覆写有两点不同:
表现形式不同。隐藏用于静态方法,覆写用于非静态方法。在代码上的表现是:@Override注解可以用于覆写,不能用于隐藏。
职责不同。隐藏的目的是为了抛弃父类静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是期望父类的静态方法不要破坏子类的业务行为;而覆写则是将父类的行为增强或减弱,延续父类的职责。
解释了这么多,我们回头看一下本建议的标题:静态方法不能覆写,可以再续上一句话,虽然不能覆写,但是可以隐藏。顺便说一下,通过实例对象访问静态方法或静态属性不是好习惯,它给代码带来了“坏味道”,建议读者阅之戒之。
建议34: 构造函数尽量简化
我们知道在通过new关键字生成对象时必然会调用构造函数,构造函数的简繁情况会直接影响实例对象的创建是否繁琐。在项目开发中,我们一般都会制订构造函数尽量简单,尽可能不抛异常,尽量不做复杂算法等规范,那如果一个构造函数确实复杂了会怎么样?我们来看一段代码:
- public class Client {
- public static void main(String[] args) {
- Server s = new SimpleServer(1000);
- }
- }
- //定义一个服务
- abstract class Server{
- public final static int DEFAULT_PORT = 40000;
- public Server(){
- //获得子类提供的端口号
- int port = getPort();
- System.out.println("端口号:" + port);
- /*进行监听动作*/
- }
- //由子类提供端口号,并做可用性检查
- protected abstract int getPort();
- }
- class SimpleServer extends Server{
- private int port=100;
- //初始化传递一个端口号
- public SimpleServer(int _port){
- port = _port;
- }
- //检查端口号是否有效,无效则使用默认端口,这里使用随机数模拟
- @Override
- protected int getPort() {
- return Math.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服务器的实现方式。
注意 构造函数简化,再简化,应该达到“一眼洞穿”的境界。
建议35: 避免在构造函数中初始化其他类
构造函数是一个类初始化必须执行的代码,它决定着类的初始化效率,如果构造函数比较复杂,而且还关联了其他类,则可能产生意想不到的问题,我们来看如下代码:
- public class Client {
- public static void main(String[] args) {
- Son s = new Son();
- s.doSomething();
- }
- }
- //父类
- class Father{
- Father(){
- new Other();
- }
- }//子类
- class Son extends Father{
- public void doSomething(){
- System.out.println("Hi,show me something");
- }
- }
- //相关类
- class Other{
- public Other(){
- new Son();
- }
- }
这段代码并不复杂,只是在构造函数中初始化了其他类,想想看这段代码的运行结果是什么?是打印“Hi,show me something”吗?
答案是这段代码不能运行,报*Error异常,栈(Stack)内存溢出。这是因为声明s变量时,调用了Son的无参构造函数,JVM又默认调用了父类Father的无参构造函数,接着Father类又初始化了Other类,而Other类又调用了Son类,于是一个死循环就诞生了,直到栈内存被消耗完毕为止。
可能有读者会觉得这样的场景不可能在开发中出现,那我们来思考这样的场景:Father是由框架提供的,Son类是我们自己编写的扩展代码,而Other类则是框架要求的拦截类(Interceptor类或者Handle类或者Hook方法),再来看看该问题,这种场景不可能出现吗?
那有读者可能要说了,这种问题只要系统一运行就会发现,不可能对项目产生影响。
那是因为我们在这里展示的代码比较简单,很容易一眼洞穿,一个项目中的构造函数可不止一两个,类之间的关系也不会这么简单的,要想瞥一眼就能明白是否有缺陷这对所有人员来说都是不可能完成的任务,解决此类问题的最好办法就是:不要在构造函数中声明初始化其他类,养成良好的习惯。
建议36: 使用构造代码块精炼程序
什么叫代码块(Code Block)?用大括号把多行代码封装在一起,形成一个独立的数据体,实现特定算法的代码集合即为代码块,一般来说代码块是不能单独运行的,必须要有运行主体。在Java中一共有四种类型的代码块:
(1)普通代码块
就是在方法后面使用“{}”括起来的代码片段,它不能单独执行,必须通过方法名调用执行。
(2)静态代码块
在类中使用static修饰,并使用“{}”括起来的代码片段,用于静态变量的初始化或对象创建前的环境初始化。
(3)同步代码块
使用synchronized关键字修饰,并使用“{}”括起来的代码片段,它表示同一时间只能有一个线程进入到该方法块中,是一种多线程保护机制。
(4)构造代码块
在类中没有任何的前缀或后缀,并使用“{}”括起来的代码片段。
我们知道,一个类至少有一个构造函数(如果没有,编译器会无私地为其创建一个无参构造函数),构造函数是在对象生成时调用的,那现在的问题来了:构造函数和构造代码块是什么关系?构造代码块是在什么时候执行的?在回答这个问题之前,我们先来看看编译器是如何处理构造代码块的,看如下代码:
- public class Client {
- {
- //构造代码块
- System.out.println("执行构造代码块");
- }
- public Client(){
- System.out.println("执行无参构造");
- }
- public Client(String _str){
- System.out.println("执行有参构造");
- }
- }
这是一段非常简单的代码,它包含了构造代码块、无参构造、有参构造,我们知道代码块不具有独立执行的能力,那么编译器是如何处理构造代码块呢?很简单,编译器会把构造代码块插入到每个构造函数的最前端,上面的代码与如下代码等价:
- public class Client {
- public Client(){
- System.out.println("执行构造代码块");
- System.out.println("执行无参构造");
- }
- public Client(String _str){
- System.out.println("执行构造代码块");
- System.out.println("执行有参构造");
- }
- }
每个构造函数的最前端都被插入了构造代码块,很显然,在通过new关键字生成一个实例时会先执行构造代码块,然后再执行其他代码,也就是说:构造代码块会在每个构造函数内首先执行(需要注意的是:构造代码块不是在构造函数之前运行的,它依托于构造函数的执行),明白了这一点,我们就可以把构造代码块应用到如下场景中:
(1)初始化实例变量(Instance Variable)
如果每个构造函数都要初始化变量,可以通过构造代码块来实现。当然也可以通过定义一个方法,然后在每个构造函数中调用该方法来实现,没错,可以解决,但是要在每个构造函数中都调用该方法,而这就是其缺点,若采用构造代码块的方式则不用定义和调用,会直接由编译器写入到每个构造函数中,这才是解决此类问题的绝佳方式。
(2)初始化实例环境
一个对象必须在适当的场景下才能存在,如果没有适当的场景,则就需要在创建对象时创建此场景,例如在JEE开发中,要产生HTTP Request必须首先建立HTTP Session,在创建HTTP Request时就可以通过构造代码块来检查HTTP Session是否已经存在,不存在则创建之。
以上两个场景利用了构造代码块的两个特性:在每个构造函数中都运行和在构造函数中它会首先运行。很好地利用构造代码块的这两个特性不仅可以减少代码量,还可以让程序更容易阅读,特别是当所有的构造函数都要实现逻辑,而且这部分逻辑又很复杂时,这时就可以通过编写多个构造代码块来实现。每个代码块完成不同的业务逻辑(当然了,构造函数尽量简单,这是基本原则),按照业务顺序依次存放,这样在创建实例对象时JVM也就会按照顺序依次执行,实现复杂对象的模块化创建。
建议37: 构造代码块会想你所想
上一个建议中我们提议使用构造代码块来简化代码,并且也了解到编译器会自动把构造代码块插入到各个构造函数中,那我们接下来看看编译器是不是足够聪明,能够为我们解决真实的开发问题。有这样一个案例:统计一个类的实例数量。可能你要说了,这很简单,在每个构造函数中加入一个对象计数器不就解决问题了吗?或者使用我们上一个建议介绍的,使用构造代码块也可以。确实如此,我们来看如下代码是否可行:
- public class Client {
- public static void main(String[] args) {
- new Base();
- new Base("");
- new Base(0);
- System.out.println("实例对象数量:" + Base.getNumOfObjects());
- }
- }
- class Base{
- //对象计数器
- private static int numOfObjects = 0;
- {
- //构造代码块,计算产生对象数量
- numOfObjects++;
- }
- public Base(){
- }
- //有参构造调用无参构造
- public Base(String _str){
- this();
- }
- //有参构造不调用其他构造
- public Base(int _i){
- }
- //返回在一个JVM中,创建了多少个实例对象
- public static int getNumOfObjects(){
- return numOfObjects;
- }
- }
这段代码是可行的吗?能计算出实例对象的数量吗?哎,好像不对呀,如果编译器把构造代码块插入到各个构造函数中,那带有String形参的构造函数可就有问题,它会调用无参构造,那通过它生成Base对象时就会执行两次构造代码块:一次是由无参构造函数调用构造代码块,一次是执行自身的构造代码块,这样的话计算可就不准确了,main函数实际在内存中产生了3个对象,但结果却会是4。不过真是这样的吗?Are you sure?我们运行一下看看结果:
实例对象数量:3
非常遗憾,你错了,实例对象的数量还是3,程序没有任何问题。奇怪吗?不奇怪,上一个建议是说编译器会把构造代码块插入到每一个构造函数中,但是有一个例外的情况没有说明:如果遇到this关键字(也就是构造函数调用自身其他的构造函数时)则不插入构造代码块,对于我们的例子来说,编译器在编译时发现String形参的构造函数调用了无参构造,于是放弃插入构造代码块,所以只执行了一次构造代码块—结果就是如此。
那Java编译器为什么会这么聪明呢?这还要从构造代码块的诞生说起,构造代码块是为了提取构造函数的共同量,减少各个构造函数的代码而产生的,因此,Java就很聪明地认为把代码块插入到没有this方法的构造函数中即可,而调用其他构造函数的则不插入,确保每个构造函数只执行一次构造代码块。
还有一点需要说明,读者千万不要以为this是特殊情况,那super也会类似处理了。其实不会,在构造代码块的处理上,super方法没有任何特殊的地方,编译器只是把构造代码块插入到super方法之后执行而已,仅此不同。
注意 放心地使用构造代码块吧,Java已经想你所想了。
建议38: 使用静态内部类提高封装性
Java中的嵌套类(Nested Class)分为两种:静态内部类(也叫静态嵌套类,Static Nested Class)和内部类(Inner Class)。内部类我们介绍过很多了,现在来看看静态内部类。什么是静态内部类呢?是内部类,并且是静态(static修饰)的即为静态内部类。只有在是静态内部类的情况下才能把static修复符放在类前,其他任何时候static都是不能修饰类的。
静态内部类的形式很好理解,但是为什么需要静态内部类呢?那是因为静态内部类有两个优点:加强了类的封装性和提高了代码的可读性,我们通过一段代码来解释这两个优点,如下所示:
- public class Person{
- //姓名
- private String name;
- //家庭
- private Home home;
- //构造函数设置属性值
- public Person(String _name){
- name = _name;
- }
- /* home、name的getter/setter方法省略 */
- public static class Home{
- //家庭地址
- private String address;
- //家庭电话
- private String tel;
- public Home(String _address,String _tel){
- address = _address;
- tel = _tel;
- }
- /* address、tel的getter/setter方法省略 */
- }
- }
其中,Person类中定义了一个静态内部类Home,它表示的意思是“人的家庭信息”,由于Home类封装了家庭信息,不用在Person类中再定义homeAddre、homeTel等属性,这就使封装性提高了。同时我们仅仅通过代码就可以分析出Person和Home之间的强关联关系,也就是说语义增强了,可读性提高了。所以在使用时就会非常清楚它要表达的含义:
- public static void main(String[] args) {
- //定义张三这个人
- Person p = new Person("张三");
- //设置张三的家庭信息
- p.setHome(new Person.Home("上海","021"));
- }
定义张三这个人,然后通过Person.Home类设置张三的家庭信息,这是不是就和我们真实世界的情形相同了?先登记人的主要信息,然后登记人员的分类信息。可能你又要问了,这和我们一般定义的类有什么区别呢?又有什么吸引人的地方呢?如下所示:
提高封装性。从代码位置上来讲,静态内部类放置在外部类内,其代码层意义就是:静态内部类是外部类的子行为或子属性,两者直接保持着一定的关系,比如在我们的例子中,看到Home类就知道它是Person的Home信息。
提高代码的可读性。相关联的代码放在一起,可读性当然提高了。
形似内部,神似外部。静态内部类虽然存在于外部类内,而且编译后的类文件名也包含外部类(格式是:外部类+$+内部类),但是它可以脱离外部类存在,也就是说我们仍然可以通过new Home()声明一个Home对象,只是需要导入“Person.Home”而已。
解释了这么多,读者可能会觉得外部类和静态内部类之间是组合关系(Composition)了,这是错误的,外部类和静态内部类之间有强关联关系,这仅仅表现在“字面”上,而深层次的抽象意义则依赖于类的设计。
那静态内部类与普通内部类有什么区别呢?问得好,区别如下:
(1)静态内部类不持有外部类的引用
在普通内部类中,我们可以直接访问外部类的属性、方法,即使是private类型也可以访问,这是因为内部类持有一个外部类的引用,可以*访问。而静态内部类,则只可以访问外部类的静态方法和静态属性(如果是private权限也能访问,这是由其代码位置所决定的),其他则不能访问。
(2)静态内部类不依赖外部类
普通内部类与外部类之间是相互依赖的关系,内部类实例不能脱离外部类实例,也就是说它们会同生同死,一起声明,一起被垃圾回收器回收。而静态内部类是可以独立存在的,即使外部类消亡了,静态内部类还是可以存在的。
(3)普通内部类不能声明static的方法和变量
普通内部类不能声明static的方法和变量,注意这里说的是变量,常量(也就是final static修饰的属性)还是可以的,而静态内部类形似外部类,没有任何限制。
建议39: 使用匿名类的构造函数
阅读如下代码,看看是否可以编译:
- public static void main(String[] args) {
- List l1 = new ArrayList();
- List l2 = new ArrayList(){};
- List l3 = new ArrayList(){{}};
- System.out.println(l1.getClass() == l2.getClass());
- System.out.println(l2.getClass() == l3.getClass());
- System.out.println(l1.getClass() == l3.getClass());
- }
注意ArrayList后面的不同点:l1变量后面什么都没有,l2后面有一对{},l3后面有2对嵌套的{},这段程序能不能编译呢?若能编译,那输出是多少呢?
答案是能编译,输出的是3个false。l1很容易解释,就是声明了ArrayList的实例对象,那l2和l3代表的是什么呢?
(1)l2=new ArrayList(){}
l2代表的是一个匿名类的声明和赋值,它定义了一个继承于ArrayList的匿名类,只是没有任何的覆写方法而已,其代码类似于:
- //定义一个继承ArrayList的内部类
- class Sub extends ArrayList{
- }
- //声明和赋值
- List l2 = new Sub();
(2) l3=new ArrayList(){{}}
这个语句就有点怪了,还带了两对大括号,我们分开来解释就会明白了,这也是一个匿名类的定义,它的代码类似于:
- //定义一个继承ArrayList的内部类
- class Sub extends ArrayList{
- {
- //初始化块
- }
- }
- //声明和赋值
- List l3 = new Sub();
看到了吧,就是多了一个初始化块而已,起到构造函数的功能。我们知道一个类肯定有一个构造函数,且构造函数的名称和类名相同,那问题来了:匿名类的构造函数是什么呢?它没有名字呀!很显然,初始化块就是它的构造函数。当然,一个类中的构造函数块可以是多个,也就是说可以出现如下代码:
- List l3 = new ArrayList(){{}{}{}{}{}};
上面的代码是正确无误,没有任何问题的。现在清楚了:匿名函数虽然没有名字,但也是可以有构造函数的,它用构造函数块来代替,那上面的3个输出就很清楚了:虽然父类相同,但是类还是不同的。
建议40: 匿名类的构造函数很特殊
在上一个建议中我们讲到匿名类虽然没有名字,但可以有一个初始化块来充当构造函数,那这个构造函数是否就和普通的构造函数完全一样呢?我们来看一个例子,设计一个计算器,进行加减乘除运算,代码如下:
- //定义一个枚举,限定操作符
- enum Ops {ADD, SUB}
- class Calculator {
- private int i, j, result;
- //无参构造
- public Calculator() {}
- //有参构造
- public Calculator(int _i, int _j) {
- i = _i;
- j = _j;
- }
- //设置符号,是加法运算还是减法运算
- protected void setOperator(Ops _op) {
- result = _op.equals(Ops.ADD)?i+j:i-j;
- }
- //取得运算结果
- public int getResult(){
- return result;
- }
- }
代码的意图是,通过构造函数输入两个int类型的数字,然后根据设置的操作符(加法还是减法)进行计算,编写一个客户端调用:
- public static void main(String[] args) {
- Calculator c1 = new Calculator(1,2) {
- {
- setOperator(Ops.ADD);
- }
- };
- System.out.println(c1.getResult());
- }
这段匿名类的代码非常清晰:接收两个参数1和2,然后设置一个操作符号,计算其值,结果是3,这毫无疑问,但是这中间隐藏着一个问题:带有参数的匿名类声明时到底是调用的哪一个构造函数呢?我们把这段程序模拟一下:
- //加法计算
- class Add extends Calculator {
- {
- setOperator(Ops.ADD);
- }
- //覆写父类的构造方法
- public Add(int _i, int _j) {
- }
- }
匿名类和这个Add类是等价的吗?可能有人会说:上面只是把匿名类增加了一个名字,其他的都没有改动,那肯定是等价的啦!毫无疑问!那好,你再写个客户端调用Add类的方法看看。是不是输出结果为0(为什么是0?这很容易,有参构造没有赋值)。这说明两者不等价,不过,原因何在呢?
原来是因为匿名类的构造函数特殊处理机制,一般类(也就是具有显式名字的类)的所有构造函数默认都是调用父类的无参构造的,而匿名类因为没有名字,只能由构造代码块代替,也就无所谓的有参和无参构造函数了,它在初始化时直接调用了父类的同参数构造,然后再调用了自己的构造代码块,也就是说上面的匿名类与下面的代码是等价的:
- //加法计算
- class Add extends Calculator {
- {
- setOperator(Ops.ADD);
- }
- //覆写父类的构造方法
- public Add(int _i, int _j) {
- super(_i,_j);
- }
- }
它首先会调用父类有两个参数的构造函数,而不是无参构造,这是匿名类的构造函数与普通类的差别,但是这一点也确实鲜有人细细琢磨,因为它的处理机制符合习惯呀,我传递两个参数,就是希望先调用父类有两个参数的构造,然后再执行我自己的构造函数,而Java的处理机制也正是如此处理的!
建议41: 让多重继承成为现实
在Java中一个类可以多重实现,但不能多重继承,也就是说一个类能够同时实现多个接口,但不能同时继承多个类。但有时候我们确实需要继承多个类,比如希望拥有两个类的行为功能,就很难使用单继承来解决问题了(当然,使用多层继承是可以解决的)。幸运的是Java中提供的内部类可以曲折地解决此问题,我们来看一个案例,定义一个父亲、母亲接口,描述父亲强壮、母亲温柔的理想情形,代码如下:
- //父亲
- interface Father{
- public int strong();
- }
- //母亲
- interface Mother{
- public int kind();
- }
其中strong和kind的返回值表示强壮和温柔的指数,指数越高强壮度和温柔度也就越高,这与在游戏中设置人物的属性值是一样的。我们继续来看父亲、母亲这两个实现:
- class FatherImpl implements Father{
- //父亲的强壮指数是8
- public int strong(){
- return 8;
- }
- }
- class MotherImpl implements Mother{
- //母亲的温柔指数是8
- public int kind(){
- return 8;
- }
- }
父亲强壮指数是8,母亲温柔指数也是8,门当户对,那他们生的儿子、女儿一定更优秀了,我们先来看儿子类,代码如下:
- class Son extends FatherImpl implements Mother{
- @Override
- public int strong(){
- //儿子比父亲强壮
- return super.strong() + 1;
- }
- @Override
- public int kind(){
- return new MotherSpecial().kind();
- }
- private class MotherSpecial extends MotherImpl{
- public int kind(){
- //儿子温柔指数降低了
- return super.kind() - 1;
- }
- }
- }
儿子继承自父亲,变得比父亲更强壮了(覆写父类strong方法),同时儿子也具有母亲的优点,只是温柔指数降低了。注意看,这里构造了MotherSpecial类继承母亲类,也就是获得了母亲类的行为方法,这也是内部类的一个重要特性:内部类可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,多重继承才会成为可能。MotherSpecial的这种内部类叫做成员内部类(也叫做实例内部类,Instance Inner Class)。我们再来看看女儿类,代码如下:
- class Daughter extends MotherImpl implements Father{
- @Override
- public int strong() {
- return new FatherImpl(){
- @Override
- public int strong() {
- //女儿的强壮指数降低了
- return super.strong() - 2 ;
- }
- }.strong();
- }
- }
女儿继承了母亲的温柔指数,同时又覆写父类的强壮指数,不多解释。注意看覆写的strong方法,这里是创建了一个匿名内部类(Anonymous Inner Class)来覆写父类的方法,以完成继承父亲行为的功能。
多重继承指的是一个类可以同时从多于一个的父类那里继承行为与特征,按照这个定义来看,我们的儿子类、女儿类都实现了从父亲类、母亲类那里所继承的功能,应该属于多重继承。这要完全归功于内部类,诸位在需要用到多重继承时,可以思考一下内部类。
在现实生活中,也确实存在多重继承的问题,上面的例子是说后人即继承了父亲也继承了母亲的行为和特征,再比如我国的特产动物“四不像"(学名麋鹿),其外形“似鹿非鹿,似马非马,似牛非牛,似驴非驴”,这你要是想用单继承表示就麻烦了,如果用多继承则可以很好地解决问题:定义鹿、马、牛、驴四个类,然后建立麋鹿类的多个内部类,继承它们即可。
建议42: 让工具类不可实例化
Java项目中使用的工具类非常多,比如JDK自己的工具类java.lang.Math、java.util.Collections等都是我们经常用到的。工具类的方法和属性都是静态的,不需要生成实例即可访问,而且JDK也做了很好的处理,由于不希望被初始化,于是就设置构造函数为private访问权限,表示除了类本身外,谁都不能产生一个实例,我们来看一下java.lang.Math代码:
- public final class Math {
- /**
- * Don't let anyone instantiate this class.
- */
- private Math() {}
- }
之所以要将“Don抰 let anyone instantiate this class.”留下来,是因为Math的构造函数设置为private了:我就是一个工具类,我只想要其他类通过类名来访问,我不想你通过实例对象访问。这在平台型或框架型项目中已经足够了。但是如果已经告诉你不能这么做了,你还要生成一个Math实例来访问静态方法和属性(Java的反射是如此的发达,修改个构造函数的访问权限易如反掌),那我就不保证正确性了,隐藏问题随时都有可能爆发!那我们在项目开发中有没有更好的限制办法呢?有,即不仅仅设置成private访问权限,还抛异常,代码如下:
- public class UtilsClass {
- private UtilsClass(){
- throw new Error("不要实例化我!");
- }
- }
如此做才能保证一个工具类不会实例化,并且保证所有的访问都是通过类名来进行的。需要注意一点的是,此工具类最好不要做继承的打算,因为如果子类可以实例化的话,那就要调用父类的构造函数,可是父类没有可以被访问的构造函数,于是问题就会出现。
注意 如果一个类不允许实例化,就要保证“平常”渠道都不能实例化它。
建议43: 避免对象的浅拷贝
我们知道一个类实现了Cloneable接口就表示它具备了被拷贝的能力,如果再覆写clone()方法就会完全具备拷贝能力。拷贝是在内存中进行的,所以在性能方面比直接通过new生成对象要快很多,特别是在大对象的生成上,这会使性能的提升非常显著。但是对象拷贝也有一个比较容易忽略的问题:浅拷贝(Shadow Clone,也叫做影子拷贝)存在对象属性拷贝不彻底的问题。我们来看这样一段代码:
- public class Client {
- public static void main(String[] args) {
- //定义父亲
- Person f = new Person("父亲");
- //定义大儿子
- Person s1 = new Person("大儿子",f);
- //小儿子的信息是通过大儿子拷贝过来的
- Person s2 = s1.clone();
- s2.setName("小儿子");
- System.out.println(s1.getName() +" 的父亲是 " + s1.getFather().getName());
- System.out.println(s2.getName() +" 的父亲是 " + s2.getFather().getName());
- }
- }
- class Person implements Cloneable{
- //姓名
- private String name;
- //父亲
- private Person father;
- public Person(String _name){
- name = _name;
- }
- public Person(String _name,Person _parent){
- name = _name;
- father = _parent;
- }
- /*name和parent的getter/setter方法省略*/
- //拷贝的实现
- @Override
- public Person clone(){
- Person p = null;
- try {
- p = (Person) super.clone();
- } catch (CloneNotSupportedException e) {
- e.printStackTrace();
- }
- return p;
- }
- }
程序中,我们描述了这样一个场景:一个父亲,有两个儿子,大小儿子同根同种,所以小儿子对象就通过拷贝大儿子对象来生成,运行输出的结果如下:
- 大儿子 的父亲是 父亲
- 小儿子 的父亲是 父亲
这很正确,没有问题。突然有一天,父亲心血来潮想让大儿子去认个干爹,也就是大儿子的父亲名称需要重新设置一下,代码如下:
- public static void main(String[] args) {
- //定义父亲
- Person f = new Person("父亲");
- //定义大儿子
- Person s1 = new Person("大儿子",f);
- //小儿子的信息是通过大儿子拷贝过来的
- Person s2 = s1.clone();
- s2.setName("小儿子");
- //认干爹
- s1.getFather().setName("干爹");
- System.out.println(s1.getName() +" 的父亲是 " + s1.getFather().getName());
- System.out.println(s2.getName() +" 的父亲是 " + s2.getFather().getName());
- }
上面仅仅修改了加粗字体部分,大儿子重新设置了父亲名称,我们期望的输出是:将大儿子父亲的名称修改为干爹,小儿子的父亲名称保持不变。下面来检查一下结果是否如此:
- 大儿子 的父亲是 干爹
- 小儿子 的父亲是 干爹
怎么回事,小儿子的父亲也成了“干爹”?两个儿子都没有,岂不是要气死“父亲”了!出现这个问题的原因就在于clone方法,我们知道所有类都继承自Object,Object提供了一个对象拷贝的默认方法,即上面代码中的super.clone方法,但是该方法是有缺陷的,它提供的是一种浅拷贝方式,也就是说它并不会把对象的所有属性全部拷贝一份,而是有选择性的拷贝,它的拷贝规则如下:
(1)基本类型
如果变量是基本类型,则拷贝其值,比如int、float等。
(2)对象
如果变量是一个实例对象,则拷贝地址引用,也就是说此时新拷贝出的对象与原有对象共享该实例变量,不受访问权限的限制。这在Java中是很疯狂的,因为它突破了访问权限的定义:一个private修饰的变量,竟然可以被两个不同的实例对象访问,这让Java的访问权限体系情何以堪!
(3)String字符串
这个比较特殊,拷贝的也是一个地址,是个引用,但是在修改时,它会从字符串池(String Pool)中重新生成新的字符串,原有的字符串对象保持不变,在此处我们可以认为String是一个基本类型。(有关字符串的知识详见第4章。)
明白了这三个规则,上面的例子就很清晰了,小儿子对象是通过拷贝大儿子产生的,其父亲都是同一个人,也就是同一个对象,大儿子修改了父亲名称,小儿子也就跟着修改了—于是,父亲的两个儿子都没了!其实要更正也很简单,clone方法的代码如下:
- public Person clone(){
- Person p = null;
- try {
- p = (Person) super.clone();
- p.setFather(new Person(p.getFather().getName()));
- } catch (CloneNotSupportedException e) {
- e.printStackTrace();
- }
- return p;
- }
然后再运行,小儿子的父亲就不会是“干爹”了。如此就实现了对象的深拷贝(Deep Clone),保证拷贝出来的对象自成一体,不受“母体”的影响,和new生成的对象没有任何区别。
注意 浅拷贝只是Java提供的一种简单拷贝机制,不便于直接使用。
建议44: 推荐使用序列化实现对象的拷贝
上一个建议说了对象的浅拷贝问题,实现Cloneable接口就具备了拷贝能力,那我们来思考这样一个问题:如果一个项目中有大量的对象是通过拷贝生成的,那我们该如何处理?每个类都写一个clone方法,并且还要深拷贝?想想看这是何等巨大的工作量呀,是否有更好的方法呢?
其实,可以通过序列化方式来处理,在内存中通过字节流的拷贝来实现,也就是把母对象写到一个字节流中,再从字节流中将其读出来,这样就可以重建一个新对象了,该新对象与母对象之间不存在引用共享的问题,也就相当于深拷贝了一个新对象,代码如下:
- public class CloneUtils {
- // 拷贝一个对象
- @SuppressWarnings("unchecked")
- public static <T extends Serializable> T clone(T obj) {
- // 拷贝产生的对象
- T clonedObj = null;
- try {
- // 读取对象字节数据
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- ObjectOutputStream oos = new ObjectOutputStream(baos);
- oos.writeObject(obj);
- oos.close();
- // 分配内存空间,写入原始对象,生成新对象
- ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
- ObjectInputStream ois = new ObjectInputStream(bais);
- //返回新对象,并做类型转换
- clonedObj = (T)ois.readObject();
- ois.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- return clonedObj;
- }
- }
此工具类要求被拷贝的对象必须实现Serializable接口,否则是没办法拷贝的(当然,使用反射那是另外一种技巧),上一个建议中的例子只要稍微修改一下即可实现深拷贝,代码如下:
- class Person implements Serializable{
- private static final long serialVersionUID = 1611293231L;
- /*删除掉clone方法,其他代码保持不变*/
- }
上去的,然后我们就可以通过CloneUtils工具进行对象的深拷贝了。用此方法进行对象拷贝时需要注意两点:
(1)对象的内部属性都是可序列化的
如果有内部属性不可序列化,则会抛出序列化异常,这会让调试者很纳闷:生成一个对象怎么会出现序列化异常呢?从这一点来考虑,也需要把CloneUtils工具的异常进行细化处理。
(2)注意方法和属性的特殊修饰符
比如final、static变量的序列化问题会被引入到对象拷贝中来(参考第1章),这点需要特别注意,同时transient变量(瞬态变量,不进行序列化的变量)也会影响到拷贝的效果。
当然,采用序列化方式拷贝时还有一个更简单的办法,即使用Apache下的commons工具包中的SerializationUtils类,直接使用更加简洁方便。
建议45: 覆写equals方法时不要识别不出自己
我们在写一个JavaBean时,经常会覆写equals方法,其目的是根据业务规则判断两个对象是否相等,比如我们写一个Person类,然后根据姓名判断两个实例对象是否相同,这在DAO(Data Access Objects)层是经常用到的。具体操作是先从数据库中获得两个DTO(Data Transfer Object,数据传输对象),然后判断它们是否是相等的,代码如下:
- class Person{
- private String name;
- public Person(String _name){
- name = _name;
- }
- /*name的getter/setter方法省略*/
- @Override
- public boolean equals(Object obj) {
- if(obj instanceof Person){
- Person p = (Person) obj;
- return name.equalsIgnoreCase(p.getName().trim());
- }
- return false;
- }
- }
覆写的equals做了多个校验,考虑到从Web上传递过来的对象有可能输入了前后空格,所以用trim方法剪切一下,看看代码有没有问题,我们写一个main:
- public static void main(String[] args) {
- Person p1 = new Person("张三");
- Person p2 = new Person("张三 ");
- List<Person> l =new ArrayList<Person>();
- l.add(p1);
- l.add(p2);
- System.out.println("列表中是否包含张三:"+l.contains(p1));
- System.out.println("列表中是否包含张三 :"+l.contains(p2));
- }
上面的代码产生了两个Person对象(注意p2变量中的那个张三后面有一个空格),然后放到List中,最后判断List是否包含了这两个对象。看上去没有问题,应该打印出两个true才是,但是结果却是:
- 列表中是否包含张三:true
- 列表中是否包含张三 :false
刚刚放到list中的对象竟然说没有,这太让人失望了,原因何在呢?List类检查是否包含元素时是通过调用对象的equals方法来判断的,也就是说constains(p2)传递进去,会依次执行p2.equals(p1)、p2.equals(p2),只要有一个返回true,结果就是true,可惜的是比较结果都是false,那问题就出来了:难道p2.equals(p2)也为false不成?
还真说对了,p2.equals(p2)确实是false,看看我们的equals方法,它把第二个参数进行了剪切!也就是说比较的是如下等式:
- "张三 ".equalsIgnoreCase("张三")
注意前面的“张三 ”是有空格的,那这个结果肯定是false了,错误也就此产生了。这是一个想做好事却办成了“坏事”的典型案例,它违背了equals方法的自反性原则:对于任何非空引用x,x.equals(x)应该返回true。
问题知道了,解决也非常容易,只要把trim()去掉即可,注意解决的只是当前问题,该equals方法还存在其他问题。
建议46: equals应该考虑null值情景
继续上一建议的问题,我们解决了覆写equals的自反性问题,是不是就很完美了呢?再把main方法重构一下:
很小的改动,那运行结果是什么呢?是两个true吗?我们来看运行结果:
- public static void main(String[] args) {
- Person p1 = new Person("张三");
- Person p2 = new Person(null);
- /*其他部分没有任何修改,不再赘述*/
- }
- 列表中是否包含张三:true
- Exception in thread "main" java.lang.NullPointerException
竟然抛异常了!为什么p1就能在List中检查一遍,并且执行p1.equals方法,而到了p2就开始报错了呢?仔细分析一下程序,马上明白了:当执行到p2.equals(p1)时,由于p2的name是一个null值,所以调用name. equalsIgnoreCase方法时就会报空指针异常了!出现这种情形是因为覆写equals没有遵循对称性原则:对于任何引用x和y的情形,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。
问题知道了,解决也很简单,增加name是否为空进行判断即可,修改后的equals代码如下:
- public boolean equals(Object obj) {
- if(obj instanceof Person){
- Person p = (Person) obj;
- if(p.getName()==null || name==null){
- return false;
- }else{
- return name.equalsIgnoreCase(p.getName());
- }
- }
- return false;
- }
建议47: 在equals中使用getClass进行类型判断
本节我们继续讨论覆写equals的问题。这次我们编写一个员工Employee类继承Person类,这很正常,员工也是人嘛,而且在JEE中JavaBean有继承关系也很常见,代码如下:
- class Employee extends Person{
- private int id;
- /*id的getter/setter方法省略*/
- public Employee(String _name,int _id) {
- super(_name);
- id = _id;
- }
- @Override
- public boolean equals(Object obj) {
- if(obj instanceof Employee){
- Employee e = (Employee) obj;
- return super.equals(obj)&& e.getId() == id;
- }
- return false;
- }
- }
员工类增加了工号ID属性,同时也覆写了equals方法,只有在姓名和ID号都相同的情况下才表示是同一个员工,这是为了避免在一个公司中出现同名同姓员工的情况。看看上面的代码,这里校验条件已经相当完备了,应该不会再出错了,那我们编写一个main方法来看看,代码如下:
- public static void main(String[] args) {
- Employee e1 = new Employee("张三",100);
- Employee e2 = new Employee("张三",1001);
- Person p1 = new Person("张三");
- System.out.println(p1.equals(e1));
- System.out.println(p1.equals(e2));
- System.out.println(e1.equals(e2));
- }
上面定义了2个员工和1个社会闲杂人员,虽然他们同名同姓,但肯定不是同一个,输出应该都是false,那我们看看运行结果:
- true
- true
- false
很不给力嘛,p1竟然等于e1,也等于e2,为什么不是同一个类的两个实例竟然也会相等呢?这很简单,因为p1.equals(e1) 是调用父类Person的equals方法进行判断的,它使用instanceof关键字检查e1是否是Person的实例,由于两者存在继承关系,那结果当然是true了,相等也就没有任何问题了,但是反过来就不成立了,e1或e2可不等于p1,这也是违反对称性原则的一个典型案例。
更玄的是p1与e1、e2相等,但e1竟然与e2不相等,似乎一个简单的等号传递都不能实现。这才是我们要分析的真正重点:e1.equals(e2)调用的是子类Employee的equals方法,不仅仅要判断姓名相同,还要判断工号是否相同,两者工号是不同的,不相等也是自然的了。等式不传递是因为违反了equals的传递性原则,传递性原则是指对于实例对象x、y、z来说,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。
这种情况发生的关键是父类使用了instanceof关键字,它是用来判断是否是一个类的实例对象的,这很容易让子类“钻空子”。想要解决也很简单,使用getClass来代替instanceof进行类型判断,Person类的equals方法修改后如下所示:
- public boolean equals(Object obj) {
- if(obj!=null && obj.getClass() == this.getClass()){
- Person p = (Person) obj;
- if(p.getName()==null || name==null){
- return false;
- }else{
- return name.equalsIgnoreCase(p.getName());
- }
- }
- return false;
- }
当然,考虑到Employee也有可能被继承,也需要把它的instanceof修改为getClass。总之,在覆写equals时建议使用getClass进行类型判断,而不要使用instanceof。
建议48: 覆写equals方法必须覆写hashCode方法
覆写equals方法必须覆写hashCode方法,这条规则基本上每个Javaer都知道,这也是JDK API上反复说明的,不过为什么要这样做呢?这两个方法之间有什么关系呢?本建议就来解释该问题,我们先来看如下代码:
- public static void main(String[] args) {
- // Person类的实例作为Map的key
- Map<Person, Object> map = new HashMap<Person, Object>() {
- {
- put(new Person("张三"), new Object());
- }
- };
- // Person类的实例作为List的元素
- List<Person> list = new ArrayList<Person>() {
- {
- add(new Person("张三"));
- }
- };
- // 列表中是否包含
- boolean b1 = list.contains(new Person("张三"));
- // Map中是否包含
- boolean b2 = map.containsKey(new Person("张三"));
- }
代码中的Person类与上一建议相同,euqals方法完美无缺。在这段代码中,我们在声明时直接调用方法赋值,这其实也是一个内部匿名类的操作(下一个建议会详细说明)。现在的问题是b1和b2这两个boolean值是否都为true?
我们先来看b1,Person类的equals覆写了,不再判断两个地址是否相等,而是根据人员的姓名来判断两个对象是否相等,所以不管我们的new Person(“张三”)产生了多少个对象,它们都是相等的。把“张三”对象放入List中,再检查List中是否包含,那结果肯定是true了。
接着来看b2,我们把张三这个对象作为了Map的键(Key),放进去的对象是张三,检查的对象还是张三,那应该和List的结果相同了,但是很遗憾,结果是false。原因何在呢?
原因就是HashMap的底层处理机制是以数组的方式保存Map条目(Map Entry)的,这其中的关键是这个数组下标的处理机制:依据传入元素hashCode方法的返回值决定其数组的下标,如果该数组位置上已经有了Map条目,且与传入的键值相等则不处理,若不相等则覆盖;如果数组位置没有条目,则插入,并加入到Map条目的链表中。同理,检查键是否存在也是根据哈希码确定位置,然后遍历查找键值的。
接着深入探讨,那对象元素的hashCode方法返回的是什么值呢?它是一个对象的哈希码,是由Object类的本地方法生成的,确保每个对象有一个哈希码(这也是哈希算法的基本要求:任意输入k,通过一定算法f(k),将其转换为非可逆的输出,对于两个输入k1和k2,要求若k1=k2,则必须f(k1)=f(k2),但也允许k1≠k2,f(k1)=f(k2)的情况存在)。
那回到我们的例子上,由于我们没有重写hashCode方法,两个张三对象的hashCode方法返回值(也就是哈希码)肯定是不相同的了,在HashMap的数组中也就找不到对应的Map条目了,于是就返回了false。
问题清楚了,修改也非常简单,重写一下hashCode方法即可,代码如下:
- class Person {
- /*其他代码相同,不再赘述*/
- @Override
- public int hashCode() {
- return new HashCodeBuilder().append(name).toHashCode();
- }
- }
其中HashCodeBuilder是org.apache.commons.lang.builder包下的一个哈希码生成工具,使用起来非常方便,诸位可以直接在项目中集成。(为什么不直接写hashCode方法?因为哈希码的生成有很多种算法,自己写麻烦,事儿又多,所以采用拿来主义是最好的方法。)
建议49: 推荐覆写toString方法
为什么要覆写toString方法,这个问题很简单,因为Java提供的默认toString方法不友好,打印出来看不懂,不覆写不行,看这样一段代码:
- public class Client {
- public static void main(String[] args) {
- System.out.println(new Person("张三"));
- }
- }
- class Person{
- private String name;
- public Person(String _name){
- name = _name;
- }
- /*name的 getter/setter方法省略*/
- }
输出的结果是:Person@1fc4bec。如果机器不同,@后面的内容也会不同,但格式都是相同的:类名 + @ + hashCode,这玩意就是给机器看的,人哪能看得懂呀!这就是因为我们没有覆写Object类的toString方法的缘故,修改一下,代码如下所示:
- public String toString(){
- return String.format("%s.name=%s",this.getClass(),name);
- }
如此就可以在需要的时候输出可调试信息了,而且也非常友好,特别是在Bean流行的项目中(一般的Web项目就是这样),有了这样的输出才能更好的debug,否则查找错误就如海底捞针呀!当然,当Bean的属性较多时,自己实现就不可取了,不过可以使用apache的commons工具包中的ToStringBuilder类,简洁、实用又方便。
可能有读者要说了,为什么通过println方法打印一个对象会调用toString方法?那是源于println的实现机制:如果是一个原始类型就直接打印,如果是一个类类型,则打印出其toString方法的返回值,如此而已!
建议50: 使用package-info类为包服务
Java中有一个特殊的类:package-info类,它是专门为本包服务的,为什么说它特殊呢?主要体现在3个方面:
(1)它不能随便被创建
在一般的IDE中,Eclipse、package-info等文件是不能随便被创建的,会报“Type name is notvalid”错误,类名无效。在Java变量定义规范中规定如下字符是允许的:字母、数字、下划线,以及那个不怎么常用的$符号,不过中划线可不在之列,那怎么创建这个文件呢?很简单,用记事本创建一个,然后拷贝进去再改一下就成了,更直接的办法就是从别的项目中拷贝过来。
(2)它服务的对象很特殊
一个类是一类或一组事物的描述,比如Dog这个类,就是描述“旺财”的,那package-info这个类是描述什么的呢?它总要有一个被描述或被陈述的对象吧,它是描述和记录本包信息的。
(3)package-info类不能有实现代码
package-info类再怎么特殊也是一个类,也会被编译成package-info.class,但是在package-info.java文件里不能声明package-info类。
package-info类还有几个特殊的地方,比如不可以继承,没有接口,没有类间关系(关联、组合、聚合等)等,不再赘述,Java中既然允许存在这么一个特殊的类,那肯定有其特殊的作用了,我们来看看它的作用,主要表现在以下三个方面:
(1)声明友好类和包内访问常量
这个比较简单,而且很实用,比如一个包中有很多内部访问的类或常量,就可以统一放到package-info类中,这样很方便,而且便于集中管理,可以减少友好类到处游走的情况,代码如下:
- //这里是包类,声明一个包使用的公共类
- class PkgClass{
- public void test(){ }
- }
- //包常量,只允许包内访问
- class PkgConst{
- static final String PACAKGE_CONST="ABC";
- }
注意以上代码是存放在package-info.java中的,虽然它没有编写package-info的实现,但是package-info.class类文件还是会生成。通过这样的定义,我们把一个包需要的类和常量都放置在本包下,在语义上和习惯上都能让程序员更适应。
(2)为在包上标注注解提供便利
比如我们要写一个注解(Annotation),查看一个包下的所有对象,只要把注解标注到package-info文件中即可,而且在很多开源项目也采用了此方法,比如Struts2的@namespace、Hibernate的@FilterDef等。
(3)提供包的整体注释说明
如果是分包开发,也就是说一个包实现了一个业务逻辑或功能点或模块或组件,则该包需要有一个很好的说明文档,说明这个包是做什么用的,版本变迁历史,与其他包的逻辑关系等,package-info文件的作用在此就发挥出来了,这些都可以直接定义到此文件中,通过javadoc生成文档时,会把这些说明作为包文档的首页,让读者更容易对该包有一个整体的认识。当然在这点上它与package.htm的作用是相同的,不过package-info可以在代码中维护文档的完整性,并且可以实现代码与文档的同步更新。
解释了这么多,总结成一句话:在需要用到包的地方,就可以考虑一下package-info这个特殊类,也许能起到事半功倍的作用。
建议51: 不要主动进行垃圾回收
很久很久以前,在Java 1.1的年代里,我们经常会看到System.gc这样的调用—主动对垃圾进行回收。不过,在Java知识深入人心后,这样的代码就逐渐销声匿迹了—这是好现象,因为主动进行垃圾回收是一个非常危险的动作。
之所以危险,是因为System.gc要停止所有的响应(Stop the world),才能检查内存中是否有可回收的对象,这对一个应用系统来说风险极大,如果是一个Web应用,所有的请求都会暂停,等待垃圾回收器执行完毕,若此时堆内存(Heap)中的对象少的话则还可以接受,一旦对象较多(现在的Web项目是越做越大,框架、工具也越来越多,加载到内存中的对象当然也就更多了),那这个过程就非常耗时了,可能0.01秒,也可能是1秒,甚至是20秒,这就会严重影响到业务的正常运行。
例如,我们写这样一段代码:new String("abc"),该对象没有任何引用,对JVM来说就是个垃圾对象。JVM的垃圾回收器线程第一次扫描(扫描时间不确定,在系统不繁忙的时候执行)时把它贴上一个标签,说 “你是可以被回收的”,第二次扫描时才真正地回收该对象,并释放内存空间,如果我们直接调用System.gc,则是在说“嗨,你,那个垃圾回收器过来检查一下有没有垃圾对象,回收一下”。瞧瞧看,程序主动招来了垃圾回收器,这意味着正在运行着的系统要让出资源,以供垃圾回收器执行,想想看吧,它会把所有的对象都检查一遍,然后处理掉那些垃圾对象。注意哦,是检查每个对象。
不要调用System.gc,即使经常出现内存溢出也不要调用,内存溢出是可分析的,是可以查找出原因的,GC可不是一个好招数!