读书笔记--编写高质量代码:改善java程序的151个建议(三)重写equals六大原则

读书笔记--编写高质量代码:改善java程序的151个建议(三)重写equals六大原则

自反性原则:对于任何非空引用x,x.equals(x)应该返回true

我们在写一个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方法还存在其他问题

对称性原则:对于任意的引用值x和y,当且仅当x.equals(y)时,y.equals(x)也一定为true

继续上面的问题,我们解决了覆写equals的自反性问题,是不是就很完美了呢?再把main方法重构一下:

public static void main(String[] args) {  
     Person p1 = new Person("张三");  
     Person p2 = new Person(null);  

     /*其他部分没有任何修改,不再赘述*/  
} 

很小的改动,那运行结果是什么呢?是两个true吗?我们来看运行结果: 列表中是否包含张三: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;  
} 

传递性原则:对于任意的引用值x,y和z,如果x.equals(y)为true,y.equals(x)为true,那么x.equals(z)也一定返回 true

我们继续讨论覆写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。

重写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方法?因为哈希码的生成有很多种算法,自己写麻烦,事儿又多,所以采用拿来主义是最好的方法。)

剩下的两个原则是:一致性(consistent)。如果x.equals(y)为true,并且x,y一直没有改变,则x.equals(y)为true.和x.equals(null)为false。这两个没什么好说的,不再赘叙。

推荐重写toString方法

最后在加一个比较推荐的好习惯,重写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方法的返回值,如此而已!

猜你喜欢

转载自blog.csdn.net/qq413041153/article/details/32152863